oppx agent engine: actions, guards against stuck turns, speed, truthful replies, context
Part of the developer notes; the index is in CLAUDE.md.
Documents and pictures (what read, outline, grep and create do with PDF, Office files, EPUB, pictures and videos) are in the documents topic.
-
What runs side by side (2026-10-16):
- The ask/edit check (
routing.wants_edit) runs in a thread (app.ROUTER) while the turn opens named files, finds matching documents and ranks related definitions.Agent.turntakes it as aFutureand waits only before its first model call. - The definitions index is warmed at session start (
Workspace.warm; 0.76 s for this repository with a cold tag cache), behind a lock, so a first message during the warm-up waits for it instead of building it twice. - Connectors start in parallel threads under one shared deadline; joined one by one, several stuck ones used to add up their timeouts.
- What stays sequential, on purpose: the steps of a turn (each depends on the model’s last action), and a request’s lookups (local and fast).
- The ask/edit check (
-
agent(oppx_chat/agent.py, no UI code;app.make_agent/agent_turnconnect it): the model reads the repository on demand, like Claude Code, instead of being sent whole files and a repo map with every request. Each step it picks one action as JSON constrained by a schema (model-agnostic, no tool-call parser; one schema per tool underanyOf, since with a single flat schema models put a path inqueryorendon a grep; a server that refusesanyOfgets the flat one):find(definitions and files by name in any spelling, from Aider’s tags plus a regex, built once per session),grep(git grep, grouped by file, code before documentation, at most 40 lines and 6 per file, the rest as counts),list,outline(Aider’s tree-sitter tags, regex fallback),read(line ranges; files up to 300 lines whole, ±6 lines around a range),plan(a short free-text plan),edit,create(a new file, or the whole of one that was read),run(asks y/N),start(a command that keeps running, such as a dev server),web_search(/v1/search),answer. A path that doesn’t exist comes back with the real ones it may have meant. Every action opens with"enough": true|falseand, when false,"missing": the one thing still needed. Text payloads are then written free-form in a second call: edits as Aider SEARCH/REPLACE blocks applied with Aider’s fuzzydo_replace, the reply streamed. Calls carryoppx_utility, so the gateway’s search router stays out. -
The model decides what a message needs. The ask/edit check (
routing.wants_edit) is a hint, not a gate: edit tools are always there unless the user switched them off (plan mode,/ask), so nothing has to be typed to get an answer or a change. On a message that reads as a question, the firsteditorcreateis answered with one check (“change it only if they asked”); chosen again, it runs. A change request that names no file gets a forced plan before its first edit (every place that must change, in order). -
A follow-up is read with what came before it (found in a real session: asked to add a sort feature, the agent got stuck, then answered “Now I’ll implement the sort feature”, and “yes, go ahead.” got a second promise and no edit):
- Cause: the ask/edit check saw the latest message alone, and on its own “yes, go ahead”, “go ahead”, “yes” and “continue” are questions (measured on Qwen3.6-27B: all four came back
ask; “do it” came backedit). Every guard that pushes a turn to an edit hangs on that answer (the first move is neveranswer, the “you haven’t changed anything” nudge, the step with only the doing tools), so the turn ended on its first action,answer. - The check gets the conversation (
routing.intent_input): the end of the assistant’s last reply (600 characters) and the change still owed, if any. The prompt says how to read a message in that light (a go-ahead after an offer or an announcement, “continue” or “where is the edit?” while a change is owed, an answer to a question the assistant needed:edit; a refusal, thanks, a question about something else:ask), with examples. - A change still owed (
Agent.pending): a change request that ended with nothing changed and no connector call (stuck, interrupted, failed, or ended on a promise) is kept, and given to the check with the next messages. The turn that changes something settles it; three turns about something else,/clear, or plan mode forget it. - A reply that announces a change is held to it (
Hooks.announces,routing.announces_change, one yes/no call after a reply in a turn that changed nothing): “Let me make the changes now” is followed by one step with onlyedit,create,runandstarton offer, then the turn goes on as a change request. An offer (“Want me to?”) or an answer is left alone and waits for the user. Once per turn; not in plan mode; not after a stuck turn. - Measured on Qwen3.6-27B (
scripts/probe_routing.py --reasoning, median 110 ms a call): first messages 16/16 as before; follow-ups read with the last reply 24/24 (23/24 at first: a bare “continue” with nothing before it came backedit, and the case’s expectation was changed to that, since it is what a resumed session means by it); the announcement check 16/16. Not yet run on Qwen2.5-Coder-14B or gpt-oss. - In a real chat (dev build, 0.7.1 server): “What would it take to add a sub function…? Don’t change anything yet” changed nothing; “yes, go ahead.” then planned, edited
src/lib.rsand rancargo check. The announcement path is unit-tested only.
- Cause: the ask/edit check saw the latest message alone, and on its own “yes, go ahead”, “go ahead”, “yes” and “continue” are questions (measured on Qwen3.6-27B: all four came back
-
A command that keeps failing the same way (the same session:
data[0]on a JSON object, sent five times, with the fix written out in “Taking stock” and “Rethink” in between):- The action after a refused repeat is sampled (
RETRY_TEMPERATURE0.7,Agent._sample): at temperature 0 the same conversation gives the same action again, whatever the model wrote in plain text before it. (That this was the cause there is an inference from the transcript, not something measured.) - A command that failed twice stays refused until a file is edited (
failed_times). Another command that works still lets one that failed once run again (an install, then the build), but aprint(keys)in between no longer lets a broken one-liner run again and again. - The refusal says what happened (“the command you just sent is, character for character, the one that failed: if you worked out a fix, it is not in it”), and so does the stuck reason (“no corrected command was ever sent, so none was rejected”): the reply had told the user the system rejected its corrected command.
- The action after a refused repeat is sampled (
-
“Let me read the file first” as the text of an edit (seen in a real session:
Error: no SEARCH/REPLACE block found in your reply, which began: 'Now I need to add the thumbnail… Let me read the current state of the file first.' {"tool": "read", …}): the model had chosenedit, and when asked for the edit’s text found it needed the file as its own earlier edit had left it. Nothing was written and the next step recovered, at the cost of one model call, an alarming line on screen and a failed edit on the count. Now a reply without a SEARCH block that carries a lookup as JSON (misplaced_lookup:read,grep,find,list,outline) is that lookup: it runs in the same step and the result opens “Not edited yet: you said you need to look at something first, so that was done” (LOOKED_FIRST); it counts neither as a change nor as a failed edit. Any other text without a block is still the error. Forcreate, the same; and a reply that is a sentence plus any action is refused instead of being written into the file as its content (it would have been). Unit-tested; not yet seen in a real chat. -
A page nobody looked at (after a React change the agent ran the lint and said “Lint passes”; “did you check your code on playwright?” was the user’s next message, and they need not have known it could): when files that show in a page were changed (
PAGE_FILES:.jsx,.tsx,.vue,.svelte,.html,.css…) and no browser tool ran in the turn, the note before the reply says a lint or a build doesn’t show whether it works in the page and to look (thebrowseraction, which asks the user once, or theplaywright.*tools when it runs), and the reply’s facts say to state that it wasn’t checked in a browser and that it can be. After a “no” to the browser the reply only says it wasn’t checked. Unit-tested; not yet seen in a real chat. -
Confirmed gaps closed (CHO-10), each checked against the code first:
- An edit to a file that changed on disk is refused (
Workspace.seen,mark,check_current): a digest of each file is kept from when the model last read or wrote it;edit, andcreateover an existing file, compare it first and answer “…has changed on disk since you read it (the user, a formatter or a command changed it). Read it again…”. The read that follows is allowed (a read after a refused edit is not a repeat) and the edit then goes in on top of the other change. The agent’s own edits update the digest, so two edits in a row need no read in between. A file/undorestored counts as changed. - The question before
startreads “Start this command and leave it running? (it is stopped when the chat ends)” (Agent.asking, read byapp.on_confirm). Seen in a real chat. - A web search step’s summary is “2 results:
…”, not the heading of the result text; “(from your earlier search, not searched again)” when it was answered from the session. - Plans: one cut at its token limit ends on its last whole line or sentence (
whole_sentences; a list number is not a sentence end), a request with several parts gets 600 tokens for it (PLAN_TOKENS_LONG), andplanchosen right after a plan is refused as a repeat without counting towards the two plans a turn may have. - Still open there: no action reads a web page; one started command can’t be stopped or restarted; a resumed session restores the messages, not what the tools returned.
- An edit to a file that changed on disk is refused (
-
Never stuck in silence (found on Qwen3.8-27B, where a question ended with
{"tool": "answer", …}as the reply):- Circles: an exact repeat, a re-read of lines already shown, a reworded search with the very same result, or one pattern grepped in a third file in a row counts as a repeat (not when the earlier result was cut from the window; then it may be fetched again). Two repeats force one Rethink in free text (what is known, what is missing, an action not tried yet, the top-level folders not looked in). Two more end the turn as stuck.
- Long turns: every 10 steps without a change the model has to take stock in free text. Without it, a simple question ran to 36 steps of file-by-file greps.
- Stuck is said out loud: the UI prints
⚠ Stuck: <reason>(Hooks.notice; repeats, or all 40 steps used), and the reply must open by saying so, then what was found, what is missing and what would unblock it.Agent.stuckholds the reason. - The reply is always readable: it is held back while it could still be a JSON action (the conversation is full of them) or empty; such a reply is asked for again with the first character constrained by regex, and if that fails too the harness writes its own (“I couldn’t produce an answer… What I did: …”). JSON the user asked for is shown.
- Dot folders:
Workspace.pathstripped leading.and/characters, so.github/workflows/release.ymlcould not be opened and an edit task ended with “paste the file”.
-
Speed (timed on split Qwen3.8-27B: about 70% of a turn is the model writing its steps at 36 tokens/s, 25% reading new tool output; start-up is 1.6 s for a trivial
-prequest):- A starting point: when the message names no file, definitions whose names contain its words (whole name parts, rarest first, tests left out;
Workspace.related) are given before the first step. A “pairing codes” request had cost ten greps in the wrong folders. - Where else: a grep limited to a folder also lists the files outside it that match.
- Several lookups in one step: a lookup may carry
"then": up to two more lookups (find,grep,list,outline,read), run without another model call. - Fewer tokens a step:
missingis at most 90 characters; a plan or stock-take at most 350 tokens. Stock-taking counts model calls, not lookups. - Edit text: an edit or new-file prompt answered with the JSON action again (four times in a row once) is asked for again inside the same step, first character constrained; the action reads as a sentence during that call.
- A change request never opens with
answer: with likely definitions in view the model declared it had enough and offered to make the change (“say the word”); the first move of such a turn has noanswer.
- A starting point: when the message names no file, definitions whose names contain its words (whole name parts, rarest first, tests left out;
-
Measured on split Qwen3.8-27B (3090 + 4090, 64k context),
scripts/eval_nav.py(10 questions about this repo that name no file, each reply checked for the fact and the file): 10/10 answered, 5.3 steps and 23 s on average, none stuck, no unreadable reply, nothing edited. Before the speed changes: 6.4 steps, 30 s. With a freewhyin place ofenough/missing: 10.9 steps, 42 s, one stuck.scripts/eval_edits.py(9 tasks, three that name no file): 9/9, 17–41 s each except two at about 90 s, where the model also updated the docs and messages that state the old value. Unit tests with a scripted model:scripts/test_agent.py. Not yet run on a small model. -
Checked or said to be unchecked: before the reply to a turn that changed files, the model is told once to run the project’s quick check for what it touched (through
run, so the allowlist and the y/N/a question apply), and the facts given with the reply say how that went: nothing run (“say they are not verified”), the last command after the latest edit failed (“say the check failed”), or it passed. A “Done.” for an edit nothing had been run against is what prompted it. On Qwen3.8-27B in-pmode (which can’t ask): it edited, readpackage.json, triedcd app && npm run check, was declined, and the reply ended “the change is unverified”. A plan or stock-take is shown without the JSON action a model tacks onto its end (strip_action). -
Guards for small models (each found in the evaluation): exact repeats and re-reads of lines already shown are refused, and after two only
answeris allowed; reading a big file page by page gets a “grep instead” hint; answering from grep hits alone, or ending a change request with nothing changed, gets one nudge; before answering a change, the model is told which files it actually changed (it claimed edits it never made); a file must be read before it can be edited; an ambiguous SEARCH (matching several places) is refused with the line numbers; a failed SEARCH returns the closest real lines; an edit that changes the{}()[]balance gets a warning; an empty edit from a reasoning model is recovered fromreasoning_content. Two failed web searches in a turn take the tool away (it rephrased a failing search 14 times). In plan mode or/askthe message says edits are off; without that, asked for a change, it answered that it had made it. Files named in the message are opened up front (small whole, big as an outline);/addand@filefiles are listed for the model. Edit replies without the filename line above a SEARCH block get it added (the agent knows the file; Qwen3.6 dropped it every time). Truthful replies: an edit counts as a change only when it applied (a refused one gets “Nothing was changed”; counting the attempt made the “files you changed” check confirm an edit that never happened). Every reply is written with the facts: files changed in this request, files changed earlier in the conversation (Agent.changed_session;/undoremoves them), and, when a change was asked for and none was made, an instruction to open with that. Repeat guard (_held_back): a reply made only of sentences from the last 6 replies to other messages is held back while it streams, never shown, and written again once with the earlier copies hidden for that one request and temperature 0.7 (with them in view, Qwen2.5-Coder-14B wrote the same text again). A reply that opens with one new sentence and then repeats is still shown. Replayed on Qwen2.5-Coder-14B (the change request it gives up on, then “Did you make that change?” and three follow-ups): before, it described a change it never made and repeated that five times; now every reply says nothing was changed, andeval_edits.pystays 6/6. On reasoning models every call sendschat_template_kwargsreasoning_effort: lowandenable_thinking: false(with only the first, Qwen3.6 put its whole action intoreasoning_contentand the turn ended with raw JSON as the answer); an empty content falls back toreasoning_content. -
Context: the conversation only grows by appending, so SGLang’s prefix cache serves most of each request; near the limit, old tool output is cut first (the latest 6 kept), then the oldest turns. The system prompt (rules, top-level folders,
OPENPHALANX.md/OPPX.md/AGENTS.md) is built once per session. Sizes are estimated at 3 characters a token, corrected by the largest real/estimate ratio the server has shown this session (usage.prompt_tokens; Qwen3.6 ran ~5% over on code, and prompt +max_tokensover the window is a 400). A context refusal sets the ratio from its numbers, fits again and resends once. Once over budget it cuts down to 75% of it (FIT_LOW): cutting just enough changed an early message on every step near the limit, so each step recomputed the whole ~28k-token prompt (29–32% cache hits)./contextand/costshow the agent’s own numbers.