Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b462fae
Record the agent's model usage per session, and enforce maxTokens and…
DavidCockerill Oct 6, 2026
d3f0b13
Close every open approval on a budget stop, keep a cancel's status, a…
DavidCockerill Oct 6, 2026
4c3b39e
Rename the per-session token cap to agent.maxSessionTokens
DavidCockerill Oct 6, 2026
2896be0
Merge remote-tracking branch 'origin/main' into david/3040-agent-usag…
DavidCockerill Oct 6, 2026
c41c6d5
Recheck a cancel before running approved actions, and keep a hollow b…
DavidCockerill Oct 6, 2026
a18566f
Tell the operator how to leave a session whose cap cannot be measured
DavidCockerill Oct 6, 2026
8def5a0
Close a cancelled run's open approvals, and record a backend result w…
DavidCockerill Oct 6, 2026
c736a73
Close open approvals in the cancel's own write instead of after the c…
DavidCockerill Oct 6, 2026
ff0d5ae
Claim an approval before its action runs, and answer open approvals o…
DavidCockerill Oct 6, 2026
f65adad
Answer a claimed approval as cancelled when the cancel lands during t…
DavidCockerill Oct 6, 2026
f35d998
Merge remote-tracking branch 'origin/main' into david/3040-agent-usag…
DavidCockerill Oct 12, 2026
d6f48f6
Never let the agent loop overwrite a cancel, and validate maxTurns li…
DavidCockerill Oct 12, 2026
d09a4f1
Record that no loop status write replaces a cancel
DavidCockerill Oct 12, 2026
e6b9781
Close three cancellation gaps: a prompt during a finishing cancelled …
DavidCockerill Oct 12, 2026
4559943
Record the cancel rules for a finished session, a mid-turn cancel and…
DavidCockerill Oct 12, 2026
1d43253
Fence runs admitted before a cancel, so a cancel during a start or a …
DavidCockerill Oct 12, 2026
cea71c7
Record the run admission fence
DavidCockerill Oct 12, 2026
68f8df1
Answer 409 when a cancel fences out the run an accepted prompt would …
DavidCockerill Oct 12, 2026
f1671b1
Merge origin/main: agent.maxTokens with rejected replies (#3064) and …
DavidCockerill Oct 19, 2026
833b2f0
Test that a rejected reply's usage is recorded, counted toward the ca…
DavidCockerill Oct 19, 2026
1f1be50
Drain approvals resolved mid-drain, answer claims a dead run left ope…
DavidCockerill Oct 19, 2026
a2eaa3d
Record the drain, interrupted-claim and followup rules
DavidCockerill Oct 19, 2026
96de058
Answer an interrupted approval in place at run start, and keep pricin…
DavidCockerill Oct 19, 2026
30a7188
Record where an interrupted approval's answer goes
DavidCockerill Oct 19, 2026
e478062
Fence a prompt before saving it, match interrupted claims on the late…
DavidCockerill Oct 19, 2026
074936e
Drain approvals resolved during a turn's later calls, and repair inli…
DavidCockerill Oct 19, 2026
295ed9c
Answer a failed run's unanswered calls in place, as the run-start rep…
DavidCockerill Oct 19, 2026
d9d24fe
Insert every approval answer at its own turn, ahead of a prompt appen…
DavidCockerill Oct 19, 2026
71b7182
Stop the auto loop on a cap it cannot measure, tell clients which age…
DavidCockerill Oct 19, 2026
e750165
Make an auto-loop cap of 0 allow no call, and correct docs the fail-c…
DavidCockerill Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Index of the design notes for the harper core: one line per note, grouped by the
- [A recorded decision is committed before its id is returned, and its facts are separate rows](resources/models/DESIGN.md#a-recorded-decision-is-committed-before-its-id-is-returned-and-its-facts-are-separate-rows) β€” Recording is opt-in (`persist: true`); a recorded `decide` commits its `hdb_model_decisions` row in its own transaction before returning; truth and action are separate `hdb_model_outcomes` rows that copy the decision's expiry, so facts from different nodes never overwrite each other.
- [No-match is a separate score, never a value](resources/models/DESIGN.md#no-match-is-a-separate-score-never-a-value) β€” An opted-in leaf gets a no-match score beside the unchanged distribution; no sentinel, capability-gated per candidate, dropped where not asked for.
- [A `@decide` decision is recorded only when the record can reach it](resources/models/DESIGN.md#a-decide-decision-is-recorded-only-when-the-record-can-reach-it) β€” A directive records its decision only with a `decision` attribute, which is server-owned; association is bounded, and read-only nodes run no model hook and stage no cache fill.
- [A generative call is priced by the entry that served it](resources/models/DESIGN.md#a-generative-call-is-priced-by-the-entry-that-served-it) β€” `costUsd` comes from the serving backend's `pricing`, needs both token counts, is computed outside the fallback catch, and never changes calibration identity.
- [A fitted calibration never crosses a score source, and never waits on storage or fails a call](resources/models/DESIGN.md#a-fitted-calibration-never-crosses-a-score-source-and-never-waits-on-storage-or-fails-a-call) β€” Populations are keyed by served-entry fingerprints and signatures; the decide path reads a cache only and fails open; immutable versions are ranked by the evidence they saw; the job is internal to the scheduler.

## server/ β€” HTTP stacks, threads, operation dispatch
Expand Down Expand Up @@ -190,6 +191,8 @@ Index of the design notes for the harper core: one line per note, grouped by the
- [The fs tools never return key material, and `config` is the config file (`agent/tools/fsTools.ts`)](agent/DESIGN.md#the-fs-tools-never-return-key-material-and-config-is-the-config-file-agenttoolsfstoolsts) β€” `config` defaults to the config file and is never enumerated; key directories, key file names and PEM private-key text are refused in every scope, against canonical paths.
- [A turn that did not finish is rejected before anything is recorded (`agent/loop.ts`)](agent/DESIGN.md#a-turn-that-did-not-finish-is-rejected-before-anything-is-recorded-agentloopts) β€” a turn the backend reports as cut short, an unparsed tool call or an empty reply ends the run `error` and appends nothing.
- [Tool results are capped where they are stored; a context-window rejection shrinks the newest once (`agent/loop.ts`)](agent/DESIGN.md#tool-results-are-capped-where-they-are-stored-a-context-window-rejection-shrinks-the-newest-once-agentloopts) β€” no observation over `agent.maxToolResultBytes` reaches the transcript; backends flag a context-window rejection and the loop shrinks the newest results once and retries.
- [Spend caps are folded from the transcript and fail closed (`agent/budget.ts`, `agent/loop.ts`)](agent/DESIGN.md#spend-caps-are-folded-from-the-transcript-and-fail-closed-agentbudgetts-agentloopts) β€” Usage rides on each assistant message; caps are per session, checked before approvals and after every call; unmeasured stops; a stop answers every tool call in one write.
- [Every tool call gets one response, and a cancel is final (`agent/loop.ts`, `agent/session.ts`)](agent/DESIGN.md#every-tool-call-gets-one-response-and-a-cancel-is-final-agentloopts-agentsessionts) β€” approvals are claimed before they run; a dead run's unanswered calls are answered `interrupted` in place; every late response goes to its turn; nothing replaces `aborted`; a cancel fences out runs admitted before it.

## config/

Expand Down
25 changes: 23 additions & 2 deletions agent/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The built-in Harper agent component.

**Read this when:** changing the agent's toolset, the `http_fetch` tool, the filesystem tools or their scopes, what `set_agent_config` can patch, how the loop ends a run, or how tool results reach the transcript.
**Read this when:** changing the agent's toolset, the `http_fetch` tool, the filesystem tools or their scopes, what `set_agent_config` can patch, how the loop ends a run, how tool results reach the transcript, or how a run records and caps model usage.

Index of every design note: [DESIGN.md](../DESIGN.md).

Expand All @@ -28,7 +28,7 @@ The rules bound the fs tools only: an operations tool returns its own text unfil

## A turn that did not finish is rejected before anything is recorded (`agent/loop.ts`)

Callers poll `get_agent_session` and read `completed` as success, so `completed` with no `lastError` must mean a natural, non-empty final answer (harper#3039). The `maxTurns` ceiling is the one documented exception: it ends `completed` and sets `lastError`. `rejectTurn` runs right after each `generate` and ends the run `error` when the backend reports `length` or `content_filter`, when it reports `tool_calls` but no call parsed, or when a reply has neither text nor tool calls.
Callers poll `get_agent_session` and read `completed` as success, so `completed` with no `lastError` must mean a natural, non-empty final answer (harper#3039). Two documented exceptions end `completed` and set `lastError`: the `maxTurns` ceiling, and a final answer that crosses a spend cap, which is still delivered (below). `rejectTurn` runs right after each `generate` and ends the run `error` when the backend reports `length` or `content_filter`, when it reports `tool_calls` but no call parsed, or when a reply has neither text nor tool calls.

- **Nothing from a rejected turn is appended.** Partial text would read as the answer. Tool calls recorded without their responses would make the session's next request invalid, and a truncated call carries truncated arguments, so none is executed or queued for approval. The rejection is one status write, so there is no partial state to recover.
- **Tool calls still dispatch on `stop`.** Only the explicit cut-short reasons block dispatch. `synthesizeGenerateFromStream` (`resources/models/backendRegistry.ts`) reports `stop` for a stream-only backend that emits no finish reason, so gating dispatch on `tool_calls` would stop such a backend from calling tools at all. The cost: truncation from such a backend is invisible here, and the guarantee holds only for backends that report it. The four built-in backends implement `generate` and do.
Expand All @@ -43,3 +43,24 @@ Every request replays the whole transcript, so one oversized tool result fails e
- **A context-window rejection shrinks, then retries once.** Backends own provider wording. They set `contextWindowExceeded` on their error (`isContextWindowRejection`, `resources/models/backendHelpers.ts`), and the loop reads only `isContextWindowExceeded`. It cuts each result over 2 KiB in the newest run of tool messages that has one down to a 2 KiB head, then retries. Selecting by size makes the step idempotent and reaches a result stored before a later prompt. It is best effort: a long prompt or history ends the run `error` with a `lastError` saying so. `Models.generate` surfaces the first candidate's error, so a rejection from a fallback candidate behind an unrelated primary failure is not recovered.

The system prompt is sent only as `input.system`. The backends hoist or prepend it, so an inline copy would be sent twice.

## Spend caps are folded from the transcript and fail closed (`agent/budget.ts`, `agent/loop.ts`)

`agent.maxSessionTokens` and `agent.maxCostUsd` bound a session, not a run: `approve_agent_action` and a follow-up `agent_prompt` each start a new `runAgent` or join the live one, so a per-run counter (which is what `maxTurns` is) would reset on every resume (harper#3040). Three rules keep the caps honest:

- **Usage lives on the call it describes.** Each assistant message carries the `usage` its `generate` reported and its `costUsd`, in the same write as the message. Session totals are folded from the transcript by `summarizeSessionUsage`, never stored, so they cannot drift from the calls. A rejected turn (above) never reaches the transcript, but it was billed, so `endRun` records its usage on `rejectedCalls`, which the fold counts as a call. A total stays present only while every assistant message reported it, so a backend that reports nothing, an unpriced model, or a message written before usage was recorded makes that total unmeasured, not zero.
- **Unmeasured stops the session.** `budgetViolation` treats a cap whose total is unmeasured as violated. Checked before `consumeResolvedApprovals` (so a cap lowered during a pause stops an approved action from running) and after every call.
- **A stop leaves a sendable transcript.** `endRun` appends the assistant turn, a response for every tool call and every open approval, resolved or not (`budget_exceeded`, or `denied_by_operator` for a denial), and the terminal status in one write, so a stop leaves no tool call without the response the provider requires on the next call, and no approval stays pending once the session leaves `awaiting_approval`. A cancel that landed first keeps its `aborted` status; the stop still completes the transcript.

A final answer that crosses a cap is delivered; the run ends `completed` with `lastError` naming the cap. What a cap does not bound, by construction: one call can cross it (it is a stop-after-call threshold), a failed attempt before a model fallback is not counted (failure records carry no usage), and an unpriced model is detected only after its first call. Pinned by `unitTests/agent/loop.test.js` and `integrationTests/server/agent-budget.test.ts`.

## Every tool call gets one response, and a cancel is final (`agent/loop.ts`, `agent/session.ts`)

A provider rejects a request in which a tool call has no response, or has two, or in which a response follows a later prompt; a session in that state cannot continue. Four rules keep every transcript sendable while cancels, stops, approvals and crashes race each other:

- **An approved action is claimed before it runs, and runs at most once.** `claimApproval` marks it taken under the session lock, and every writer that answers approvals (`cancelSession`, `endRun`) decides which are open on the row it read under that lock, so a cancel or a stop never answers an action already running. The run checks the cancel again after the claim. A crash between the claim and the result leaves that call unanswered rather than running the action twice.
- **Calls a dead run left unanswered are repaired, in place.** Inline results are appended one at a time as each call finishes, so a crash mid-turn can leave calls without a response too. The next run starts by answering every such call of the latest turn `interrupted`, outcome unknown (`answerInterruptedCalls`), without running it again; it looks only at the latest turn, as a provider may reuse a tool-call id. A run that fails answers its open approvals and its turn's remaining calls `run_failed` in its final write. Every response written after its turn has moved on (an approval's result, a cancel's or stop's answer, `run_failed`, the repair) is inserted right after that turn's existing responses (`responseIndex`), so a prompt appended meanwhile, for example after a crash between resolving an approval and starting its run, stays after them.
- **A cancel is final.** `cancelSession` answers every open approval `cancelled_by_operator` in the write that marks the session `aborted`, and decides under the lock, so it leaves a session a run already finished as it is. Every status the loop writes after `running` goes through `endRun`, which keeps `aborted`, and `addPendingApproval` answers a gated call `cancelled_by_operator` on an `aborted` session. A cancel between a turn's tool calls answers the rest of the turn in one write. Every cancel bumps a per-session count (`fenceRuns`); a request that starts a run takes an `admitRun` token before its first await, and `runAgent` starts nothing for a token a later cancel fenced out, so a cancel that arrives while `agent_prompt` or `approve_agent_action` is still writing is not undone by the run it would start. `agent_prompt` answers 409 while a cancelled run is still finishing (`isRunInFlight`), because a prompt appended then would land between that run's tool calls and their results; each of its 409s carries a `code` and `retryable` (false only when the prompt was saved but not run), and session reads carry `runInFlight`, since a cancelled session reads `aborted` before its run is done.
- **An approval resolved while a run is live joins that run.** That covers one resolved during the drain or during the turn's later inline calls. So the run drains after run start and after any turn that parked an approval, repeats until nothing resolved is left, decides the pause under the lock (`pauseWhileApprovalsOpen`), and writes `running` back over the `idle` that resolving the last approval leaves. Such an approval is checked against the caps the live run started with, like everything else in that run; a cap lowered meanwhile applies from the next run. A scheduled followup that fires while a run is in flight or approvals are pending retries until neither holds, carrying its admission token so a cancel drops it.

Run control is per process: the session lock, the in-flight runs and the cancel count live in memory, while `hdb_agent_session` replicates, so two nodes acting on one session are not serialized. Pinned by `unitTests/agent/loop.test.js` and `unitTests/agent/operations.test.js`.
Loading
Loading