Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 89 additions & 7 deletions lib/ourocode/model/catalog.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ defmodule Ourocode.Model.Catalog do
alias Ourocode.Provider.Codex
alias Ourocode.Provider.Codex.Client

@ouroboros_config_path Path.expand("~/.ouroboros/config.yaml")

@cli_labels %{
claude: "claude cli",
codex_cli: "codex cli",
Expand All @@ -32,18 +34,20 @@ defmodule Ourocode.Model.Catalog do
end

@doc """
Picks the default active model: a ready CLI if one exists, otherwise
Codex (ready when signed in, else offered for `/login`).
Picks the default active model.

When Ouroboros has a configured runtime backend, ourocode follows that
backend first so the main session and MCP interview runtime do not silently
split across providers. If no shared preference is available, fall back to
Codex when ready, then any ready CLI, then Codex for `/login`.
"""
@spec default(keyword()) :: Model.t()
def default(opts \\ []) do
models = list(opts)
codex = Enum.find(models, &(&1.id == :codex))

cond do
codex && Model.ready?(codex) -> codex
ready = Enum.find(models, &Model.ready?/1) -> ready
true -> codex || hd(models)
case preferred_ouroboros_model(models, opts) do
%Model{} = model -> model
nil -> fallback_default(models)
end
end

Expand Down Expand Up @@ -83,4 +87,82 @@ defmodule Ourocode.Model.Catalog do
}
end)
end

defp fallback_default(models) do
codex = Enum.find(models, &(&1.id == :codex))

cond do
codex && Model.ready?(codex) -> codex
ready = Enum.find(models, &Model.ready?/1) -> ready
true -> codex || hd(models)
end
end

defp preferred_ouroboros_model(models, opts) do
opts
|> ouroboros_backend()
|> backend_model_ids()
|> Enum.find_value(fn id ->
case fetch(models, id) do
%Model{status: :unavailable} -> nil
%Model{} = model -> model
nil -> nil
end
end)
end

defp ouroboros_backend(opts) do
case Keyword.fetch(opts, :ouroboros_backend) do
{:ok, backend} -> normalize_backend(backend)
:error -> read_ouroboros_backend(Keyword.get(opts, :ouroboros_config_path, :default))
end
end

defp read_ouroboros_backend(false), do: nil
defp read_ouroboros_backend(nil), do: nil

defp read_ouroboros_backend(:default), do: read_ouroboros_backend(@ouroboros_config_path)

defp read_ouroboros_backend(path) when is_binary(path) do
if File.regular?(path) do
case Ourocode.Config.parse_config_file(path) do
{:ok, %{data: data}} ->
data
|> configured_backend()
|> normalize_backend()

{:error, _reason} ->
nil
end
end
end

defp read_ouroboros_backend(_path), do: nil

defp configured_backend(data) when is_map(data) do
get_in(data, ["orchestrator", "runtime_backend"]) ||
get_in(data, ["llm", "backend"])
end

defp configured_backend(_data), do: nil

defp normalize_backend(value) when is_atom(value),
do: value |> Atom.to_string() |> normalize_backend()

defp normalize_backend(value) when is_binary(value) do
value
|> String.downcase()
|> String.replace("-", "_")
|> String.trim()
end

defp normalize_backend(_value), do: nil

defp backend_model_ids("codex"), do: [:codex_cli, :codex]
defp backend_model_ids("codex_cli"), do: [:codex_cli, :codex]
defp backend_model_ids("claude"), do: [:claude]
defp backend_model_ids("claude_cli"), do: [:claude]
defp backend_model_ids("gemini"), do: [:gemini]
defp backend_model_ids("gemini_cli"), do: [:gemini]
defp backend_model_ids(_backend), do: []
end
12 changes: 11 additions & 1 deletion lib/ourocode/runtime/interview_router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,10 @@ defmodule Ourocode.Runtime.InterviewRouter do
# known footers so strict routing survives real provider wrappers without
# accepting arbitrary prose as a decision.
defp first_directive_segment(text) do
lines = String.split(text, "\n", trim: false)
lines =
text
|> strip_echoed_prompt()
|> String.split("\n", trim: false)

case Enum.find_index(lines, &(String.trim(&1) =~ @directive_re)) do
nil ->
Expand All @@ -239,6 +242,13 @@ defmodule Ourocode.Runtime.InterviewRouter do
end
end

defp strip_echoed_prompt(text) do
case Regex.split(~r/^\s*## Your reply\s*$/m, text, parts: 2) do
[_before, after_marker] -> after_marker
_no_marker -> text
end
end

defp cli_footer_line?(line) do
String.trim(line) in ["tokens used"]
end
Expand Down
135 changes: 127 additions & 8 deletions lib/ourocode/runtime/loop_bindings.ex
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,7 @@ defmodule Ourocode.Runtime.LoopBindings do

defp reuse_mcp_daemon?(nil, _current_backend, _requested_backend), do: false
defp reuse_mcp_daemon?(%{mode: :external}, _current_backend, _requested_backend), do: true

defp reuse_mcp_daemon?(_handle, current_backend, requested_backend),
do: current_backend == requested_backend

Expand Down Expand Up @@ -959,8 +960,25 @@ defmodule Ourocode.Runtime.LoopBindings do
on_reason: on_reason
) do
{:answer, payload_text, source} ->
push_dialogue(agent, :main, ensure_answer_prefix(payload_text, source))
followup(agent, st, payload_text, streak_after(st.streak, source))
if leaked_router_prompt?(payload_text) do
push_router_trace(agent, "router: discarded echoed prompt and asked user")
push_dialogue(agent, :main, "→ asking you: " <> clean_markdown(question))
enqueue(agent, ask_user_wonder_event(st.parent_call_id, st.round, question, []))

case await_user_answer(agent, st.parent_call_id, question) do
{:done, text} ->
push_dialogue(agent, :user, text)
enqueue_complete(agent, st.parent_call_id, :user_done)
:ok

{:answer, user_text} ->
push_dialogue(agent, :user, user_text)
followup(agent, st, ensure_user_prefix(user_text), 0)
end
else
push_dialogue(agent, :main, ensure_answer_prefix(payload_text, source))
followup(agent, st, payload_text, streak_after(st.streak, source))
end

{:ask_user, prompt, options} ->
# SKILL PATH 2: present as a wonderTool checkpoint (model-suggested
Expand Down Expand Up @@ -1160,7 +1178,11 @@ defmodule Ourocode.Runtime.LoopBindings do
when is_map(meta),
do: meta

defp response_meta(_response), do: %{}
defp response_meta(response) do
response
|> response_text()
|> decode_text_meta()
end

# Ouroboros writes the session id as `Session ID: <id>` (start),
# `session_id="<id>"` (resume hint), or bare `Session <id>` (resume). The
Expand Down Expand Up @@ -1284,7 +1306,7 @@ defmodule Ourocode.Runtime.LoopBindings do
when role in [:mcp, :main, :user] and is_binary(text) do
trimmed = String.trim(text)

if trimmed == "" do
if trimmed == "" or (role == :main and leaked_router_prompt?(trimmed)) do
:ok
else
Agent.update(agent, fn state ->
Expand All @@ -1301,6 +1323,24 @@ defmodule Ourocode.Runtime.LoopBindings do

defp push_dialogue(_agent, _role, _text), do: :ok

defp leaked_router_prompt?(text) when is_binary(text) do
flat = String.replace(text, ~r/\s+/, " ")

String.contains?(flat, [
"You are the answerer/router half",
"Routing rules (from the interview SKILL)",
"Tool protocol",
"Output exactly one directive as the first line",
"ANSWER [from-code] <answer>",
"ASK_USER <question for the human>"
]) or
(String.length(flat) > 900 and
String.contains?(flat, "ANSWER [from-code]") and
String.contains?(flat, "ASK_USER"))
end

defp leaked_router_prompt?(_text), do: false

# MCP wire-encodes the dialectic signal as `(ambiguity: 0.42) <question>`.
# The operator asked to see that score immediately, so the MCP turn keeps
# it inline instead of stripping it to the right-hand telemetry pane.
Expand Down Expand Up @@ -1427,10 +1467,28 @@ defmodule Ourocode.Runtime.LoopBindings do
%{state | interview: interview, paused: false}

:none ->
if state.interview && meta != %{} do
%{state | interview: merge_interview_meta(state.interview, meta)}
else
state
cond do
state.interview && meta != %{} ->
%{state | interview: merge_interview_meta(state.interview, meta)}

meta != %{} && interview_meta?(meta) && String.trim(text) != "" ->
prev = state.interview || %{}

interview =
prev
|> Map.merge(%{
question: clean_markdown(text),
parent_call_id: Map.get(event, :parent_call_id) || prev[:parent_call_id],
child_id: Map.get(event, :child_id) || prev[:child_id],
waiting: false
})
|> merge_interview_meta(meta)
|> Map.delete(:answered)

%{state | interview: interview, paused: false}

true ->
state
end
end
rescue
Expand All @@ -1439,15 +1497,76 @@ defmodule Ourocode.Runtime.LoopBindings do

defp merge_interview_meta(interview, meta) when is_map(interview) do
interview
|> maybe_put(:ambiguity, numeric_meta_value(meta, "ambiguity_score"))
|> maybe_put(:milestone, meta_value(meta, "milestone"))
|> maybe_put(:seed_ready, meta_value(meta, "seed_ready"))
|> maybe_put(:breakdown, meta_value(meta, "ambiguity_breakdown"))
|> maybe_put(:session_id, meta_value(meta, "session_id"))
|> maybe_put(:mcp_reasoning, reasoning_lines(meta))
|> maybe_put(:mcp_reasoning_state, meta_value(meta, "interview_reasoning"))
end

defp interview_meta?(meta) when is_map(meta) do
Enum.any?(
["internal_reasoning", "interview_reasoning", "ambiguity_score", "milestone", "seed_ready"],
&(not is_nil(meta_value(meta, &1)))
)
end

defp interview_meta?(_meta), do: false

defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, []), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)

defp numeric_meta_value(meta, key) do
case meta_value(meta, key) do
value when is_float(value) -> value
value when is_integer(value) -> value / 1
value when is_binary(value) -> parse_float(value)
_other -> nil
end
end

defp reasoning_lines(meta) when is_map(meta) do
meta
|> meta_value("internal_reasoning")
|> normalize_reasoning_lines()
end

defp reasoning_lines(_meta), do: []

defp normalize_reasoning_lines(lines) when is_list(lines) do
lines
|> Enum.map(&to_string/1)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.take(12)
end

defp normalize_reasoning_lines(line) when is_binary(line) do
line
|> String.split(~r/\r?\n/)
|> normalize_reasoning_lines()
end

defp normalize_reasoning_lines(_value), do: []

defp decode_text_meta(text) when is_binary(text) do
trimmed = String.trim(text)

if String.starts_with?(trimmed, "{") do
case Ourocode.Json.decode(trimmed) do
{:ok, %{} = body} -> body
_error -> %{}
end
else
%{}
end
end

defp decode_text_meta(_text), do: %{}

defp meta_value(meta, key) when is_map(meta) do
case Map.fetch(meta, key) do
{:ok, value} -> value
Expand Down
Loading