mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
8a6a4f20d61bcf2ef3a7b4801fa497842974db73
106
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8a6a4f20d6 | feat: modernize studio interface and session recovery | ||
|
|
15efab5476 |
feat(turn): control owns the authored turn boundary across planner and executor runs
A turn had as many starts as it had model runs. The coordinator opened one before planning, the executor opened another before working, and a plan approved mid-turn opened a third — so "a turn began" meant "an agent run began", and the only thing telling the two apart was whether the event happened to carry an identity. The route that made this worse than untidy is production-reachable. When the planner concludes no changes are needed, no executor ever runs: the user's message is written straight into the transcript by the coordinator, and the turn's only start was the bare one from before planning. So a durable authored message existed that the live stream had never named — the row got no rewind entry until the session was reopened and the transcript rebuilt. Two truths about the same message, which is the thing this series exists to remove. Control now opens the boundary. It already had both numbers before any work could produce the message: the index it opens the checkpoint at and marks the turn in flight with, and the raw authored text it puts in the context itself. It publishes them as one TurnStarted before the runner is called, and hands the runner the identity to land on. Four seams take it: the orchestrated turn, the headless one, a synthetic continuation (which carries ownership with no identity, because it belongs to a turn already announced), and the slash-invoked subagent skill — that last one was a fourth start emitter and a third place that appended the user's message, found while doing the work rather than in the preflight that preceded it. Identity is the host's; representation stays with whoever writes the message. A plan-and-execute turn still persists the handoff as Content while the user's own words stay in RawContent, and that distinction is worth keeping. What the seam takes away is the freedom to disagree about which message the turn was about: the raw authored text comes from the published identity, and the landing is checked against it. The check detects and cannot repair — the name is already out — so a break is an operator notice with a code, and the durable index stays the authority a reload reads. persistExecutorNoOp lands through that seam now rather than composing its own message beside it. It had been setting RawContent empty whenever nothing wrapped the input, which left the classification reading a different field on that path than on every other; it agreed with the others by coincidence, and the coincidence is now a contract. Suppression is declared, not observed. A run reads a host boundary out of its context; nothing infers that it has been announced because an event already reached the sink. A standalone Agent.Run is still its own lifecycle owner and still announces — that contract is not collateral to a desktop fix. The coordinator's filter on planner lifecycle events is NOT dead and was kept. Under a host boundary the planner announces nothing, but standalone it still opens a turn over its own session, and that name would reach the parent as a second answer to which message the turn is about. A test runs both ways rather than a grep deciding it. Six production routes are now a matrix rather than an observation: executor-only, plan-and-execute, conclude-no-changes, an approval the user denied, planner failure falling back to the executor, and the subagent skill. Each asserts one named start, no bare one, one end, that the start precedes every visible piece of work, and that the message it named classifies as the authored turn it said. Each also asserts whether the executor ran, so a route that quietly took another branch cannot satisfy the matrix. The negative control holds the other end: an approved plan's execution opens neither a start nor a done of its own. Two deliberate behavior changes come with the ownership move. The text renderer resets its per-stream state on a phase marker instead of on a second turn start, so the executor's thinking still gets its own header now that planner and executor are one turn; a byte-exact dual-model fixture pins that boundary. And telemetry's turn count changes meaning: it counted starts, so a two-model turn counted twice and an approved plan counted again. It now counts authored turns, which is what the metric was always named after. Not in this cut: which items belong to a turn, and where an execution episode begins inside one. |
||
|
|
d9e9e4328f |
feat(turn): live turn start names the authored message it is about
turn_started carried nothing, so a client that mints its own user row had no way to learn which message the kernel had just written for it. The row and the snapshot taken for it were paired by position and confirmed by prompt text — which is why two identical prompts could only be told apart by luck, and why a steer sitting in the transcript shifted every pairing after it. The event now carries the message's provenance: AuthoredTurn, the number the conversation gives the turn this message opens, and MsgIndex, the session index it takes. They are two namespaces and neither is the checkpoint's Turn, which numbers snapshots — the first authored turn is 1 while the first checkpoint turn is 0, and TestCheckpointTurnIsNotTheAuthored- Turn reads both off one real run so an implementation that sourced one from the other cannot pass. One rule answers both projections. ClassifyTurn is what the display-index sidecar already counted a transcript with, now named and exported, and the live path asks it the same question about the message it is about to append. The message is composed before the turn is announced so the announcement can classify what actually goes out rather than a stand-in for it, and the prior count is re-derived from the transcript rather than tracked beside it — a second counter is a second thing to be wrong. A turn that opens no authored message (a steer, a host-injected turn) names none rather than minting a number. The join then goes structural on both sides. checkpoint.Meta carries the boundary it was opened at, /checkpoints and /history publish it, and pairCheckpoints is a lookup on that index — no position, no prompt equality. A row that names no message gets no rewind entry at all, which is the right refusal: a rewind cannot be undone, and an entry against the wrong turn throws away work nobody asked to lose. The client joins by causation, not by adjacency: a send is remembered until the kernel names a message for it, in the order the sends were made, and a name that no longer names a row — taken back, or replaced by a rebuild — is dropped rather than handed to the next turn. Guidance queued mid-turn never joins that line, because it starts no turn of its own. Six proofs, at the three boundaries: the agent alone names its turn (that layer cannot import control, so no checkpoint state can reach the number); the live number and the durable display index agree about the same message; a steer and a synthetic turn each mint nothing and leave the number for the next real turn; the checkpoint boundary equals the index the event named; and at the HTTP boundary two identical prompts pair with their own snapshots. Not in this cut: the other eleven item kinds still carry no turn. Which items belong to a turn, and where an execution episode begins inside one, is a separate question — this only establishes the boundary it would be asked about. |
||
|
|
584bf5cdb1 |
fix(agent): derive the host's step identity, stop storing it
The gate found two notes and one of them was stale. A fold froze the note into
its body; the next fold carried that body's remainder forward, which is what a
body suffix is for — historical material with no canonical counterpart. A step
identity is not that. It is what the host holds now, and a frozen copy outlives
the state it names: the model was shown ids the host had already dropped, and a
restart reproduced them exactly.
So the note is derived for each request and stored nowhere. Owed is recomputed
from the ids the host holds against the history that request actually carries,
which is the judgement withTodoIdentityProjection already made — only against
the wrong thing, at the wrong time, with the answer kept.
todoIdentityShown is deleted rather than redefined. It recorded that some
earlier request showed the ids, which cannot say whether this one can: the same
mistake as validating a projection by a transcript version, a scalar witness
standing in for a fact about content. With the derivation in place there is
nothing for it to answer, and nothing to persist across a restart either.
Both storage paths are gone with it. The compaction producers no longer carry
host state into a body, and the run loop no longer writes the note into the
canonical transcript. What replaces them is one seam:
modelVisibleHistory() frozen body + canonical tail
modelVisibleMessages() that, plus what the host derives for this request
A fold plans on the history. The request carries both. So the note can never
become digest input, kept history, a body suffix, coverage, or sidecar
material, and admission still prices it — a note that cannot be folded is not
free, and counting only the foldable part would make every fold look cheaper
than the request it produces.
The gate now holds, and not vacuously: with the ids folded out of history the
request carries exactly one note, after the live tail, naming the ids the host
holds now — and the same on the far side of a process boundary, derived rather
than restored.
Two things this surfaced. The loop fixture ended a round on "the last message is
a tool result", which a tail note makes false; it now looks past host state,
because that note says nothing about whose turn it is. And the gate itself
demanded a note unconditionally, which would have passed a migration that
appended one to every request — it now reports whether the history still showed
the ids, and expects no note when it did.
|
||
|
|
1f48317164 |
feat(cache): say whether the body a request carried is the one it sent before
PrefixShape hashes the system prompt and the tool schemas. The conversation is not in it, so a full miss on a prefix the host itself hashed as unchanged had no attribution at all: the diagnostic could not tell a dropped cache from a message the host rewrote behind the boundary. session_types.ts already claimed the hash "tells changed from the endpoint dropped it" — with the body unobserved, it could not. BodyChain hashes the provider-visible messages cumulatively, one entry per message, so two requests compare at the length of the shorter and a rewrite anywhere in the carried region moves every entry after it. It is taken from the request that goes out, not from the conversation: a local-only message never reaches the provider, and reporting a change to one as a cache change is the mistake CompareShape's own comment already warns about. A rewrite the session declared is already named through contentReasons. One it did not declare gets "body_unreported" — the case this observation exists for, because nothing else can see it. Measured over 21 sessions before this: 118 full misses on an unchanged prefix, 10.6M prompt tokens re-paid, with cacheDiagnostics reporting prefixChanged false on every one and nothing able to say who dropped them. The desktop panel gets the second pill beside "前缀未变", so the answer reaches the person who can act on it. event.go was at its 800-line ceiling and session.ts at exactly 800, so CacheDiagnostics moves to its own file and the metrics fold joins the usage helpers it belongs with — both lower a budget rather than widen one. |
||
|
|
0382481ea9 |
refactor(agent): lift two host-message steps out of runToolLoop
The boundary classification and the identity re-projection both landed inside the loop body and pushed it past its size limit. Neither is loop control: one settles what changed the conversation since the last request, the other states a fact about the response that just ended. |
||
|
|
3105838461 |
fix(agent): project host-owned step ids where the model can read them
complete_step calls step_id PREFERRED, and the host has held one for every item since plan projection. The model was never shown one. todoState never rides in the prompt, todo_write answered with counts alone, and all three list renderers printed bare ordinals — so the only place an id ever appeared was the model's own todo_write arguments. A fold takes that out of view and the id is gone from the conversation while the host still holds it. What the model then cites is the ordinal it was shown, which is exactly the citation that goes stale when a replan inserts a step above it. evidence.TodoCitation is now the single rendering, used by todo_write's result, complete_step's inventory, and both incomplete-todo prompts. The fold carries the ids in its own output rather than leaving them to the round after it. The request a fold feeds is frozen before that round exists, so a sign-off sent in it could still only cite an ordinal — the same failure this change exists to remove, reproduced once per compaction. A fold whose kept tail still shows the ids appends nothing. Rewind and snip go through Session.Rewrite instead and are re-projected at the next turn tail. Compaction never calls Session.Rewrite — it installs a projection and leaves the canonical transcript alone — so a rewrite-reason trigger would have tested green against a signal the real fold path never emits. Not covered, and separate: rebuildTodoState derives canonical todos from the transcript's todo_write calls, so a resume after a fold that dropped them starts with no canonical list at all. The identity does not survive there to be projected. |
||
|
|
7d4d582e53 |
fix(agent): classify the response boundary before committing a turn
A response cut at the output limit reached the model as "invalid arguments for bash; it requires "command"". The host knew the cause — finish_reason was length — but finishReasonMessage only ever emitted it to the sink, so the model saw a downstream symptom and attributed it to its own formatting. The lesson a real session drew from this was to stop batching independent calls, which is the opposite of what the tree asks for and costs a round per call. classifyResponseBoundary runs before the assistant turn reaches the conversation. A truncation can only have cut the last call, so that is the only one it drops, and only when its arguments do not parse: earlier calls were emitted whole and still execute, and a cut that landed after the last call closed drops nothing. The dropped call neither runs nor enters history, and the reason rides the turn tail as a host fact. stopUnexecutedBoundaryCalls is not this: it fires on the round budget and never reads the usage it is handed, and by the time it runs the assistant turn is already committed. malformedArgumentsDetail now quotes the bytes the parser stopped on with a caret under them. An offset into a payload the model cannot see is what got '>' blamed for a missing delimiter. |
||
|
|
c963ebc84c |
feat(plan): make the plan lifecycle a host-owned state with authority epochs
The plan workflow was an atomic bool carrying two questions at once — is the
user in the workflow, and what may run right now. A bool cannot say
AwaitingApproval, and it cannot say that approving grants capability to the
work that follows rather than to whatever was already in flight: flipping it
admitted every tool call the model had streamed while planning, retroactively.
planmode.Runtime holds one canonical State{Phase, Epoch}. Active is derived, not
stored, so there is no second field to keep in step. Every accepted transition
raises the epoch — Submit and Revise included, because each changes which
authority is current, and deciding case by case which one "really" counts is how
stale work finds a way back in.
Transitions compare against the state the caller last saw. An approval answered
after a revise finds Planning@N+1 where it expected AwaitingApproval@N and does
nothing, so a late click is harmless rather than merely unlikely.
Each provider round is stamped with the state it was sampled under, before
sampling rather than at execution: what matters is the authority the model
produced the call under, which the state at execution time cannot report. A call
whose origin epoch is no longer current is refused as stale, and told so, since
the reason is what sends the model to the current state instead of a retry. The
admission the gate records carries its epoch too, so the execution assertion
checks freshness — it still re-classifies nothing.
ResolvePlanDecision becomes the transition surface it already described:
revise and exit move the lifecycle before the waiting turn is answered, while
start stays with that turn, which holds the state it submitted and is the only
party that can tell an approval from a late one.
status.plan stays a bool. It is now a projection of the lifecycle for wire and
UI compatibility — true while the marker rides the turn, false once a plan is
approved, exactly as the flag behaved. Nothing in the kernel reads it.
|
||
|
|
8ae4fc4c29 |
feat(jobs): a finished background job wakes the session that owns it
The execution layer was already asynchronous; the agent's control flow was not. A background job ran to completion, queued a sentence, and then waited for the user to say something before the model could learn it had finished. The only way for a turn to act on its own background work was to poll it with wait, which is what #7948 reports. Nothing here builds a second runtime for that. The session inbox already is one: durable, idempotent, crash-recoverable, with a level-triggered dispatcher that admits work only when the session is idle, and a steer path that consumes guidance at the run loop's model-round boundary. What was missing was one wire between the job table and it. So jobs publishes a CompletionEvent -- an identity, not a sentence: completion/<job-id>/terminal-v1 is derived from the job, so a delivery path that runs twice can tell a replay from a second completion. The controller takes it and translates it into a host continuation on the queue it already owns. Nothing under the frontend learns what an LLM is: returning nil from the observer means the event reached durable storage, never that a model read it. The observer fires after close(j.done), not with the note. recordCompletion stays where it was for the reason its comment gives -- an observed terminal status has to imply the note is queued -- but a wake-up is different in kind: it can start a turn whose first question is about this job, and a turn that can still observe Running has been told a lie. Which delivery a completion gets is never decided by reading whether a turn is running. The turn can end in the gap between that read and the enqueue. The steer attempt itself reports which world it is in: taken means the live turn has it, refused converts the item to a follow-up and kicks the dispatcher. Both notification paths survive, and exactly one delivers. A completion the observer took is acknowledged out of the manager's own queue; one it could not take stays there and rides the next real user turn. A session with no durable inbox (headless run, e2ebench) degrades to the old path rather than failing. Host attribution now travels with the item instead of with the call site. PromptOrigin lives in the manifest, not the envelope -- one place to read, and nothing for UpdateItem to fork when it rewrites a blob. A host item steers through SteerHostItem, runs synthetic when it degrades to a follow-up, keeps its attribution when an abnormal exit records it unapplied, and is named as a background follow-on in the queue panel rather than as a line the user typed. Invariants locked by tests, each verified by making it fail: - the observer runs before terminal publication -> status reads "running" - the host dispatch is dropped -> the model is told the user spoke - the legacy note is not acknowledged -> the next turn hears it twice Plus an end-to-end over the real jobs.Manager and Controller: a background job finishing is enough to give the session a turn, with nothing left queued. Known gaps, both deliberately out of scope: - A host follow-up persists as a user-role message, so previews, titles and user-turn counts still read it as the user's. Fixing that wants a structured marker on provider.Message, which also retires the 17-prefix SyntheticUserPrefixes table -- a change of its own. - The jobs -> inbox handoff is not yet crash-safe: a process that dies between terminal metadata and the enqueue loses the wake-up. The deterministic event id makes redelivery free, so reconciliation at bind is a separate, small change. Cache-impact: none -- the system-prompt prefix is untouched; a continuation rides the turn tail like every other host notice. Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged) Documentation-impact: none -- no docs/*.md describes the completion note path. Repolint: no budget widened; agent.go 1617->1597, controller.go 5056->5041, jobs.go 1174->1141 as the moved code lands where it belongs. |
||
|
|
e44b3871d2 |
fix(agent): a turn's check baseline is captured before its tools run, not by them
checkContract carried a lazy write: the first caller to ask what the task owed captured the baseline the task is held to. Every parallel tool call asks as it finishes, so that first ask arrives on several goroutines at once — -race reports the read at final_readiness.go against the write beside it, through obligations -> finishToolExecution -> executeOne -> runParallel. Two baselines could overwrite each other, and the baseline is what decides whether the turn's delivery claim stands. The capture moves to beginRunTurn, where nothing else is running, and the ask becomes a pure read. That is also the stronger reading of its own rule: the baseline must come from the pre-image, before any call could rewrite the declaration it is taken from. Guarding the write instead would have kept a mutable field on a path that has no reason to write at all. TestCancelInsideLargeParallelBatchStopsSchedulingNewTools and TestBuildRegistersUsableHistoryAndMemoryRetrievalTools both go green under -race; both failed on it before. |
||
|
|
03a2c8c412 |
feat(evidence): the host owns provenance, and actions cannot vouch for themselves
Five holes let a turn answer the gate that was auditing it. Each is closed by the same move: the fact comes from what the host observed, never from what execution was free to rewrite. - A `go test ./...` quoted inside a here-doc body satisfied a declared project check that never ran. commandSegments split the raw text on separators, so the body's lines became segments. SplitOutsideHereDoc drops the body and keeps the commands beside it. - A here-doc cost the whole command its decomposition, so a turn that ran its tests next to one read as having skipped them, and `python - <<PY` walked past the Delivery gate that refuses `python -c`. The shell tables now read the same fact permission already reads. - `cd backend && go test ./...` shared an identity with the root run, so a green root suite cleared a red subdirectory one. Canonicalisation now drops spelling and plumbing, never the context a segment before the check decides. - An unclassified receipt took the pipeline's exit status for the check's, so `go test ./... | tail` could clear the failure it was hiding. - missingMutation was wired end to end and never incremented. An unprovable change is now a debt only observation settles; a check that runs afterwards speaks for the state, not for the effect. Obligations are derived by diffing the ledger, never announced: nothing can emit or erase its own debt, and the delta reaches the model with the result that made it. Delivery stops refusing opaque interpreters — what the host cannot audit is recorded unproven and owed, not blocked at the door. Criteria the task began under are the host's. Declared checks keep a captured baseline the current declaration cannot retract; test criteria are captured from the pre-image before any call that could overwrite them, and evaluated through `go test -overlay` against the code that replaced them. A rewritten test passing answers for itself, not for the bytes it replaced. Also: the credentials error names the file it reads instead of an env var it never consults, the MCP rail shows the way out instead of the endpoint's words, and run --metrics carries the turn's own verdict beside its outcome. |
||
|
|
4d320f7cd7 |
fix(agent): a change stays in review's scope until a review has covered it
The review gate read one index for two questions — what the change set is, and how fresh a review of it must be — so a later harmless write moved the window past everything before it. A sensitive edit followed by a README write scored Low and dropped the whole structured-review demand. UnreviewedMutationBaseline now answers the first question and the latest mutation still answers the second. It advances per review kind, so an accepted review does not discharge security_review's obligation with it. Alongside, the parts of that gate that promised more than they did: - A blocking verdict is honored at every role setting, and review subagents submit the typed report everywhere. A verdict a caller must act on carries an identity, not a sentence. The nudge runs everywhere too; the hard failure stays in Delivery, since outside it no report is no block either way. - Medium no longer demands a structured review "or" host-proven self-inspection. That was no alternative — the reader there is the author — so the cheap side was always taken and the demand never bound. The real requirement inside it moves to the self-inspection gate and grows teeth: inspect every changed path, fresh per path, not one of them. - warn findings were collected into a slice nothing read. They now reach the user, and they are collected from any review the turn ran rather than only from one the gate asked for. - verificationGapResult.advisory carried no text and reached nobody while two comments called it reported. The waiver it stood for is right and stays; the flag wearing a message's type is gone. The review and security-review bodies stop hardcoding git: fileutil owns one declared store table, environment reads it, and a skill subagent's prompt now carries the workspace fact its parent always had. They also drop the ~12-call self-cap, which was well under the host's real budget, and split the burden of proof — adversarial about what must be read, conservative about what may be reported. A pass is a negative claim and owes what any other does. complete_step: manual evidence no longer stands alone in a turn that already has receipts. The evidence ablation arm reaches the tool schema, so the two arms stop paying the same prompt and the A/B can answer for the contract rather than only for the gate. |
||
|
|
432353ef87 |
refactor(agent): the loose flags on Agent group by how long they live
Agent carried 15 scalar fields against a ceiling of 12. The ceiling exists because independent flags multiply into states no type records as legal, and the fix it blocks is adding one more bool — so two groups come out by lifetime, which is what the rule asks for. steerInbox is the queue and the two flags around it. They already shared a mutex and a lifetime — opened on the way into Run, closed on the way out — and now share a type, so the transitions are its methods instead of seven call sites reaching for the lock. take() hands the entry back rather than loading its body under the lock, which is the one behaviour change and the right way round: no inbox lock is held across a read. agentRole is what an agent is when it is built and stays: a planner that may not execute, an executor told to reach for its tools, a caller that needs visible final text. That fixed lifetime is what separates them from planMode and the language settings they were sitting next to, which move between turns. deliveryProfile looks like one of them and is not — beginRunTurn recomputes it per turn — so it stays where it is. Agent is at 12, and the reclaim takes the room with it: baseline drops struct-state for agent.go entirely, along with the file-size and function-size the two extractions freed. Nothing was raised. The ratchet also caught the new file trying to carry two essay findings in, which is what it is for. |
||
|
|
a7e2702318 |
fix(control): a turn that owes todos can act on what it is told to do
The continuation demanded "finish it now" while the completion guard rejects the one action that phrase describes: a todo_write marking an item done is refused until a complete_step signs it off. So the model flipped a status, the guard refused, the list stayed exactly as it was, and the next round repeated the identical demand. Asking the run to stop changed nothing — the loop reopens on every user turn — and the stop button was the only way out. Both the requirement and the continuation now name the three things the guard lets through: complete_step to close an item (it advances the list, so no todo_write is needed), a todo_write without an item that should no longer be done, and conclude_blocked for one that cannot be done as specified. The stall test also moves off the category list, which stays identical while the counts inside it change, onto the full requirement state; and a ceiling ends a run whose gap keeps changing shape without ever shrinking, which no stall counter can see. |
||
|
|
70928f045a |
feat(agent): the model can see how much room it has left
We had a lot of compaction machinery and no way for the model to know it was about to be compacted. Grepping the tree for any figure ever shown to it returned nothing: the model was always the folded party, never an informed one. A model that knows it is at 15% writes down the exact path and the next step before the summary takes them; one that does not gets folded mid-thought. Two halves, because they answer different moments. The push is a tagged fragment appended before the next sampling call, at 75% and again at 92% of the distance to the compaction trigger — the fold is the event it can still act ahead of, so the figure counts down to that rather than to the physical window. It rides the turn tail, so the prefix stays byte-stable; it is edge-triggered, because a notice that re-fired every step would spend the very window it reports on. The rungs re-arm when the history is rebuilt. It hangs in the run loop rather than in control.Compose. A goal turn runs dozens of tool calls between user turns, and a notice that only fires on a user turn would never reach the turn that needs it. The pull is context_budget, for the decision that arrives between the two rungs: a broad search or a large file the model cannot afford to read. Measured at the boundary rather than estimated — ToolSchemaTokens in the golden moves 4362 -> 4509, so the schema costs 147 tokens of prefix, and SystemHash is unchanged, which is the evidence the base prompt was not touched. ContextGeneration lands here because the latch needs it. Compaction installs a projection and never calls Session.Rewrite, so a latch keyed on the rewrite counter alone never notices a fold. Extracting toolCallContext is not drive-by: the one binding line pushed prepareToolExecution past the 120-line limit, and the repo does not widen a baseline to land a change. The extraction reclaims budget instead. |
||
|
|
f2be0e79ca |
fix(trajectory): a round's tokens land on the round that billed them
The pane attached a usage report to whichever model round was still open. The kernel emits usage after StreamAttemptCommit, which is exactly when the open round is cleared, so every report resolved to nothing and was dropped by a branch whose two arms were the same value. A trajectory could say how long a turn took and never what it cost. Usage now names the attempt that billed it, from runAttempt through event.Event to the wire, and the pane addresses rounds by that id whether or not they have settled. A report belonging to no round (compaction, planner) gets its own row instead of vanishing. On a retry the tokens land on the attempt that committed, not on the one that was discarded. Two more frames the pane knew about and never said. stream_attempt was filtered out of the wire log, so a replayed session lost every model_round row -- the rows carrying 99% of a turn's clock -- while the port still promised row-for-row replay. And a discarded round now reports what interrupted it, because time spent and thrown away is still time spent. TurnDone has carried Cancelled since it was added and eventwire never mapped it, so a turn the user stopped and a gateway dropping the connection both read as one context-canceled string. state/trajectory.ts had no tests. It has nine now. |
||
|
|
b3574e54ad |
fix(agent): the blocked turn reports too
emitTurnShadows ran only after readiness passed, so the turn stopped at the gate published no completion summary at all — and that is the turn whose summary matters: an unverified mutation or a rewritten check is still outstanding there, while a turn that passed has already answered for both. It now reports on that path as well, and says blocked rather than whatever the contract concluded. The contract reflects what the model believed it finished; the gate had the last word, and the summary is the host's account, not the model's. Live: a delivery turn that renamed a function and skipped verification now ends `verdict: blocked, mutations: 2, criteria_rewritten: [TestMedianOdd]` instead of ending silently. |
||
|
|
97d59406e3 |
refactor(agent): the host stops narrating what it could not read
When a project has not declared its checks, the host cannot tell a runner from any other command — so a turn that verified itself through its own script came back with an advisory saying the check went unread, and how to declare one. It was true, and it was in the way: it fired on every turn that wrote anything in a project with no declared checks, and the receipt beside it already said the turn was unverified. The advice was worth saying once; nothing said it once. The message goes. What it was protecting stays: the same branch still marks the gap as the host's blind spot rather than the turn's, so a project whose checks run through an unreadable script is not failed for it. That was always the load -bearing half, and it is now the only half. With the notice gone, everything that existed to carry it follows — ReadinessResult.Advisory, which nothing else has ever read; the three fields on finalReadinessCheck; the undeclared_checks code and the desktop's mapping for it; and commandPrograms, which named a command without pasting it. Naming was the right answer to the message being unreadable; not sending it is a better one, and the helper has no second caller to justify keeping it. Cache-impact: none - a removed notice and dead plumbing; no tool schema, prompt prefix or provider request is touched Cache-guard: final_readiness_test.go and taskpolicy_execution_test.go pin the branch that still spares the turn; neither reaches a provider Documentation-impact: none - the advisory was a runtime message the docs never quoted, and Reasonix host checks stays documented as the way to declare them |
||
|
|
9227f65956 |
fix(agent): a command is named in the advisory, not pasted into it
The advisory that says a project has not declared its checks quoted whatever had just run, with %q, in the middle of a sentence. What ran in the field was a forty-line here-document, and the sentence carrying it was rendered into a chat window: newlines collapsed, wrapping mid-token, the reader's own file contents riding along inside the script body. A shell command is a thing, not a clause. It is named now, by what it runs, read from the parsed command rather than from its spelling: the static argv where the command is simple, a compound statement's leaves where it is not, the reader of a here-document where neither answers. A command none of them can read goes unnamed rather than guessed at — "a command ran" is true, and a name assembled by cutting the string would not reliably be. The name reaches the message as its own field, so a frontend can render it apart from the sentence and a terminal can print it after one. The notice carries a code as well, because the sentence was English in a window whose interface is not. Code has always been documented as the id a frontend localizes by; nothing rendered notices had ever read it. The desktop card now does, with the kernel's own words as the fallback for a code nothing maps — dropping a notice would be worse than printing one in the wrong language. And it is stamped for the operator, like the assembly notices before it: it describes how this project is configured, not something said in the conversation, so it holds its own place instead of scrolling past as a turn. The same standing fact is true again every turn, so the strip that holds it collapses repeats rather than growing a row each time. The receipt already said the turn was unverified; this only ever added how to make a check count. It still does, in one line a person can read. Cache-impact: none - notice text and a new notice code; no tool schema, prompt prefix or provider request is touched Cache-guard: internal/agent/command_programs_test.go and final_readiness_test.go cover the naming and the advisory contract; neither reaches a provider Documentation-impact: none - the advisory is a runtime message the docs do not quote; what it asks for, Reasonix host checks, is documented already |
||
|
|
1d027b0709 |
fix(agent): a check the host cannot read is reported, not charged to the turn
Measured against the real model: a bash project whose check is ./check.sh had its bug fixed, ran the check, watched it pass — and the run exited 1. The classifier cannot tell a project's runner from a deploy, so it read no check, and the turn was failed for the host's own blind spot. Anyone driving `-p` from CI got a failure for work that was done. Nor did the failure buy anything. The gate reads the writer from this turn, so the next one — having written nothing — passes with no evidence at all. It could not collect later what it refused to accept now; it only cost a turn and a false red. So the gap now says which kind it is. Nothing ran at all is still the turn's own miss and still fails. A command that ran and could not be read is an advisory: it names what went unread and how to declare it, and lets the turn end. Delivery is untouched, and its stricter static answer still stands. Print mode dropped every warning, having nowhere but stdout to put one, so the advisory would have been invisible where it matters most. Warnings now go to stderr, where the run already reports itself — stdout still carries the answer alone, and no pipeline changes. That also restores planner fallbacks and folded user turns to view. Verified end to end against deepseek-v4-flash: unreadable check → exit 0 with the warning on stderr and the fix applied; wrote a file and ran nothing → still exit 1. |
||
|
|
a35603549a |
fix(evidence): a shell's exit status is not the verdict of the check inside it
`bash -l -i -c` sets no pipefail, so `go test ./... | head` exits 0 on a suite that just printed three failures. Six places read that status as the check's own answer: the host classifier, the completion report, the readiness floor, delivery sign-off, the contract's declared checks, and the summary line. Eight real runs produced 22 verification commands between them; only 10 carried a status that says anything about the check. The verdict now comes from what a zero status actually proves. shellparse.ExitZeroImplies reads it off the AST: both sides of `&&`, only the last stage of a pipeline, only the last of several `;` statements, neither side of `||`, and nothing at all behind `&`. A check whose verdict the shell handed to a later stage is recorded as inconclusive — it neither proves nor refutes, so it counts as neither. Receipts the host never classified keep reading their own outcome, which is all a declared command or a replayed session carries. Two shapes this fixes, both observed live rather than imagined: - `ls -la && go test ./... 2>&1 | head -100`, run while three tests were red, was recorded as a verification that passed. - `go test -v ./... | tail -20 && git status --short`, run on a green tree outside a repository, was recorded as the suite failing. Verification items key on the check they carry rather than on the wrapper around it, so re-running one inside a different pipeline supersedes the earlier outcome instead of opening a second item the fresh run never clears. A check is also no longer discarded for its company. `go test ./... && gofmt -l .` carries a readable verdict even though the host cannot prove gofmt leaves the tree alone: the short-circuit is the same reasoning the admission gate already applies when it blocks only the maskable mixed shapes. Not adding gofmt to a read-only table was the point — ruff, prettier and eslint follow it, and the table never ends. IsDeliveryVerificationCommand keeps the stricter question for sign-off, where "only verification and read-only work" is what is being asked. The summary line and the completion receipt now answer together: a contract the ledger cannot fully vouch for is no longer summarized as complete beside a receipt listing what stays unproven. Recovery guidance rides the steer path under its own host prefix. A model told the user interrupted starts answering someone who never spoke, and cannot tell that from a real interruption. |
||
|
|
d70d9d4686 |
perf(studio): opening a conversation no longer waits on a wallet read
Switching sessions felt slow, and the length of the transcript was not why: a 12MB record reads back in 8ms. What the reader waited on was /status, which queried the provider's wallet endpoint on every call — 87-167ms, uncached — while the pane held the transcript behind a Promise.all with it. Three layers, measured at each end: - billing.Cache serves one wallet answer for 30s, lets exactly one caller refresh it, and keeps a good reading standing in for five minutes when the endpoint starts failing: unreachable is not empty. billing.Store shares one cache per endpoint and credential, because opening a conversation builds a whole runtime and a per-runtime cache is cold on exactly the read a switch waits on. control.Options collapses its three balance fields into that one cache, which is also what keeps boot.build under its ratchet. - The pane reads the record and the numbers over it separately. __restore no longer carries the cache totals; __totals brings them when they arrive, so they are still read back from the kernel rather than restarted at zero. - fromHistory drops two O(n²) shapes — a linear search per tool result, and a full copy of the folded list per item. 3200 turns: 121.6ms to 2.9ms. /status 100ms to 15ms; transcript on screen 139-219ms to 48-125ms, and no longer a function of how long the conversation is. perf/switch.mjs is the bench that holds it, with PERF_STATUS_MS to separate the two waits. Carries the rest of the in-progress studio work in the same snapshot. |
||
|
|
c314676604 |
fix(agent): stop replaying a request the model simply had nothing to think about
Three defects found by running the real DeepSeek API behind a recording proxy and reading the trajectories. A tool-call turn with no reasoning was treated as a lost field and answered with one exact replay of the whole frozen request. Every run triggered it, and on a long-context turn it re-sent 29k prompt tokens to recover five reasoning tokens. Billed thinking tokens with no text mean the value really was lost in transit; zero billed tokens mean the model stayed silent, which is ordinary between tool calls. The classifier now separates them, and the exclusive reasoningObservation states replace a (missing, shouldRetry) pair that could spell 'not missing but replay it'. Optional sink capabilities are delivered by type assertion, so CostQuoteSink - which forwarded Emit alone - silently dropped all ten audit channels one link before the trajectory recorder. Trajectories recorded zero audits while the code faithfully emitted them. capabilityContracts is now the single authority on the set, FanOut forwards them across the split, and the three hand-written forwarders get the RunBudget channel they had all missed. update_goal is unreachable in a headless run, yet its 2.2k-char schema shipped every turn and the model duly called it and was refused. Whole-session unavailability can leave the schema without churning the cache prefix; only per-phase visibility must not. GoalTurnsUnreachable draws that line. A turn that took more than one request billed the sum while its context was only the last request's, and the input column showed the sum with no hint. Measured on the same task before and after: 12 rounds instead of 14, 51.4s instead of 58.8s, zero wasted tokens instead of 29,085, and 14 audit records where there had been none. |
||
|
|
31d377957e |
feat(recovery): stop counting to a number the user never picked
Auto recovery and the guardian each stopped the turn on a count. Recovery's
was 3 failures of one operation, 6 per Episode, 3 reviewer rejects, 3 retries
of an already-stopped operation; the guardian's was 3 consecutive denials or
10 in the last 50. Both then interrupted: recovery burned a summarize-only
round and returned "Automatic retries paused. Send continue to start a fresh
attempt", the guardian injected "stop the current approach and request
explicit instructions".
Neither number came from the user, and neither measured what it claimed. Six
failures is a long debugging session in one codebase and a runaway in another.
The reviewer rejecting three proposals means the reviewer disagreed three
times, not that the work is unrecoverable. What the host actually did with
those counts was take the turn away and ask the user to type "continue" —
which is the user's decision arriving as a chore.
What survives is the part that reads the proposal in front of it: Decide still
routes an ordinary mutation to allow and an ambiguous retry of the same failed
operation to the reviewer, and the reviewer's own reject still blocks that call
with its rationale. A repeat now goes back to the reviewer instead of hitting a
ceiling. The guardian still denies, still forces deny on critical risk and on
high risk without authorization; it just no longer stops the turn on top of
that verdict.
Per-operation failure counts stay — the reviewer and the approval card read
them as evidence ("this exact call has failed N times"). They no longer arm
anything.
With this, RouteStop/RouteStopTurn, the Episode budget, StopReason,
RecoveryPauseError, and the finalization round it drove have no producer, so
they go. event.TurnOutcomeRecoveryPaused stays readable: sessions recorded
before this still carry it.
Nothing else bounds a turn by default (agent MaxSteps is 0 unless the user sets
one), so an Auto run that keeps failing now keeps consulting the reviewer until
the user interrupts it. That is the intended trade.
|
||
|
|
935e0eb9e5 |
docs(preset): say what the host does now, not what it did before two removals
Ten declaration comments still describe a three-setting product ("light|
balanced|delivery", "all three role settings") that one setting left, and two
describe an injection that no longer happens: DeliveryRuntimeMarker's doc says
the agent appends it to user turns and calls its text cache-frozen for every
live delivery session, twelve lines above the code that stopped appending it.
A comment that names a mechanism is a claim the reader cannot check without
reading the whole path. Both of these sent the reader looking for a caller
that is not there.
|
||
|
|
21638e356c |
refactor(agent): stop counting rounds, and let the executor decide when to plan
Two ladders came down.
The planner gate was scoring task text out of twelve keyword tables — work,
high-risk, cross-surface, ambiguous, atomic — and buying a second model round
from vocabulary. "migrate" was enough. So was a message over 240 runes, or two
file paths in one sentence. What the tables were reaching for is a judgment the
executor is better placed to make: it reads the same words with the repository
in front of it, and lays out steps with todo_write when a task needs them,
where the delivery gates and the contract can both see them. The gate now
answers only "did the user ask for a plan?" — plan mode, a synthetic turn, a
slash command, or a directive the user stated outright ("先规划再执行", "wait
for my approval"). taskpolicy loses the surface-shape signals that fed it for
the same reason: rune counts and path counts described the sentence, not the
work. Risk stays a host signal, and nothing derives one from wording.
The six per-round counters came down with it. A turn was being watched by the
zero-gain ladder (2/4/6), the storm breaker (3 identical failures, 3 all-blocked
rounds), the repeat-failure and repeat-success blocks (2 each), and the todo
stall checkpoints (8/16) — all reading the same receipts, all reaching for the
same "it looks stuck" from a different angle, and each one able to redirect the
model or refuse its call. run_budget.go already says why this was the wrong
axis: rounds are a poor proxy, and the failures worth stopping are reported in
hours and tokens. Those budgets remain, off by default and user-owned. Stopping
a task stays the user's call.
Removing them retires the loop-guard pass in final readiness too — an escape
hatch whose only job was to release a model the guards had cornered.
The outcome scorer survives as an instrument (internal/agent/outcome_shadow.go):
it still decomposes each round for the bench, and still decides nothing.
Net -3250 lines.
Cache-impact: none - the system-prompt prefix is untouched; the per-turn
<execution-policy> block keeps its shape and only ever carried preset-derived
values, which are unchanged for every preset.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none - no docs/*.md describes the planner scoring or the
round counters; user-facing copy for plan mode and task budgets is unchanged.
|
||
|
|
b071fef950 |
refactor(agent): let receipts decide, and retire the word lists that guessed
A turn's obligations were being read off its wording. taskintent held ~1200
lines of CN/EN keyword tables, hand-rolled negation windows, and clause
splitting to answer "is this a mutation request?", and final readiness then
demanded a state change from any sentence that scored one. Its own doc.go
called the vocabulary a liability; boundary_test.go existed to stop it growing.
Readiness now reads the ledger: a turn that changed nothing owes nothing, and
one that did owes the verification, review, and sign-off it already owed. The
contract's intent label follows the same rule -- taskcontract.Kind is observed
from receipts after the fact instead of predicted from text -- and the atomic
contract shape is triggered by a mutation receipt rather than by a verb.
taskpolicy keeps the constraints a user stated outright ("don't modify", "only
run go test"): those restrict, and the user meant them. What goes is the risk
guessing, where "migrate" or "credential" bought a full plan and a forced
review from a topic word rather than from what the turn touches. Risk now
arrives only as host-computed signals.
Also removes machinery that decided nothing in production: the EBM and
reasoning-governor experiments (env-gated, off), the fork-bundle capture that
served them, and the shadow delegation-admission verdict. The per-round
contract replay went with them -- it rebuilt the whole contract from every
receipt on every tool round, quadratic in a long turn, for an audit channel
only the bench implements.
Net -3382 lines. Behavior change: a Delivery turn that claims work without
calling a tool is no longer refused. Catching that belongs to the model's own
claim (update_goal / complete_step), which internal/completion already checks
against the ledger, not to a keyword table.
Cache-impact: none - no tool schema, prompt, or memory text changes; the
per-turn <execution-policy> block rides the turn tail as before and its
route/risk values are derived from the same frozen preset.
Cache-guard: go test ./internal/boot/ (prefix_shape golden unchanged)
Documentation-impact: none - taskintent and the two experiments were never
user-facing; no docs/*.md describes the removed gates.
|
||
|
|
23bdf08130 |
refactor(agent): separate the collaborators from the state (#8595)
Nineteen fields on Agent were who it talks to, interleaved with what it remembers. They are now agentServices, reached as a.svc. This stage is deliberately not framed as a lifetime, because it is not one. Thirteen of the nineteen are rebound at runtime through the Set* seams the controller uses between turns, and fork wraps prov mid-run. The axis here is collaborators versus state, and the doc comment says so rather than implying a process scope the code does not have. It pays down no ratchet either: every one of them is an interface or a pointer, and struct-state counts scalars. What it buys is that Agent's remaining fields are recognisable as state at a glance — after the four stages it is agentConfig, svc, sess, task, turn, pending, and a short tail of flags. New would have crossed the 120-line function limit carrying the literal, so the binding moves to newAgentServices: adding a collaborator now touches one place instead of two. repolint: no entry increases; agent.go essay 62 -> 55 and file-size 2155 -> 2061 as the field docs move to services.go, which adds no findings of its own. Part of #8523. |
||
|
|
33fca85bc5 |
refactor(agent): make the turn a value the tool path is handed (#8588)
The turn was two types: perTurnState, embedded in Agent and zeroed by
assignment, and runLoopState, a genuine local threaded through the loop. Both
described one Agent.Run. They are now one turnRuntime, and beginRunTurn returns
a pointer to the agent's copy instead of opening a second object — the loop
fields used to be built into a fresh literal that discarded everything the
classification above had just computed onto perTurnState.
executeBatch, executeOne, resolveToolPolicy and applyDeliveryPolicyGates take
*turnRuntime. Reading it off the receiver compiled and behaved identically, and
would have kept doing so right up until a turn was not the agent's current one.
Two more fields moved after checking their write sites:
- classifierTaskText is written only by New, so it is agentConfig, and the
existing immutability guard now covers it.
- preserveEvidenceOnce, deliveryRecoveryPending and forkRestore are armed by
someone outside the Run — a sub-agent spawner, the turn that just failed
readiness, the fork capture — and consumed by the next one. They are
pendingTurn. Putting them in turnRuntime would have been wrong in a way
that compiles: beginRunTurn's replacement assignment would wipe state armed
before the turn existed.
capabilityLedger and capabilityGate stay on Agent for the same reason. Their
reset lives in internal/control, which calls SeedCapabilityRoute before a Run;
a controller cannot seed a value the Run has not created yet.
turnruntime_test.go guards all three properties: the type stays free of mutexes
and atomics so the single assignment keeps compiling, the tool path reads its
parameter rather than a.turn, and beginRunTurn's replacement literal stays
empty.
repolint: struct-state for agent.go 10 -> 3, repo total 122 -> 115. Verified
key-by-key that no baseline entry increases.
Part of #8523.
|
||
|
|
c498209054 |
refactor(agent): type the session lifetime, and the one that outlives it (#8564)
SetSession reset fifteen fields one by one and carried a comment explaining
which of them was deliberately spared. Those fields are now sessionRuntime and
that seam is a.sess.reset(s).
Three corrections came out of checking each candidate against its write sites
rather than its name:
- workspaceID is written only by New, so it belongs in agentConfig. The
existing immutability guard now covers it, which matters because it is a
prompt-cache lineage component: a mid-life change would silently rekey the
cache.
- activeTurnCreatedAt is cleared by a defer in Run, so it is turn state and
stays put for the turn stage.
- pendingResolveAt was the spared field, and the reason it is spared is that
it answers to the provider configuration rather than to any conversation.
It is now its own type, unwrittenResolve, sitting beside the warn state it
belongs with. missingReasoningWatch is session state again, with no
exception to explain.
outputBudgetState stops being embedded and becomes a named field that owns
lastUsage, so restarting it is one visible r.output.reset() instead of an
Agent method reaching across two layers.
sessionRuntime holds three mutexes and five atomics, so the whole-value
assignment taskRuntime uses is illegal here — vet's copylocks would reject it.
The "no field is forgotten" property is therefore enforced by
sessionstate_test.go: both lifetime lists are checked against the struct, and
reset's body is read back from source to confirm it touches everything the
reset list claims. A field added to sessionRuntime fails the test until someone
states which side it is on.
Behaviour is unchanged. lastPrefixShape and haveLastPrefixShape survive a
conversation swap exactly as before; that is recorded on the carry-over list
rather than changed here.
repolint: struct-state for agent.go 18 -> 10, repo total 130 -> 122; essay,
file-size, complexity and function-size all fall. No baseline entry increases.
Part of #8523.
|
||
|
|
ea9ee16e4e |
refactor(agent): extract the task lifetime into taskRuntime (#8541)
Eight fields on Agent outlive a single Run but are narrower than a session: they belong to one delivery scope. resetTurnEvidence only runs when that scope changes, which is what made them a lifetime of their own rather than session state — recorded until now in comments rather than in a type. Group them into taskRuntime and reach them as a.task.<field>. Reset conditions are unchanged: the ledger group, the checkpoint, and the repeat-failure records each keep their own condition in beginRunTurn, so restartLedger carries scope, checkpoint, and failure records forward exactly as before. The gain is that the carry-over is one named list in a single assignment instead of an emergent property of which fields each call site happened not to mention. prepareRepeatFailureScope moves to taskRuntime.prepareScope verbatim. The mechanical rename went through gopls so internal/control's own deliveryCheckpoint and taskBudget — different types, same names — were left alone by construction. The repolint baseline goes down: struct-state 20 -> 18 for agent.go, essay 81 -> 76, file-size 2276 -> 2251. No entry increases. Part of #8523. |
||
|
|
d56096520a |
refactor(agent): group the recovery gate's identity into one value (#8453)
recoveryAgentID, recoveryTaskID and recoveryRunSeq are read as a unit: both call sites build a RecoveryObservation whose first three fields are exactly these, and Agent already exposes them through a setter named SetRecoveryIdentity. The concept had a name everywhere except in the struct. recoveryIdentity gives it one, next to the gate it describes. The counter stays an atomic inside the value, so the identity is assigned field by field rather than wholesale — an atomic cannot be copied, and nothing here wants to reset it anyway. Third bucket under the struct-state ratchet: Agent 52 -> 49 scalars, baseline tightened to match (agent.go 40 -> 37, total 152 -> 149, file-size 2317 -> 2301 for the 16 lines this removes). |
||
|
|
f40777ee76 |
merge: integrate execution-role runtime with continuous Goal
Problem: main-v2 advanced with the execution-role and completion-summary stack while PR #8373 was in CI, leaving the Goal runtime branch conflicted. Root cause: both changes touch the Desktop and ACP runtime DTO owners and their repolint baselines. Fix: merge main-v2, preserve the upstream execution-role behavior, retain the additive Goal workDurationMs mappings, and combine the exact repolint baselines. Verification: git diff --check; go run ./tools/repolint |
||
|
|
1713e5a131 |
Merge latest main-v2 diagnostics and goal budgets
# Conflicts: # desktop/frontend/src/locales/zh.ts # tools/repolint/baseline.json |
||
|
|
6005e9a23d |
merge: integrate opt-in Goal spend budget from main-v2
Preserve continuous Goal semantics and cumulative runtime statistics while accepting the newly merged, user-configured token budget. Keep structural no-progress guards corrective-only and retain downgrade-safe sidecar migration. |
||
|
|
e6d542bc94 |
feat(goal): replace the guessed turn quota with an opt-in token budget (#8382)
A Goal was bounded by 10/20/40 turns, picked by classifying its own text as simple, write, or research. That is two guesses stacked: guess how big the task is, then stop it on the guess. It was also the only axis, so a loop that was slow and expensive never came close to it while a fast empty one hit it for no reason. Codex, which this feature follows, bounds /goal on a token budget that is optional, and stops with a summary into a budget_limited state rather than ending the goal. It has no iteration cap. This matches that: cumulative tokens, off unless [agent].goal_token_budget is set, one summary and a resumable budget_spend pause on reaching it. Tokens are the axis that generalizes. A slow expensive loop accumulates them and so does a fast empty one, since every iteration pays for at least one prompt; wall clock catches only the first, and money is not portable across models. #7725 removed token limits for a different reason — it enforced a class-derived ceiling at every provider boundary, conflating cumulative autonomous-work safety with per-request context size. This is the cumulative check at the FSM's continuation decision, which is where the loop actually decides to keep going. Resume grants the budget again instead of returning into an exhausted one; openai/codex#34215 is the same situation without that. What still stops a Goal, all of it reading content rather than counting: completion, a blocked report, a failed or uncertain evaluator, the structural no-progress loop. TurnsLimit stays in the sidecar and the runtime view for compatibility and is never set; GoalRuntimeView reports the real TokensLimit again, which #7725 had pinned to zero. Tests that used the quota as a termination condition now configure a budget and bill usage against it. Several of them looped forever without that, which is the shape of the design: an unattended loop with nothing configured has no host-owned stop, by choice. |
||
|
|
0e1932d128 |
Merge main-v2 into execution settings
Problem: The upstream main-v2 branch advanced while the execution-setting changes were under CI, leaving PR #8275 behind the live base and conflicting in the generated repolint baseline. Root cause: Both branches updated files covered by the baseline, so Git could not choose combined finding counts. Fix: Merge the three latest upstream fixes and regenerate the repolint baseline from the combined source tree. Verification: - go test ./... - cd desktop && go test ./... - go vet ./... - go test -race ./internal/acp ./internal/control ./internal/agent - go run ./tools/repolint - cd desktop/frontend && pnpm test:all - cd desktop/frontend && pnpm build |
||
|
|
2d6d2f2afa |
merge: reconcile continuous Goal runtime with main-v2
Integrate the newly landed no-Todo-stall and no-Goal-round-ceiling changes from #8347 and #8357 without rewriting the published PR history. Preserve this PR's broader continuous-runtime behavior: Goal Todo stalls still trigger a second-stage replan and epoch reset, while goal_stuck finalization remains removed. Verified with full Go tests, agent/control race tests, go vet, repolint, Desktop tests, frontend typecheck/test/build, and cache guards. |
||
|
|
792f5df047 |
fix(agent): remove the todo-stall pause (#8347)
A stalled todo could end a run: 8 rounds without a new completion, read, command or mutation got a reassessment nudge, 16 got a resumable pause. The pause goes; the nudge stays. It was never the mechanism that caught the stall it claimed. The zero-evidence ladder escalates on the same receipts and reaches its stop at 6, and the storm breaker reaches its own at 3 — writing the Goal test for this proved it, since Goal never reached 16 at all: the ladder's structural pause always fired first. So under Goal it was dead code, and in ordinary chat it was the last remaining way for the host to end a turn the user never asked it to end. What it measured was also narrower than it read. Any first-time successful read, command or mutation renews the streak, so 16 rounds meant 16 rounds producing literally nothing new — a state the two guards above already handle, twice over and far sooner. todoStallPause and maxTodoStallRounds are gone with it, along with the todo_stall pause class, its RunPauseInfo mapping, the planner's non-finalization message for it, and the Goal FSM's stop cause. |
||
|
|
1bf365af6e |
fix(goal): make execution continuous by default
Problem: Goal mode could pause at host-owned numeric ceilings even while useful work was still progressing. Root cause: Default round, turn, wall-time, and stall thresholds were wired as terminal runtime boundaries and persisted as blocked Goal state. Fix: Remove host-owned Goal execution ceilings, preserve only user-selected resource limits, convert numeric stall guards into replanning redirects, migrate legacy numeric pauses, and expose cumulative work duration as observational runtime data. Verification: - go test ./... - go test -race ./internal/agent ./internal/control - go vet ./... - cd desktop && go test ./... - cd desktop/frontend && pnpm typecheck && pnpm test:all && pnpm build - scripts/cache-guard.sh - scripts/check-cache-impact.sh - go run ./tools/repolint |
||
|
|
ec2da98bda |
Merge remote-tracking branch 'origin/main-v2' into fix/pr-8275-agent-role-settings
# Conflicts: # internal/agent/run_loop.go |
||
|
|
175455df43 |
Keep transient routing out of completion receipts / 避免内部能力路由污染完成回执 (#8317)
* fix(agent): keep transient routing out of completion receipts
Problem:
Ordinary turns could surface an unverified completion receipt whose acceptance criterion exposed the internal capability-route block and invented a required-change check.
Root cause:
The agent kept the provider-composed turn as the completion contract input even though the controller already supplied authenticated raw user text separately.
Fix:
Use the raw user input for live and final completion contracts while preserving the composed provider prompt. Add regression coverage for mutation receipts and advisory turns containing capability routing with delivery-like wording.
Verification:
- go test ./internal/agent ./internal/completion ./internal/taskcontract
- go test ./...
- cd desktop && go test ./...
- git diff --check
* fix(agent): preserve trusted task text in completion contracts
Problem:
The first completion-receipt fix used raw user input unconditionally, which did not preserve Goal scope task text or pristine sub-agent classifier text. Its explanatory comment also exceeded repository lint budgets.
Root cause:
Completion contracts and delivery classification selected their task source independently even though both require the same host-trusted text.
Fix:
Store the already-selected trusted classification input as the per-turn contract input. Add regressions for Goal continuations and host-wrapped sub-agents, and remove the lint-expanding comment.
Verification:
- go run ./tools/repolint
- go test -race ./internal/agent -run TestCompletionContract -count=1
- go test ./internal/agent ./internal/completion ./internal/taskcontract
- go vet ./...
- go test ./...
- cd desktop && go test ./...
- git diff --check
* chore(ci): refresh PR metadata checks
Problem:
The documentation-impact field was added after the pull_request_target event was created, and rerunning that workflow reused the old event payload.
Root cause:
GitHub Actions reruns preserve the original pull request metadata snapshot.
Fix:
Create a new synchronize event so metadata guards evaluate the corrected PR body without rewriting published history.
Verification:
- PR body contains Documentation-impact
- local code and test gates passed at
|
||
|
|
97738e0a9b |
Merge remote-tracking branch 'origin/main-v2' into fix/pr-8275-agent-role-settings
# Conflicts: # internal/boot/effect_test.go # tools/repolint/baseline.json |
||
|
|
f1b5997b15 |
feat(agent): gate a task on spend; drop the chat round ceiling (#8320)
* feat(agent): gate a task on spend; drop the chat round ceiling The gate moves to the axes a runaway is actually reported in. A task that reaches its wall-clock or cost budget gets one tool-free summary and a resumable pause — the same landing the round ceiling produced, now triggered by what the work costs instead of by a count. Ordinary chat loses its round ceiling entirely rather than getting a bigger one. Rounds carry no information the spend axes lack: a turn that reaches a high count without crossing either is one whose rounds are individually cheap and fast, which is the case least worth interrupting. The only failure rounds could catch alone is a loop that is both free and instant, and wall clock catches that one too, in the right unit. Goal keeps its 16-round boundary — an FSM yield point, not a runaway guard — and an explicit --max-steps still owns either turn. Wall clock is the only axis with a default, deliberately: half an hour means the same thing on every model, an amount of money does not. A cost default loose enough for DeepSeek would land a frontier model within a couple of answers, so cost stays off until someone who knows what they are paying sets [agent].task_cost_budget. Cost also only judges what it can price — an unpriced model never crosses that axis rather than reading as free — and wall clock still bounds a free or local model's loop. Landing is now one path. armFinalizationRound owns arming the grace round, injecting the finalization request, and emitting the notice; the spend gate and an explicit max_steps both go through it, and gracePause names which one stopped the Run. The grace round, not the wording, is what refuses tools. toolOutcome moved to execute_batch.go, where it is used, to keep agent.go inside its budget. The remaining baseline diff is 5 lines in config.go for the two keys and 1 in boot.go for passing them; both files are far past the 800-line ceiling already and the added code is at its minimum shape. * docs(guide): document the task spend budget Both guides get the two keys, why only wall clock has a default (money is not portable across models), the unpriced-model rule, and the fact that ordinary chat no longer has a round ceiling at all. |
||
|
|
0edff6d99a |
Merge latest main-v2 into agent role settings
Problem: main-v2 advanced again while the final lint cleanup was being pushed, leaving the pull request without a mergeable synthetic ref. Resolution: Merge the latest target tip and validate the combined role-policy and turn-spend changes before rerunning CI. Verification: focused Go tests, golangci-lint, repolint, and frontend bundle checks rerun after conflict resolution # Conflicts: # tools/repolint/baseline.json |
||
|
|
dbf0dd873a |
feat(agent): observe what a turn actually spends (#8309)
Rounds are the axis we bound and the one nobody reports a failure in. The turn behind #8228 was four hours, 524 requests and 194M tokens; not one of those numbers is a round count, and the same hundred rounds cost minutes or hours depending on what each one read and how long the model thought. This adds the axis that is missing: tokens, requests, wall clock and money, accumulated per turn and reported after every round to sinks that opt into event.RunBudgetSink. Cost goes through provider.Pricing, so a cache hit prices at a fiftieth of a miss instead of reading as a full-price prompt — on a cache-first agent, anything else would misreport the bill by an order of magnitude. Shadow only. Nothing reads a verdict from it and no threshold exists yet: the distribution these samples record is what a threshold should be chosen from, and picking one first would repeat how 100 was picked. Rounds still count when a round's usage never arrived, so the axis never reads cheaper than the turn was. run_loop.go was 3 lines over the file-size ceiling with this, so the sampling-usage accounting it already carried — estimate, merge, finalize, emit — moved to run_usage.go as its own concern. That drops run_loop.go under 800 lines and retires its file-size debt; the baseline diff carries the two findings that moved with the extraction and records one fewer finding overall. |
||
|
|
daec1e0c03 |
fix(agent): enforce role policy end to end
Problem: Role settings derived policy fields that were not consistently shared by planner, capability routing, execution, and final readiness. ACP prompt admission could race an in-place role switch, and desktop branch metadata could restore a stale canonical preset. CI also exposed stale role-copy assertions, locale bundle overages, and an outdated repolint baseline. Root cause: Each layer independently inferred role behavior, prompt admission did not participate in the state-change lock, and session persistence updated only the legacy tokenMode field. Fix: Freeze one TaskPolicy per turn and enforce its route, capability, mutation, external-action, verification allowlist, explore-subagent, and post-write verification constraints. Serialize ACP admission with role changes, dual-write desktop role metadata, keep planner/router runtimes role-neutral for in-place switching, and repair lint/bundle/test contracts. Verification: go test ./... cd desktop && go test ./... go test -race ./internal/acp ./internal/control ./internal/agent go vet ./... go run ./tools/repolint cd desktop/frontend && pnpm build && pnpm test:all |
||
|
|
5e03e03eaf |
feat(agent): light/balanced/delivery role settings with unified tools / 角色设定与统一工具面
Problem: Work modes (economy/full/delivery) mixed token surface trimming, delivery gates, and UI labels. Economy forced connect_tool_source schema growth; switching modes rebuilt controllers and busted prompt-cache prefixes. Skill profiles could hide tools from the model surface. Fix: - Introduce AgentPreset (light|balanced|delivery) + TaskPolicy host floors for plan route, verification, and independent review. - Share one provider-visible core surface across presets; optional tools dispatch only through use_capability (retire connect_tool_source boot). - Switch role settings in place without controller rebuild (CLI /preset, desktop, ACP agent_preset; legacy work_mode/tokenMode dual-write one version). - Emit turn_phase and completion_summary events; wire desktop/CLI UI. - Keep skill profiles diagnostic-only; dual-write agent_preset on tabs/branch meta. - Docs, i18n, golden surfaces, and regression matrix updated. Verification: - go test ./internal/boot ./internal/agent ./internal/cli ./internal/acp ./internal/control ./internal/skill ./internal/agentpreset ./internal/taskpolicy - desktop role-setting smoke tests |
||
|
|
397ed30ec8 |
feat(agent): content-driven context maintenance (single compact_ratio) / 内容驱动上下文维护(单一 compact_ratio) (#8244)
* feat(agent): content-driven context maintenance with single compact_ratio Replace multi-threshold prune/snip/native auto maintenance with one content-driven checkpoint path (stable prefix + one summary + recent tail) gated solely by compact_ratio (default 0.85). First-visible tool results are bounded at creation; prune/snip APIs remain no-ops for call-site compatibility while legacy config/sidecar fields load and migrate away. Also align max_output_tokens auto ladder (0=auto; 128K explicit only), desktop/settings/docs, and force CompactNow planning so mid-size strict-alternating sessions (e.g. guardian) still reduce tokens. Verification: go test ./...; desktop go test ./...; frontend test:all; cache-guard; race on core packages; live seed→resume→continue smoke (version=1, one summary, ~$0.0012). * fix(agent): make context projection durable on Windows and clear CI gates Problem: - SaveCompactionState fsynced the parent directory after rename. Windows returns ERROR_ACCESS_DENIED for directory Sync, so every projection install failed and Windows smoke tests went red. - gofmt, repolint, frontend AgentView fixtures, and required cache/docs PR-body lines also blocked CI for #8244. Root cause: - Custom atomic write path reimplemented durability with a directory Sync that is not portable, instead of using the shared Windows-safe helper. - Residual format/debt and a missing coldResumePrune fixture field. Fix: - Persist sidecars through fileutil.AtomicWriteFile (temp + file fsync + replace) without directory Sync. - gofmt touched Go files; shorten essay comments; extract fold helpers; carry residual structural debt in the repolint baseline after reducing new essay debt; restore coldResumePrune on settings fixtures. Verification: - gofmt clean; go run ./tools/repolint clean - go test ./internal/agent/ ./internal/config/ ./internal/provider/... ./internal/guardian/ ./internal/cli/ - pnpm settings-refresh-snapshot: 91 passed * chore: nudge PR head refresh after Windows durability fix Empty commit so GitHub re-resolves the fork head for #8244 after the merge of main-v2 and the Windows sidecar write fix. * fix(agent): close #8244 review blockers for content-driven maintenance Problem: Review of PR #8244 requested changes for CI failures, threshold under-count, legacy native lineage drop, non-atomic CAS/write, residual cold-resume UI, and duplicate context_maintenance notices. Root cause: - Threshold used message-only estimates without tools/role projection. - Sidecar lineage required exact PromptCacheKey and marked restored before covered-hash validation. - Checkpoint install checked transcript outside the install lock and used non-strict atomic write with EXDEV copy fallback. - coldResumePrune UI/API remained after behavior removal; locale gzip grew. - Maintenance notices ignored operationId on reconnect/replay. - golangci reported unused prune/summary helpers and minor modernize issues. Fix: - Estimate compact trigger from final request shape (messages + tools + provider role projection). - Normalize legacy |context-editing-native-* keys; restore only if projection still validates; persist normalized key without version bump/events. - CAS install under compactionMu; SaveCompactionState uses AtomicWriteFileStrict. - Remove coldResumePrune settings UI/Wails field/locales; keep config migration. - Dedupe context_maintenance notices by operationId (bounded FIFO) + tests. - Delete unused prune/summary helpers; clear golangci; trim locale strings for bundle budget; strip SPEC trailing space. Verification: - make lint (golangci + repolint) clean - go test agent/config/control/guardian/provider - pnpm frontend build (locale budgets pass) - context-maintenance-notice + settings-refresh-snapshot tests * fix(desktop): gofmt reasoning_display fixture after cold-prune removal Desktop CI failed gofmt on reasoning_display_app.go after ColdResumePrune was dropped from the AgentView fixture literal. * fix(agent): unblock context-maintenance merge blockers Problem: PR #8244 still had three merge blockers: emit under compactionMu (deadlock risk), restore rewriting exact-key sidecars, and initial raw bundle over budget. Root cause: commitSummaryProjection held the lock through synchronous sink.Emit; LoadProjectionSidecar persisted whenever the key already matched; ContextPanel/TranscriptSelectionMenu were eagerly imported into the initial chunk. Fix: - Unlock before emitContextMaintenance; reentrant sink test - needsNormalization only for legacy native lineage rewrite; log persist errors instead of ignoring them - Lazy-load ContextPanel and TranscriptSelectionMenu - Document pre-interceptor threshold estimate + overflow recovery - CLI/GOAL docs: compact_ratio default 85% Verification: - go test ./internal/agent/ (+ race focused tests) - make lint (repolint baseline essay bump) - pnpm build: initial raw 2236.8 / 2240 KiB - context-maintenance-notice test ok * fix(agent): close remaining context-maintenance plan gaps Problem: After the merge blockers landed, plan gaps remained: no parent-dir fsync on strict checkpoint writes, new writers still mirrored last_*/blocked_* fields, dead CompactionCapabler shell, and the pre-interceptor threshold contract lacked a regression test. Root cause: AtomicWriteFileStrict only fsynced the temp file; LastReceipt was authoritative but writers still dual-wrote mirrors; provider.CompactionCapabler had no production callers; Prepare never invoked interceptors by design without an explicit test. Fix: - Strict atomic write: fsync parent directory after rename (Windows/ unsupported FS soft-fail) - SaveCompactionState / commit / blocked paths: LastReceipt only; strip last_trigger/last_mode/token mirrors and top-level blocked_* - Restore receipt-only blocked sidecars on load - ContextReport/CLI/status prefer LastReceipt with legacy fallback - Delete unused CompactionCapabler/NativeCompaction shell - TestPrepareThresholdSkipsExtensionInterceptors Verification: - go test ./internal/agent ./internal/fileutil ./internal/provider - go test -race focused packages - make lint (repolint clean) * docs: drop removed context_editing example from zh GUIDE Problem: GUIDE.zh-CN still suggested context_editing = "native" after native Anthropic context editing was removed and startup migration deletes that key. Root cause: bilingual GUIDE config sample was not fully synced with the single compact_ratio local-summary contract. Fix: remove the obsolete config line from docs/GUIDE.zh-CN.md. English GUIDE already had no such example. Verification: grep docs for remaining context_editing user examples (only SPEC migration notes remain, which correctly describe removal). * fix(fileutil): never fail strict write after successful rename Problem: AtomicWriteFileStrict returned parent-dir fsync errors after rename had already published the new file. commitSummaryProjection and blocked-receipt writers treated that as "not committed" and rolled memory back, forking disk (new generation) from process memory (old generation) and allowing paid re-summary loops. Root cause: post-publish power-loss durability was modeled as a hard failure even though the atomic publish already completed. Fix: - After successful rename, parent-dir fsync is best-effort only - Returned error still means "destination not published" - syncParentDir also covers relative parents (".") - Agent tests: applied and blocked receipts survive injected post-publish dir-sync faults without generation fork / re-summary - PR body: document AtomicWriteFileStrict + best-effort dir fsync Verification: - go test ./internal/agent ./internal/fileutil - make lint |