From 3d8605f664cbbf8604e03b93ade888d8c52a848f Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 12:28:25 -0400 Subject: [PATCH 01/15] Document model pricing and the agent's per-session spend caps Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/configuration/options.md | 22 ++++++++++++++++++++-- reference/models/api.md | 15 ++++++++------- reference/models/backends.md | 1 + reference/models/overview.md | 24 ++++++++++++++++++++++++ reference/models/tool-calling.md | 25 +++++++++++++------------ reference/operations-api/operations.md | 16 ++++++++++++---- 6 files changed, 78 insertions(+), 25 deletions(-) diff --git a/reference/configuration/options.md b/reference/configuration/options.md index b540e0671..ea2913234 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -393,7 +393,8 @@ agent: - `provider` — Recorded on the session but **not yet used to route the model call** — only `model` reaches the provider. Set the provider through the [`models`](../models/overview.md#configuration) configuration instead - `model` — Model id override, passed through to the model call; _Default_: the [`models`](../models/overview.md#configuration) generative default - `maxTurns` — Maximum tool-call iterations in a single run; _Default_: `50` -- `maxCostUsd` — Intended per-run cost ceiling. **Not enforced** — it is a stored setting only, and nothing checks spend against it; _Default_: `5.00` +- `maxTokens` — Per-session cap on prompt plus completion tokens. See [Spend caps](#agent-spend-caps); _Default_: none +- `maxCostUsd` — Per-session cap in USD, priced from the [`pricing`](../models/overview.md#pricing) on the `models.generative` entry that served each call. See [Spend caps](#agent-spend-caps). Before v5.3.2 this setting was stored but never enforced, and defaulted to `5.00`; _Default_: none - `autoApprove` — Run without per-action approval gates; _Default_: `false` - `allowDestructive` — Include the tools marked destructive in the agent's toolset: `write_file`, the inspector's code-evaluation tools, and any operations tool carrying MCP's [`destructiveHint`](../mcp/tool-metadata.md) (`drop_table`, `delete`, `restart`, `set_configuration`, ...). When `false` they are removed entirely rather than gated. That hint comes from a curated set in core which does not cover every damaging operation, so this is not a complete safety boundary on its own — see [Agent operations](../operations-api/operations.md#agent); _Default_: `false` - `user` — Harper user the agent's **operations** tools run as; the filesystem, HTTP, schedule, and inspector tools always run at process privilege regardless. If it cannot be resolved and it is not the default, the agent fails closed and runs with no operations tools; _Default_: `hdb_agent`, which falls back to a `super_user` bootstrap identity @@ -401,7 +402,24 @@ agent: - `httpFetch` — Whether the agent has its `http_fetch` tool, and which hosts it may reach: `true`, `false`, or `{ allow: [...] }`. Read at startup only. See [Restricting `http_fetch`](#restricting-http_fetch); _Default_: `true` - `systemPromptAppend` — Operator text appended to the agent's system prompt -`enabled`, `provider`, `model`, `maxTurns`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend` can also be changed at runtime with [`set_agent_config`](../operations-api/operations.md#set_agent_config), which applies in memory only. `enabled` is the exception worth knowing: it cannot switch the agent on, because with the agent disabled at startup no agent operation is registered at all. +`enabled`, `provider`, `model`, `maxTurns`, `maxTokens`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend` can also be changed at runtime with [`set_agent_config`](../operations-api/operations.md#set_agent_config), which applies in memory only. `enabled` is the exception worth knowing: it cannot switch the agent on, because with the agent disabled at startup no agent operation is registered at all. + +### Agent spend caps + + + +`maxTokens` and `maxCostUsd` bound a whole session, across every run in it. Approving a paused action or sending a follow-up prompt starts a new run, but does not reset a cap the way it resets `maxTurns`. Each assistant message records the usage of the model call that produced it, and [`get_agent_session`](../operations-api/operations.md#get_agent_session) reports the session's totals as `usage`. + +A cap is checked when a run starts, before it executes an approved action, and after every model call: + +- **At or over a cap**, the session makes no further model call and ends `error`, with `lastError` naming the cap, for example `agent.maxTokens=200000 reached: this session used 201450 tokens`. Tool calls the model had just requested are answered with a `budget_exceeded` result rather than run, so the transcript stays valid if you continue the session. A final answer that crosses the cap is still delivered: the run ends `completed`, with `lastError` naming the cap. +- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, ends the session `error` after its first call, with `lastError` saying why, rather than letting it run uncapped. The same applies to a session created before v5.3.2, whose earlier calls recorded no usage. +- **Pending approvals are closed too.** A run stopped before it executes an approved action answers that action, and any approval still awaiting a decision, with `budget_exceeded`, so nothing is left pending. +- **A capped session stays capped.** Raise the cap with `set_agent_config` or start a new session. + +A cap is a threshold checked after each call, not a guarantee: the call that reaches it can overshoot it. A call that fails and falls back to another model is not counted, because the failed attempt reports no usage. + +If you upgrade with `maxCostUsd` already set in your configuration, it starts being enforced, and needs `pricing` on the generative entry the agent uses. ### Restricting `http_fetch` diff --git a/reference/models/api.md b/reference/models/api.md index 29ffbf1d8..0b725d583 100644 --- a/reference/models/api.md +++ b/reference/models/api.md @@ -68,13 +68,14 @@ Additional options apply only when `toolMode: 'auto'`; they are documented in [T ### GenerateResult -| Field | Type | Description | -| -------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `content` | `string` | The generated text | -| `finishReason` | `'stop'` \| `'length'` \| `'tool_calls'` \| `'content_filter'` | Why generation stopped, normalized across backends | -| `toolCalls` | `ToolCall[]` | Tool calls the model requested, when `finishReason` is `'tool_calls'` (each `{ id, name, arguments }`, with `arguments` parsed to an object) | -| `usage` | `TokenUsage` | Token usage reported by the backend (`promptTokens`, `completionTokens`, …), when available | -| `trace` | `ToolTraceEntry[]` | Per-tool-invocation trace; only populated by the `toolMode: 'auto'` loop — see [Tool Calling](./tool-calling) | +| Field | Type | Description | +| -------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `content` | `string` | The generated text | +| `finishReason` | `'stop'` \| `'length'` \| `'tool_calls'` \| `'content_filter'` | Why generation stopped, normalized across backends | +| `toolCalls` | `ToolCall[]` | Tool calls the model requested, when `finishReason` is `'tool_calls'` (each `{ id, name, arguments }`, with `arguments` parsed to an object) | +| `usage` | `TokenUsage` | Token usage reported by the backend (`promptTokens`, `completionTokens`, …), when available | +| `costUsd` | `number` | From v5.3.2, the call's cost from the serving entry's [pricing](./overview#pricing); absent when that entry is unpriced or usage is incomplete. With `toolMode: 'auto'`, the final round's only, like `usage` | +| `trace` | `ToolTraceEntry[]` | Per-tool-invocation trace; only populated by the `toolMode: 'auto'` loop — see [Tool Calling](./tool-calling) | ## generateStream() diff --git a/reference/models/backends.md b/reference/models/backends.md index 19b34e65b..f3ae177f3 100644 --- a/reference/models/backends.md +++ b/reference/models/backends.md @@ -26,6 +26,7 @@ All backends support these common fields: | `model` | Provider-side model identifier (e.g. `gpt-4o`) used when a call does not pass its own `model` option | | `requestTimeoutMs` | Per-request timeout in milliseconds; composed with any caller-provided `AbortSignal` | | `revision` | From v5.3.1, a non-secret label for the deployment behind the entry, for [calibration](./calibration#when-the-deployment-changes-but-the-configuration-does-not); change it when the same settings reach a different model | +| `pricing` | From v5.3.2, `generative` entries only: `{ inputPerMtok, outputPerMtok }`, what you pay in USD per million prompt and completion tokens. See [Pricing](./overview#pricing) | ## Ollama diff --git a/reference/models/overview.md b/reference/models/overview.md index 0e0ae29be..a9e294564 100644 --- a/reference/models/overview.md +++ b/reference/models/overview.md @@ -145,6 +145,30 @@ The logical name `default` is used when application code does not pass an explic See [Backends](./backends) for the full set of configuration fields supported by each backend. +### Pricing + + + +A `generative` entry can declare what you pay for its model, so Harper can put a dollar figure on each call. Harper ships no prices of its own: the rates are yours to keep current. + +```yaml +models: + generative: + default: + backend: openai + apiKey: ${OPENAI_API_KEY} + model: gpt-4o + pricing: + inputPerMtok: 2.5 # USD per million prompt tokens + outputPerMtok: 10 # USD per million completion tokens +``` + +Each `generate()` call is priced at the rates of the entry that served it, and the result carries `costUsd`. A call that fell back to another entry in a [fallback group](./routing#fallback-groups) is priced at that entry's rates, not the requested one's. A call has no `costUsd` when the entry that served it declares no `pricing`, or when the provider did not report both a prompt and a completion token count. + +The two rates are all Harper models. Discounts for cached prompt tokens, long-context surcharges and provisioned capacity are not, so set rates that match what an average call costs you, or rates that overestimate it if the figure backs a cap. Streaming calls (`generateStream()`) are not priced. + +The price is read by [`maxCostUsd`](./tool-calling#options) on `toolMode: 'auto'` calls and by the [built-in agent's `maxCostUsd`](../configuration/options#agent-spend-caps). `pricing` is accepted only on `generative` entries; on an `embedding` or `decision` entry it fails configuration validation. A price change applies on config reload, except on an entry whose `backend` is a module rather than a built-in, which takes it on restart like any other change to that entry. It does not affect [calibration](./calibration). + ### Credentials String values in model entries support environment-variable indirection with `${VAR_NAME}` syntax, resolved at startup. Use this for API keys rather than placing the literal key in the configuration file — Harper logs a warning at startup when a credential field contains a literal value. If the referenced environment variable is unset, the placeholder is left as-is; for credential fields the backend rejects the unresolved placeholder at startup, while other fields (such as `host` or `model`) carry the literal placeholder into requests — surfacing as per-request failures rather than a startup error. Indirection applies to string-typed fields only; numeric fields such as `requestTimeoutMs` must be literal values. diff --git a/reference/models/tool-calling.md b/reference/models/tool-calling.md index 0c0f1174e..a82743275 100644 --- a/reference/models/tool-calling.md +++ b/reference/models/tool-calling.md @@ -78,20 +78,21 @@ A handler receives the parsed arguments and a context object `{ signal, accounti All options below apply only with `toolMode: 'auto'`. -| Option | Type | Default | Description | -| -------------------- | ----------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `toolHandlers` | `Record` | — | Dispatch table keyed by tool name. A tool declared in `tools` with no handler here is a configuration error (400) | -| `maxToolIterations` | `number` | `10` | Hard cap on model → tools → model rounds | -| `maxToolTokens` | `number` | — | Cumulative prompt+completion token cap across rounds. Best-effort: requires the backend to report usage; if it does not, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | -| `toolParallelism` | `'parallel'` \| `'serial'` | `'parallel'` | When one round requests multiple tool calls, run handlers concurrently or in order | -| `toolResultMaxBytes` | `number` | `65536` | Per-result byte cap (JSON-stringified). Larger results are truncated with a marker; the model sees the truncated form, the trace records the original size | -| `toolErrorMode` | `'recover'` \| `'abort'` | `'recover'` | `'recover'` feeds a handler error back to the model as the tool result so it can react; `'abort'` stops the loop and throws with the trace attached | -| `includeToolTrace` | `boolean` | `false` | Populate `result.trace` with one entry per tool invocation (iteration, name, arguments, result size, duration, error) | -| `conversation` | `ConversationAppender` | — | Optional persistence hook — see below | +| Option | Type | Default | Description | +| -------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `toolHandlers` | `Record` | — | Dispatch table keyed by tool name. A tool declared in `tools` with no handler here is a configuration error (400) | +| `maxToolIterations` | `number` | `10` | Hard cap on model → tools → model rounds | +| `maxToolTokens` | `number` | — | Cumulative prompt+completion token cap across rounds. Best-effort: requires the backend to report usage; if it does not, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | +| `maxCostUsd` | `number` | — | From v5.3.2, cumulative USD cap across rounds, from the [pricing](./overview#pricing) of the entry that served each round. If a round is unpriced, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | +| `toolParallelism` | `'parallel'` \| `'serial'` | `'parallel'` | When one round requests multiple tool calls, run handlers concurrently or in order | +| `toolResultMaxBytes` | `number` | `65536` | Per-result byte cap (JSON-stringified). Larger results are truncated with a marker; the model sees the truncated form, the trace records the original size | +| `toolErrorMode` | `'recover'` \| `'abort'` | `'recover'` | `'recover'` feeds a handler error back to the model as the tool result so it can react; `'abort'` stops the loop and throws with the trace attached | +| `includeToolTrace` | `boolean` | `false` | Populate `result.trace` with one entry per tool invocation (iteration, name, arguments, result size, duration, error) | +| `conversation` | `ConversationAppender` | — | Optional persistence hook — see below | ### Budgets and errors -When `maxToolIterations` or `maxToolTokens` is exhausted, the loop throws a budget-exceeded error (HTTP status 429) carrying a `partialTrace` of everything that ran — the trace is attached on error paths regardless of `includeToolTrace`. With `toolErrorMode: 'abort'`, a handler failure throws an error carrying the same trace. +When `maxToolIterations`, `maxToolTokens` or `maxCostUsd` is exhausted, the loop throws a budget-exceeded error (HTTP status 429) carrying a `partialTrace` of everything that ran — the trace is attached on error paths regardless of `includeToolTrace`. With `toolErrorMode: 'abort'`, a handler failure throws an error carrying the same trace. If the model requests a tool name that was never declared in `tools` (a hallucinated tool), the call is treated as a tool error and routed by `toolErrorMode` — with `'recover'`, the model is told the tool is unknown and can correct itself. @@ -101,4 +102,4 @@ The `conversation` option accepts any object with an `append(turn)` method retur ### Reserved options -`toolArgValidation` (`'strict'` / `'lenient'` JSON Schema validation of tool arguments), `maxCostUsd`, and `conversationId` exist on the type surface but are not functional in 5.1 — the validation modes and streaming token budgets throw a `501` error, and the cost cap has no rate card behind it yet. Don't rely on them. +`toolArgValidation` (`'strict'` / `'lenient'` JSON Schema validation of tool arguments) and `conversationId` exist on the type surface but are not functional: the validation modes and streaming token and cost budgets throw a `501` error. Don't rely on them. Before v5.3.2, `maxCostUsd` had no prices behind it and never tripped. diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index eb05c42de..c51425098 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1708,7 +1708,7 @@ A session's `status` is one of: | `awaiting_approval` | Paused on one or more destructive tool calls; see `pendingApprovals` | | `completed` | The run ended without throwing — a final answer, or the `maxTurns` ceiling; check `lastError` | | `aborted` | Cancelled by an operator via `cancel_agent_run` | -| `error` | The run failed; `lastError` carries the message | +| `error` | The run failed or a spend cap stopped it; `lastError` carries the message | `completed` also covers hitting the `agent.maxTurns` ceiling — in that case `lastError` reads `Reached maxTurns= without a final answer.`, so check it before treating a completed session as finished. @@ -1740,6 +1740,14 @@ A session that is `running` or `awaiting_approval` rejects a new prompt with a 4 Returns the full session record: `status`, `user` (the Operations API caller who created the session, falling back to `agent.user`), the `messages` transcript (user, assistant, and tool messages, including tool calls and their observations), `pendingApprovals`, `model`, `provider`, `createdAt`/`updatedAt`, and `lastError`. This is the polling endpoint for a run in flight. +From v5.3.2 each assistant message carries the `usage` its model call reported (`promptTokens`, `completionTokens`, and `costUsd` when the model is [priced](../models/overview.md#pricing)), and the response adds the session's totals: + +```json +"usage": { "calls": 3, "promptTokens": 41230, "completionTokens": 2210, "costUsd": 0.157 } +``` + +`calls` counts model calls. A total is left out once any call in the session did not report it, such as an unpriced model for `costUsd`, so a missing total means unmeasured, not zero. The [spend caps](../configuration/options.md#agent-spend-caps) read the same totals. + ```json { "operation": "get_agent_session", "session_id": "3f7c..." } ``` @@ -1748,7 +1756,7 @@ Unknown `session_id` returns a 404. ### `list_agent_sessions` -Lists agent sessions. +Lists agent sessions, each with the same `usage` totals as `get_agent_session`. | Parameter | Type | Description | | --------- | ------- | ---------------------------------------- | @@ -1809,7 +1817,7 @@ One gap is worth knowing: changing `allowDestructive` with [`set_agent_config`]( ### `set_agent_config` -Updates agent settings and returns the resulting configuration. Accepts any of `enabled`, `provider`, `model`, `maxTurns`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend`; keys not supplied are left unchanged. Each field is described under [`agent`](../configuration/options.md#agent). A request that includes `httpFetch` is rejected with a 400 and nothing in it is applied: the [`http_fetch` policy](../configuration/options.md#restricting-http_fetch) is read at startup only. +Updates agent settings and returns the resulting configuration. Accepts any of `enabled`, `provider`, `model`, `maxTurns`, `maxTokens`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend`; keys not supplied are left unchanged. Set `maxTokens` or `maxCostUsd` to `null` to remove that cap; a value that is not a usable number is rejected with a 400, and nothing in the request is applied. Each field is described under [`agent`](../configuration/options.md#agent). A request that includes `httpFetch` is rejected with a 400 and nothing in it is applied: the [`http_fetch` policy](../configuration/options.md#restricting-http_fetch) is read at startup only. ```json { "operation": "set_agent_config", "autoApprove": false, "maxTurns": 20 } @@ -1818,7 +1826,7 @@ Updates agent settings and returns the resulting configuration. Accepts any of ` Three limits are worth knowing: - **The change is in-memory and not persisted.** It applies for the life of the process and is lost on restart; edit `harper-config.yaml` for a durable change. -- **A run already in flight keeps the settings it started with** — its toolset, `autoApprove`, `model`, and `systemPromptAppend` are all captured at start. Changes take effect on the next run. To stop a run immediately, use `cancel_agent_run`. +- **A run already in flight keeps the settings it started with** — its toolset, `autoApprove`, `model`, `systemPromptAppend` and spend caps are all captured at start. Changes take effect on the next run. To stop a run immediately, use `cancel_agent_run`. - **`enabled` is not a kill switch.** It cannot turn the agent on — if it was off at startup, this operation does not exist. Setting it to `false` only makes subsequent `agent_prompt` calls return 409; a run already in flight continues, and `approve_agent_action` still resumes a paused one. Use `cancel_agent_run` to stop a run. ### MCP access From 7df48c53e4590b3a2b584b1251aa0740d001a9d3 Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 12:57:57 -0400 Subject: [PATCH 02/15] Rename the session token cap to maxSessionTokens, and say which calls a cap counts agent.maxTokens is the per-reply output limit (HarperFast/documentation#712). Also: a failed call is never counted, an unmeasurable cap on a final answer ends completed, invalid caps fail the agent's start, and auto-mode costUsd covers the final round. Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/configuration/options.md | 16 +++++++++------- reference/models/overview.md | 2 +- reference/models/tool-calling.md | 22 +++++++++++----------- reference/operations-api/operations.md | 4 ++-- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/reference/configuration/options.md b/reference/configuration/options.md index ea2913234..280492a5d 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -393,8 +393,8 @@ agent: - `provider` — Recorded on the session but **not yet used to route the model call** — only `model` reaches the provider. Set the provider through the [`models`](../models/overview.md#configuration) configuration instead - `model` — Model id override, passed through to the model call; _Default_: the [`models`](../models/overview.md#configuration) generative default - `maxTurns` — Maximum tool-call iterations in a single run; _Default_: `50` -- `maxTokens` — Per-session cap on prompt plus completion tokens. See [Spend caps](#agent-spend-caps); _Default_: none -- `maxCostUsd` — Per-session cap in USD, priced from the [`pricing`](../models/overview.md#pricing) on the `models.generative` entry that served each call. See [Spend caps](#agent-spend-caps). Before v5.3.2 this setting was stored but never enforced, and defaulted to `5.00`; _Default_: none +- `maxSessionTokens` — Per-session cap on prompt plus completion tokens, summed over every model call in the session; a positive integer. See [Spend caps](#agent-spend-caps); _Default_: none +- `maxCostUsd` — Per-session cap in USD, priced from the [`pricing`](../models/overview.md#pricing) on the `models.generative` entry that served each call; a number of `0` or more, and `0` stops every session before its first call. See [Spend caps](#agent-spend-caps). Before v5.3.2 this setting was stored but never enforced, and defaulted to `5.00`; _Default_: none - `autoApprove` — Run without per-action approval gates; _Default_: `false` - `allowDestructive` — Include the tools marked destructive in the agent's toolset: `write_file`, the inspector's code-evaluation tools, and any operations tool carrying MCP's [`destructiveHint`](../mcp/tool-metadata.md) (`drop_table`, `delete`, `restart`, `set_configuration`, ...). When `false` they are removed entirely rather than gated. That hint comes from a curated set in core which does not cover every damaging operation, so this is not a complete safety boundary on its own — see [Agent operations](../operations-api/operations.md#agent); _Default_: `false` - `user` — Harper user the agent's **operations** tools run as; the filesystem, HTTP, schedule, and inspector tools always run at process privilege regardless. If it cannot be resolved and it is not the default, the agent fails closed and runs with no operations tools; _Default_: `hdb_agent`, which falls back to a `super_user` bootstrap identity @@ -402,22 +402,24 @@ agent: - `httpFetch` — Whether the agent has its `http_fetch` tool, and which hosts it may reach: `true`, `false`, or `{ allow: [...] }`. Read at startup only. See [Restricting `http_fetch`](#restricting-http_fetch); _Default_: `true` - `systemPromptAppend` — Operator text appended to the agent's system prompt -`enabled`, `provider`, `model`, `maxTurns`, `maxTokens`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend` can also be changed at runtime with [`set_agent_config`](../operations-api/operations.md#set_agent_config), which applies in memory only. `enabled` is the exception worth knowing: it cannot switch the agent on, because with the agent disabled at startup no agent operation is registered at all. +`enabled`, `provider`, `model`, `maxTurns`, `maxSessionTokens`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend` can also be changed at runtime with [`set_agent_config`](../operations-api/operations.md#set_agent_config), which applies in memory only. `enabled` is the exception worth knowing: it cannot switch the agent on, because with the agent disabled at startup no agent operation is registered at all. ### Agent spend caps -`maxTokens` and `maxCostUsd` bound a whole session, across every run in it. Approving a paused action or sending a follow-up prompt starts a new run, but does not reset a cap the way it resets `maxTurns`. Each assistant message records the usage of the model call that produced it, and [`get_agent_session`](../operations-api/operations.md#get_agent_session) reports the session's totals as `usage`. +`maxSessionTokens` and `maxCostUsd` bound a whole session, across every run in it. Approving a paused action or sending a follow-up prompt starts a new run, but does not reset a cap the way it resets `maxTurns`. Each assistant message records the usage of the model call that produced it, and [`get_agent_session`](../operations-api/operations.md#get_agent_session) reports the session's totals as `usage`. A cap is checked when a run starts, before it executes an approved action, and after every model call: -- **At or over a cap**, the session makes no further model call and ends `error`, with `lastError` naming the cap, for example `agent.maxTokens=200000 reached: this session used 201450 tokens`. Tool calls the model had just requested are answered with a `budget_exceeded` result rather than run, so the transcript stays valid if you continue the session. A final answer that crosses the cap is still delivered: the run ends `completed`, with `lastError` naming the cap. -- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, ends the session `error` after its first call, with `lastError` saying why, rather than letting it run uncapped. The same applies to a session created before v5.3.2, whose earlier calls recorded no usage. +- **At or over a cap**, the session makes no further model call and ends `error`, with `lastError` naming the cap, for example `agent.maxSessionTokens=200000 reached: this session used 201450 tokens`. Tool calls the model had just requested are answered with a `budget_exceeded` result rather than run, so the transcript stays valid if you continue the session. A final answer that crosses the cap is still delivered: the run ends `completed`, with `lastError` naming the cap. +- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The same applies to a session created before v5.3.2, whose earlier calls recorded no usage. - **Pending approvals are closed too.** A run stopped before it executes an approved action answers that action, and any approval still awaiting a decision, with `budget_exceeded`, so nothing is left pending. - **A capped session stays capped.** Raise the cap with `set_agent_config` or start a new session. -A cap is a threshold checked after each call, not a guarantee: the call that reaches it can overshoot it. A call that fails and falls back to another model is not counted, because the failed attempt reports no usage. +A cap is a threshold checked after each call, not a guarantee: the call that reaches it can overshoot it. Only calls that return are counted. A call that fails, for example on a timeout or a provider error and whether or not another model then serves it, is not, even if the provider billed it. + +A cap that is not a usable number fails the agent's start: Harper logs the error and registers no agent operation. Through `set_agent_config` the same value is rejected with a 400. If you upgrade with `maxCostUsd` already set in your configuration, it starts being enforced, and needs `pricing` on the generative entry the agent uses. diff --git a/reference/models/overview.md b/reference/models/overview.md index a9e294564..0d138229e 100644 --- a/reference/models/overview.md +++ b/reference/models/overview.md @@ -163,7 +163,7 @@ models: outputPerMtok: 10 # USD per million completion tokens ``` -Each `generate()` call is priced at the rates of the entry that served it, and the result carries `costUsd`. A call that fell back to another entry in a [fallback group](./routing#fallback-groups) is priced at that entry's rates, not the requested one's. A call has no `costUsd` when the entry that served it declares no `pricing`, or when the provider did not report both a prompt and a completion token count. +Each `generate()` call is priced at the rates of the entry that served it, and the result carries `costUsd`. With `toolMode: 'auto'`, the result's `costUsd` covers the final round only, like its `usage`. A call that fell back to another entry in a [fallback group](./routing#fallback-groups) is priced at that entry's rates, not the requested one's. A call has no `costUsd` when the entry that served it declares no `pricing`, or when the provider did not report both a prompt and a completion token count. The two rates are all Harper models. Discounts for cached prompt tokens, long-context surcharges and provisioned capacity are not, so set rates that match what an average call costs you, or rates that overestimate it if the figure backs a cap. Streaming calls (`generateStream()`) are not priced. diff --git a/reference/models/tool-calling.md b/reference/models/tool-calling.md index a82743275..6d36e4ab2 100644 --- a/reference/models/tool-calling.md +++ b/reference/models/tool-calling.md @@ -78,17 +78,17 @@ A handler receives the parsed arguments and a context object `{ signal, accounti All options below apply only with `toolMode: 'auto'`. -| Option | Type | Default | Description | -| -------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `toolHandlers` | `Record` | — | Dispatch table keyed by tool name. A tool declared in `tools` with no handler here is a configuration error (400) | -| `maxToolIterations` | `number` | `10` | Hard cap on model → tools → model rounds | -| `maxToolTokens` | `number` | — | Cumulative prompt+completion token cap across rounds. Best-effort: requires the backend to report usage; if it does not, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | -| `maxCostUsd` | `number` | — | From v5.3.2, cumulative USD cap across rounds, from the [pricing](./overview#pricing) of the entry that served each round. If a round is unpriced, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | -| `toolParallelism` | `'parallel'` \| `'serial'` | `'parallel'` | When one round requests multiple tool calls, run handlers concurrently or in order | -| `toolResultMaxBytes` | `number` | `65536` | Per-result byte cap (JSON-stringified). Larger results are truncated with a marker; the model sees the truncated form, the trace records the original size | -| `toolErrorMode` | `'recover'` \| `'abort'` | `'recover'` | `'recover'` feeds a handler error back to the model as the tool result so it can react; `'abort'` stops the loop and throws with the trace attached | -| `includeToolTrace` | `boolean` | `false` | Populate `result.trace` with one entry per tool invocation (iteration, name, arguments, result size, duration, error) | -| `conversation` | `ConversationAppender` | — | Optional persistence hook — see below | +| Option | Type | Default | Description | +| -------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `toolHandlers` | `Record` | — | Dispatch table keyed by tool name. A tool declared in `tools` with no handler here is a configuration error (400) | +| `maxToolIterations` | `number` | `10` | Hard cap on model → tools → model rounds | +| `maxToolTokens` | `number` | — | Cumulative prompt+completion token cap across rounds. Best-effort: requires the backend to report usage; if it does not, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | +| `maxCostUsd` | `number` | — | From v5.3.2, cumulative USD cap across rounds, from the [pricing](./overview#pricing) of the entry that served each round. If a round is unpriced, Harper warns once and `maxToolIterations` remains the bound; a cap of `0` still stops after the first round. Not supported on `generateStream()` (throws) | +| `toolParallelism` | `'parallel'` \| `'serial'` | `'parallel'` | When one round requests multiple tool calls, run handlers concurrently or in order | +| `toolResultMaxBytes` | `number` | `65536` | Per-result byte cap (JSON-stringified). Larger results are truncated with a marker; the model sees the truncated form, the trace records the original size | +| `toolErrorMode` | `'recover'` \| `'abort'` | `'recover'` | `'recover'` feeds a handler error back to the model as the tool result so it can react; `'abort'` stops the loop and throws with the trace attached | +| `includeToolTrace` | `boolean` | `false` | Populate `result.trace` with one entry per tool invocation (iteration, name, arguments, result size, duration, error) | +| `conversation` | `ConversationAppender` | — | Optional persistence hook — see below | ### Budgets and errors diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index c51425098..3e318e603 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1746,7 +1746,7 @@ From v5.3.2 each assistant message carries the `usage` its model call reported ( "usage": { "calls": 3, "promptTokens": 41230, "completionTokens": 2210, "costUsd": 0.157 } ``` -`calls` counts model calls. A total is left out once any call in the session did not report it, such as an unpriced model for `costUsd`, so a missing total means unmeasured, not zero. The [spend caps](../configuration/options.md#agent-spend-caps) read the same totals. +`calls` counts the model calls that returned an answer; a failed call is not counted. A total is left out once any call in the session did not report it, such as an unpriced model for `costUsd`, so a missing total means unmeasured, not zero. The [spend caps](../configuration/options.md#agent-spend-caps) read the same totals. ```json { "operation": "get_agent_session", "session_id": "3f7c..." } @@ -1817,7 +1817,7 @@ One gap is worth knowing: changing `allowDestructive` with [`set_agent_config`]( ### `set_agent_config` -Updates agent settings and returns the resulting configuration. Accepts any of `enabled`, `provider`, `model`, `maxTurns`, `maxTokens`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend`; keys not supplied are left unchanged. Set `maxTokens` or `maxCostUsd` to `null` to remove that cap; a value that is not a usable number is rejected with a 400, and nothing in the request is applied. Each field is described under [`agent`](../configuration/options.md#agent). A request that includes `httpFetch` is rejected with a 400 and nothing in it is applied: the [`http_fetch` policy](../configuration/options.md#restricting-http_fetch) is read at startup only. +Updates agent settings and returns the resulting configuration. Accepts any of `enabled`, `provider`, `model`, `maxTurns`, `maxSessionTokens`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend`; keys not supplied are left unchanged. Set `maxSessionTokens` or `maxCostUsd` to `null` to remove that cap; a value that is not a usable number is rejected with a 400, and nothing in the request is applied. Each field is described under [`agent`](../configuration/options.md#agent). A request that includes `httpFetch` is rejected with a 400 and nothing in it is applied: the [`http_fetch` policy](../configuration/options.md#restricting-http_fetch) is read at startup only. ```json { "operation": "set_agent_config", "autoApprove": false, "maxTurns": 20 } From d4358088ca817dc88ea6cd9d36d0169718b5f10a Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 13:33:50 -0400 Subject: [PATCH 03/15] Say how to leave a session whose cap cannot be measured, and that a refused approval does not run later Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/configuration/options.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/reference/configuration/options.md b/reference/configuration/options.md index 280492a5d..e55f569fe 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -393,7 +393,7 @@ agent: - `provider` — Recorded on the session but **not yet used to route the model call** — only `model` reaches the provider. Set the provider through the [`models`](../models/overview.md#configuration) configuration instead - `model` — Model id override, passed through to the model call; _Default_: the [`models`](../models/overview.md#configuration) generative default - `maxTurns` — Maximum tool-call iterations in a single run; _Default_: `50` -- `maxSessionTokens` — Per-session cap on prompt plus completion tokens, summed over every model call in the session; a positive integer. See [Spend caps](#agent-spend-caps); _Default_: none +- `maxSessionTokens` — Per-session cap on prompt plus completion tokens, summed over every model call in the session that returns; a positive integer. See [Spend caps](#agent-spend-caps); _Default_: none - `maxCostUsd` — Per-session cap in USD, priced from the [`pricing`](../models/overview.md#pricing) on the `models.generative` entry that served each call; a number of `0` or more, and `0` stops every session before its first call. See [Spend caps](#agent-spend-caps). Before v5.3.2 this setting was stored but never enforced, and defaulted to `5.00`; _Default_: none - `autoApprove` — Run without per-action approval gates; _Default_: `false` - `allowDestructive` — Include the tools marked destructive in the agent's toolset: `write_file`, the inspector's code-evaluation tools, and any operations tool carrying MCP's [`destructiveHint`](../mcp/tool-metadata.md) (`drop_table`, `delete`, `restart`, `set_configuration`, ...). When `false` they are removed entirely rather than gated. That hint comes from a curated set in core which does not cover every damaging operation, so this is not a complete safety boundary on its own — see [Agent operations](../operations-api/operations.md#agent); _Default_: `false` @@ -413,8 +413,8 @@ agent: A cap is checked when a run starts, before it executes an approved action, and after every model call: - **At or over a cap**, the session makes no further model call and ends `error`, with `lastError` naming the cap, for example `agent.maxSessionTokens=200000 reached: this session used 201450 tokens`. Tool calls the model had just requested are answered with a `budget_exceeded` result rather than run, so the transcript stays valid if you continue the session. A final answer that crosses the cap is still delivered: the run ends `completed`, with `lastError` naming the cap. -- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The same applies to a session created before v5.3.2, whose earlier calls recorded no usage. -- **Pending approvals are closed too.** A run stopped before it executes an approved action answers that action, and any approval still awaiting a decision, with `budget_exceeded`, so nothing is left pending. +- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The unmeasured call stays in the session's totals, so later runs stop before making any call, and raising the cap or adding `pricing` does not clear it: start a new session, or set the cap to `null`. A session created before v5.3.2, whose earlier calls recorded no usage, is in the same state. +- **Pending approvals are closed too.** A run stopped before it executes an approved action answers that action, and any approval still awaiting a decision, with `budget_exceeded`, so nothing is left pending. The action does not run later, even once the cap is raised; the model sees the refusal and can ask again. - **A capped session stays capped.** Raise the cap with `set_agent_config` or start a new session. A cap is a threshold checked after each call, not a guarantee: the call that reaches it can overshoot it. Only calls that return are counted. A call that fails, for example on a timeout or a provider error and whether or not another model then serves it, is not, even if the provider billed it. From 30cb0c70debd54ae7b2c43447b73929b181fb044 Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 13:40:12 -0400 Subject: [PATCH 04/15] Date model pricing and the agent spend caps to 5.4.0, and add them to the 5.4 release notes HarperFast/harper#3040 is milestoned v5.4, which is main, so it ships in 5.4.0 rather than a 5.3 patch. Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/configuration/options.md | 10 +++++----- reference/models/api.md | 2 +- reference/models/backends.md | 2 +- reference/models/overview.md | 2 +- reference/models/tool-calling.md | 4 ++-- reference/operations-api/operations.md | 2 +- release-notes/v5-lincoln/5.4.md | 10 ++++++++++ 7 files changed, 21 insertions(+), 11 deletions(-) diff --git a/reference/configuration/options.md b/reference/configuration/options.md index e55f569fe..688dba96e 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -393,8 +393,8 @@ agent: - `provider` — Recorded on the session but **not yet used to route the model call** — only `model` reaches the provider. Set the provider through the [`models`](../models/overview.md#configuration) configuration instead - `model` — Model id override, passed through to the model call; _Default_: the [`models`](../models/overview.md#configuration) generative default - `maxTurns` — Maximum tool-call iterations in a single run; _Default_: `50` -- `maxSessionTokens` — Per-session cap on prompt plus completion tokens, summed over every model call in the session that returns; a positive integer. See [Spend caps](#agent-spend-caps); _Default_: none -- `maxCostUsd` — Per-session cap in USD, priced from the [`pricing`](../models/overview.md#pricing) on the `models.generative` entry that served each call; a number of `0` or more, and `0` stops every session before its first call. See [Spend caps](#agent-spend-caps). Before v5.3.2 this setting was stored but never enforced, and defaulted to `5.00`; _Default_: none +- `maxSessionTokens` — Per-session cap on prompt plus completion tokens, summed over every model call in the session that returns; a positive integer. See [Spend caps](#agent-spend-caps); _Default_: none +- `maxCostUsd` — Per-session cap in USD, priced from the [`pricing`](../models/overview.md#pricing) on the `models.generative` entry that served each call; a number of `0` or more, and `0` stops every session before its first call. See [Spend caps](#agent-spend-caps). Before v5.4.0 this setting was stored but never enforced, and defaulted to `5.00`; _Default_: none - `autoApprove` — Run without per-action approval gates; _Default_: `false` - `allowDestructive` — Include the tools marked destructive in the agent's toolset: `write_file`, the inspector's code-evaluation tools, and any operations tool carrying MCP's [`destructiveHint`](../mcp/tool-metadata.md) (`drop_table`, `delete`, `restart`, `set_configuration`, ...). When `false` they are removed entirely rather than gated. That hint comes from a curated set in core which does not cover every damaging operation, so this is not a complete safety boundary on its own — see [Agent operations](../operations-api/operations.md#agent); _Default_: `false` - `user` — Harper user the agent's **operations** tools run as; the filesystem, HTTP, schedule, and inspector tools always run at process privilege regardless. If it cannot be resolved and it is not the default, the agent fails closed and runs with no operations tools; _Default_: `hdb_agent`, which falls back to a `super_user` bootstrap identity @@ -406,14 +406,14 @@ agent: ### Agent spend caps - + `maxSessionTokens` and `maxCostUsd` bound a whole session, across every run in it. Approving a paused action or sending a follow-up prompt starts a new run, but does not reset a cap the way it resets `maxTurns`. Each assistant message records the usage of the model call that produced it, and [`get_agent_session`](../operations-api/operations.md#get_agent_session) reports the session's totals as `usage`. A cap is checked when a run starts, before it executes an approved action, and after every model call: - **At or over a cap**, the session makes no further model call and ends `error`, with `lastError` naming the cap, for example `agent.maxSessionTokens=200000 reached: this session used 201450 tokens`. Tool calls the model had just requested are answered with a `budget_exceeded` result rather than run, so the transcript stays valid if you continue the session. A final answer that crosses the cap is still delivered: the run ends `completed`, with `lastError` naming the cap. -- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The unmeasured call stays in the session's totals, so later runs stop before making any call, and raising the cap or adding `pricing` does not clear it: start a new session, or set the cap to `null`. A session created before v5.3.2, whose earlier calls recorded no usage, is in the same state. +- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The unmeasured call stays in the session's totals, so later runs stop before making any call, and raising the cap or adding `pricing` does not clear it: start a new session, or set the cap to `null`. A session created before v5.4.0, whose earlier calls recorded no usage, is in the same state. - **Pending approvals are closed too.** A run stopped before it executes an approved action answers that action, and any approval still awaiting a decision, with `budget_exceeded`, so nothing is left pending. The action does not run later, even once the cap is raised; the model sees the refusal and can ask again. - **A capped session stays capped.** Raise the cap with `set_agent_config` or start a new session. @@ -425,7 +425,7 @@ If you upgrade with `maxCostUsd` already set in your configuration, it starts be ### Restricting `http_fetch` - + `http_fetch` lets the agent send HTTP requests from the Harper process: to its own REST endpoints while it builds an app, or to pull a reference page. The same agent reads table rows, logs, and component source, and text in any of them can steer it, so an enabled agent has a read path and an egress path in one toolset. `agent.httpFetch` bounds that tool. It is not a bound on every way the agent can reach the network: with `allowDestructive` on, for example, the agent can write component code, and the Harper process runs that code. diff --git a/reference/models/api.md b/reference/models/api.md index 0b725d583..ef29e3388 100644 --- a/reference/models/api.md +++ b/reference/models/api.md @@ -74,7 +74,7 @@ Additional options apply only when `toolMode: 'auto'`; they are documented in [T | `finishReason` | `'stop'` \| `'length'` \| `'tool_calls'` \| `'content_filter'` | Why generation stopped, normalized across backends | | `toolCalls` | `ToolCall[]` | Tool calls the model requested, when `finishReason` is `'tool_calls'` (each `{ id, name, arguments }`, with `arguments` parsed to an object) | | `usage` | `TokenUsage` | Token usage reported by the backend (`promptTokens`, `completionTokens`, …), when available | -| `costUsd` | `number` | From v5.3.2, the call's cost from the serving entry's [pricing](./overview#pricing); absent when that entry is unpriced or usage is incomplete. With `toolMode: 'auto'`, the final round's only, like `usage` | +| `costUsd` | `number` | From v5.4.0, the call's cost from the serving entry's [pricing](./overview#pricing); absent when that entry is unpriced or usage is incomplete. With `toolMode: 'auto'`, the final round's only, like `usage` | | `trace` | `ToolTraceEntry[]` | Per-tool-invocation trace; only populated by the `toolMode: 'auto'` loop — see [Tool Calling](./tool-calling) | ## generateStream() diff --git a/reference/models/backends.md b/reference/models/backends.md index f3ae177f3..be4b71886 100644 --- a/reference/models/backends.md +++ b/reference/models/backends.md @@ -26,7 +26,7 @@ All backends support these common fields: | `model` | Provider-side model identifier (e.g. `gpt-4o`) used when a call does not pass its own `model` option | | `requestTimeoutMs` | Per-request timeout in milliseconds; composed with any caller-provided `AbortSignal` | | `revision` | From v5.3.1, a non-secret label for the deployment behind the entry, for [calibration](./calibration#when-the-deployment-changes-but-the-configuration-does-not); change it when the same settings reach a different model | -| `pricing` | From v5.3.2, `generative` entries only: `{ inputPerMtok, outputPerMtok }`, what you pay in USD per million prompt and completion tokens. See [Pricing](./overview#pricing) | +| `pricing` | From v5.4.0, `generative` entries only: `{ inputPerMtok, outputPerMtok }`, what you pay in USD per million prompt and completion tokens. See [Pricing](./overview#pricing) | ## Ollama diff --git a/reference/models/overview.md b/reference/models/overview.md index 0d138229e..03f820019 100644 --- a/reference/models/overview.md +++ b/reference/models/overview.md @@ -147,7 +147,7 @@ See [Backends](./backends) for the full set of configuration fields supported by ### Pricing - + A `generative` entry can declare what you pay for its model, so Harper can put a dollar figure on each call. Harper ships no prices of its own: the rates are yours to keep current. diff --git a/reference/models/tool-calling.md b/reference/models/tool-calling.md index 6d36e4ab2..4483a2c7b 100644 --- a/reference/models/tool-calling.md +++ b/reference/models/tool-calling.md @@ -83,7 +83,7 @@ All options below apply only with `toolMode: 'auto'`. | `toolHandlers` | `Record` | — | Dispatch table keyed by tool name. A tool declared in `tools` with no handler here is a configuration error (400) | | `maxToolIterations` | `number` | `10` | Hard cap on model → tools → model rounds | | `maxToolTokens` | `number` | — | Cumulative prompt+completion token cap across rounds. Best-effort: requires the backend to report usage; if it does not, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | -| `maxCostUsd` | `number` | — | From v5.3.2, cumulative USD cap across rounds, from the [pricing](./overview#pricing) of the entry that served each round. If a round is unpriced, Harper warns once and `maxToolIterations` remains the bound; a cap of `0` still stops after the first round. Not supported on `generateStream()` (throws) | +| `maxCostUsd` | `number` | — | From v5.4.0, cumulative USD cap across rounds, from the [pricing](./overview#pricing) of the entry that served each round. If a round is unpriced, Harper warns once and `maxToolIterations` remains the bound; a cap of `0` still stops after the first round. Not supported on `generateStream()` (throws) | | `toolParallelism` | `'parallel'` \| `'serial'` | `'parallel'` | When one round requests multiple tool calls, run handlers concurrently or in order | | `toolResultMaxBytes` | `number` | `65536` | Per-result byte cap (JSON-stringified). Larger results are truncated with a marker; the model sees the truncated form, the trace records the original size | | `toolErrorMode` | `'recover'` \| `'abort'` | `'recover'` | `'recover'` feeds a handler error back to the model as the tool result so it can react; `'abort'` stops the loop and throws with the trace attached | @@ -102,4 +102,4 @@ The `conversation` option accepts any object with an `append(turn)` method retur ### Reserved options -`toolArgValidation` (`'strict'` / `'lenient'` JSON Schema validation of tool arguments) and `conversationId` exist on the type surface but are not functional: the validation modes and streaming token and cost budgets throw a `501` error. Don't rely on them. Before v5.3.2, `maxCostUsd` had no prices behind it and never tripped. +`toolArgValidation` (`'strict'` / `'lenient'` JSON Schema validation of tool arguments) and `conversationId` exist on the type surface but are not functional: the validation modes and streaming token and cost budgets throw a `501` error. Don't rely on them. Before v5.4.0, `maxCostUsd` had no prices behind it and never tripped. diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 3e318e603..3869f37c0 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1740,7 +1740,7 @@ A session that is `running` or `awaiting_approval` rejects a new prompt with a 4 Returns the full session record: `status`, `user` (the Operations API caller who created the session, falling back to `agent.user`), the `messages` transcript (user, assistant, and tool messages, including tool calls and their observations), `pendingApprovals`, `model`, `provider`, `createdAt`/`updatedAt`, and `lastError`. This is the polling endpoint for a run in flight. -From v5.3.2 each assistant message carries the `usage` its model call reported (`promptTokens`, `completionTokens`, and `costUsd` when the model is [priced](../models/overview.md#pricing)), and the response adds the session's totals: +From v5.4.0 each assistant message carries the `usage` its model call reported (`promptTokens`, `completionTokens`, and `costUsd` when the model is [priced](../models/overview.md#pricing)), and the response adds the session's totals: ```json "usage": { "calls": 3, "promptTokens": 41230, "completionTokens": 2210, "costUsd": 0.157 } diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 321203679..5efd788c6 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -23,3 +23,13 @@ A durable MQTT session now resumes through the same checked replay. When a recon ### Certifying a Release in a Canary Worker A `deploy_component` with `restart: true` or `restart: "rolling"` now checks its release before rolling it out. The first worker started on the release, the canary, is held out of traffic until it reports whether the deployed component loaded, and the rollout goes on only if it did. Each later replacement is checked the same way. When the node that received the deploy rejects the release, the deploy fails with `400`. The release it replaced is made live again, or, with nothing to go back to, the component fails closed on that node. A new `certification` field in the response reports the outcome, and says when a restarting deploy went out unchecked (`uncertified` or `unavailable`, as for a `package: file:` deploy). With `"rolling"`, each other node then certifies the release with a canary of its own, and a node that rejects it fails the deploy's job, not the deploy: poll its `restartJobId` with `get_job` for the outcome. A node running a version before 5.4.0 activates without a canary. A deploy that restarts nothing is not test-loaded. If its release throws when it loads, `get_status` reports the failure. See [Certifying a release in a canary worker](/reference/v5/operations-api/operations#certifying-a-release-in-a-canary-worker). + +## Models and the Built-in Agent + +### Model Pricing and Per-Call Cost + +A `models.generative` entry can now declare `pricing: { inputPerMtok, outputPerMtok }`, what you pay in USD per million prompt and completion tokens. Harper ships no prices of its own. Each `generate()` call is priced at the rates of the entry that served it, a fallback included, and the result carries `costUsd`. This makes `maxCostUsd` work on `toolMode: 'auto'` calls; before, it had no prices behind it and never tripped. See [Pricing](/reference/v5/models/overview#pricing). + +### Spend Caps for the Built-in Agent + +The agent records the usage of each model call on its session, and `get_agent_session` and `list_agent_sessions` report a session's totals as `usage`. Two caps bound a whole session, across every run in it: the new `agent.maxSessionTokens`, and `agent.maxCostUsd`, which is now enforced from model pricing and no longer defaults to `5.00`. A session at a cap makes no further model call and ends `error`, and a cap that cannot be measured stops the session rather than letting it run uncapped. If your configuration already sets `agent.maxCostUsd`, declare `pricing` on the generative entry the agent uses. See [Agent spend caps](/reference/v5/configuration/options#agent-spend-caps). From 22705b60e9fbe61979dd73351a203cf90b8f8025 Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 13:42:31 -0400 Subject: [PATCH 05/15] Say an unmeasured call keeps the session total unmeasured, not that it stays in the total Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/configuration/options.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/configuration/options.md b/reference/configuration/options.md index 688dba96e..154b77a9e 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -413,7 +413,7 @@ agent: A cap is checked when a run starts, before it executes an approved action, and after every model call: - **At or over a cap**, the session makes no further model call and ends `error`, with `lastError` naming the cap, for example `agent.maxSessionTokens=200000 reached: this session used 201450 tokens`. Tool calls the model had just requested are answered with a `budget_exceeded` result rather than run, so the transcript stays valid if you continue the session. A final answer that crosses the cap is still delivered: the run ends `completed`, with `lastError` naming the cap. -- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The unmeasured call stays in the session's totals, so later runs stop before making any call, and raising the cap or adding `pricing` does not clear it: start a new session, or set the cap to `null`. A session created before v5.4.0, whose earlier calls recorded no usage, is in the same state. +- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The unmeasured call stays in the transcript, so the session's total stays unmeasured and later runs stop before making any call, and raising the cap or adding `pricing` does not clear it: start a new session, or set the cap to `null`. A session created before v5.4.0, whose earlier calls recorded no usage, is in the same state. - **Pending approvals are closed too.** A run stopped before it executes an approved action answers that action, and any approval still awaiting a decision, with `budget_exceeded`, so nothing is left pending. The action does not run later, even once the cap is raised; the model sees the refusal and can ask again. - **A capped session stays capped.** Raise the cap with `set_agent_config` or start a new session. From 093e0c96362c94ab00246bebab0479c3706b547c Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 13:45:55 -0400 Subject: [PATCH 06/15] Keep the http_fetch section badge at v5.3.2, and make the 5.4 notes exact about costUsd and capped final answers Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/configuration/options.md | 2 +- reference/operations-api/operations.md | 6 +++++- release-notes/v5-lincoln/5.4.md | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/reference/configuration/options.md b/reference/configuration/options.md index 154b77a9e..75d544260 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -425,7 +425,7 @@ If you upgrade with `maxCostUsd` already set in your configuration, it starts be ### Restricting `http_fetch` - + `http_fetch` lets the agent send HTTP requests from the Harper process: to its own REST endpoints while it builds an app, or to pull a reference page. The same agent reads table rows, logs, and component source, and text in any of them can steer it, so an enabled agent has a read path and an egress path in one toolset. `agent.httpFetch` bounds that tool. It is not a bound on every way the agent can reach the network: with `allowDestructive` on, for example, the agent can write component code, and the Harper process runs that code. diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 3869f37c0..9bab7607e 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1743,7 +1743,11 @@ Returns the full session record: `status`, `user` (the Operations API caller who From v5.4.0 each assistant message carries the `usage` its model call reported (`promptTokens`, `completionTokens`, and `costUsd` when the model is [priced](../models/overview.md#pricing)), and the response adds the session's totals: ```json -"usage": { "calls": 3, "promptTokens": 41230, "completionTokens": 2210, "costUsd": 0.157 } +{ + "session_id": "3f7c...", + "status": "completed", + "usage": { "calls": 3, "promptTokens": 41230, "completionTokens": 2210, "costUsd": 0.157 } +} ``` `calls` counts the model calls that returned an answer; a failed call is not counted. A total is left out once any call in the session did not report it, such as an unpriced model for `costUsd`, so a missing total means unmeasured, not zero. The [spend caps](../configuration/options.md#agent-spend-caps) read the same totals. diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 5efd788c6..6dc113b5e 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -28,8 +28,8 @@ A `deploy_component` with `restart: true` or `restart: "rolling"` now checks its ### Model Pricing and Per-Call Cost -A `models.generative` entry can now declare `pricing: { inputPerMtok, outputPerMtok }`, what you pay in USD per million prompt and completion tokens. Harper ships no prices of its own. Each `generate()` call is priced at the rates of the entry that served it, a fallback included, and the result carries `costUsd`. This makes `maxCostUsd` work on `toolMode: 'auto'` calls; before, it had no prices behind it and never tripped. See [Pricing](/reference/v5/models/overview#pricing). +A `models.generative` entry can now declare `pricing: { inputPerMtok, outputPerMtok }`, what you pay in USD per million prompt and completion tokens. Harper ships no prices of its own. Each `generate()` call is priced at the rates of the entry that served it, a fallback included, and the result carries `costUsd` when that entry is priced and the provider reported both token counts. With `toolMode: 'auto'` it covers the final round, and streaming calls are not priced. This makes `maxCostUsd` work on `toolMode: 'auto'` calls; before, it had no prices behind it and never tripped. See [Pricing](/reference/v5/models/overview#pricing). ### Spend Caps for the Built-in Agent -The agent records the usage of each model call on its session, and `get_agent_session` and `list_agent_sessions` report a session's totals as `usage`. Two caps bound a whole session, across every run in it: the new `agent.maxSessionTokens`, and `agent.maxCostUsd`, which is now enforced from model pricing and no longer defaults to `5.00`. A session at a cap makes no further model call and ends `error`, and a cap that cannot be measured stops the session rather than letting it run uncapped. If your configuration already sets `agent.maxCostUsd`, declare `pricing` on the generative entry the agent uses. See [Agent spend caps](/reference/v5/configuration/options#agent-spend-caps). +The agent records the usage of each model call on its session, and `get_agent_session` and `list_agent_sessions` report a session's totals as `usage`. Two caps bound a whole session, across every run in it: the new `agent.maxSessionTokens`, and `agent.maxCostUsd`, which is now enforced from model pricing and no longer defaults to `5.00`. A session at a cap makes no further model call: it ends `error`, or `completed` with `lastError` naming the cap when the call that reached it was the final answer. A cap that cannot be measured stops the session rather than letting it run uncapped. If your configuration already sets `agent.maxCostUsd`, declare `pricing` on the generative entry the agent uses, and start new sessions: a session created before 5.4.0 recorded no usage, so it cannot be continued under a cap. See [Agent spend caps](/reference/v5/configuration/options#agent-spend-caps). From 5f602d6e71168c2c78c8d99ac495414706e70841 Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 13:48:35 -0400 Subject: [PATCH 07/15] Say that cancel_agent_run answers every pending approval Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/operations-api/operations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 9bab7607e..b836c3b97 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1805,6 +1805,8 @@ Whether a tool is treated as destructive at all is governed by `agent.allowDestr Cancels a session's run, clears any followups it scheduled, and marks the session `aborted`. Works on a paused (`awaiting_approval`) or `idle` session as well as an actively running one. +From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An action approved before the cancel therefore never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. + ```json { "operation": "cancel_agent_run", "session_id": "3f7c..." } ``` From 0122412c159dc86394ae1b5bcf99975bf095828e Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 13:54:02 -0400 Subject: [PATCH 08/15] Contrast the two cost caps, say clearing a cap by set_agent_config lasts until restart, and note streaming is unpriced Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/configuration/options.md | 2 +- reference/models/api.md | 2 +- reference/models/overview.md | 2 +- reference/models/tool-calling.md | 22 +++++++++++----------- reference/operations-api/operations.md | 2 +- release-notes/v5-lincoln/5.4.md | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/reference/configuration/options.md b/reference/configuration/options.md index 75d544260..5a1e717aa 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -413,7 +413,7 @@ agent: A cap is checked when a run starts, before it executes an approved action, and after every model call: - **At or over a cap**, the session makes no further model call and ends `error`, with `lastError` naming the cap, for example `agent.maxSessionTokens=200000 reached: this session used 201450 tokens`. Tool calls the model had just requested are answered with a `budget_exceeded` result rather than run, so the transcript stays valid if you continue the session. A final answer that crosses the cap is still delivered: the run ends `completed`, with `lastError` naming the cap. -- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The unmeasured call stays in the transcript, so the session's total stays unmeasured and later runs stop before making any call, and raising the cap or adding `pricing` does not clear it: start a new session, or set the cap to `null`. A session created before v5.4.0, whose earlier calls recorded no usage, is in the same state. +- **A cap that cannot be measured stops the session too.** `maxCostUsd` needs every call priced, and `maxSessionTokens` needs every call's token counts. A model with no `pricing`, or a provider that reports no usage, stops the session after its first call rather than letting it run uncapped: it ends `error`, or `completed` if that call was the final answer, with `lastError` saying why either way. The unmeasured call stays in the transcript, so the session's total stays unmeasured and later runs stop before making any call, and raising the cap or adding `pricing` does not clear it: start a new session, or clear the cap. `set_agent_config` with `null` clears it until the next restart; to clear it for good, remove the key from the configuration file. A session created before v5.4.0, whose earlier calls recorded no usage, is in the same state. - **Pending approvals are closed too.** A run stopped before it executes an approved action answers that action, and any approval still awaiting a decision, with `budget_exceeded`, so nothing is left pending. The action does not run later, even once the cap is raised; the model sees the refusal and can ask again. - **A capped session stays capped.** Raise the cap with `set_agent_config` or start a new session. diff --git a/reference/models/api.md b/reference/models/api.md index ef29e3388..8f9e51a74 100644 --- a/reference/models/api.md +++ b/reference/models/api.md @@ -83,7 +83,7 @@ Additional options apply only when `toolMode: 'auto'`; they are documented in [T models.generateStream(input: GenerateInput, options?: GenerateOpts): AsyncIterable ``` -Identical to `generate()` but yields the completion incrementally: +Identical to `generate()` but yields the completion incrementally. Streamed calls are not [priced](./overview#pricing), so no chunk carries `costUsd`: ```javascript let text = ''; diff --git a/reference/models/overview.md b/reference/models/overview.md index 03f820019..e389c3649 100644 --- a/reference/models/overview.md +++ b/reference/models/overview.md @@ -165,7 +165,7 @@ models: Each `generate()` call is priced at the rates of the entry that served it, and the result carries `costUsd`. With `toolMode: 'auto'`, the result's `costUsd` covers the final round only, like its `usage`. A call that fell back to another entry in a [fallback group](./routing#fallback-groups) is priced at that entry's rates, not the requested one's. A call has no `costUsd` when the entry that served it declares no `pricing`, or when the provider did not report both a prompt and a completion token count. -The two rates are all Harper models. Discounts for cached prompt tokens, long-context surcharges and provisioned capacity are not, so set rates that match what an average call costs you, or rates that overestimate it if the figure backs a cap. Streaming calls (`generateStream()`) are not priced. +The two rates are the only cost inputs Harper uses. Discounts for cached prompt tokens, long-context surcharges and provisioned capacity are not, so set rates that match what an average call costs you, or rates that overestimate it if the figure backs a cap. Streaming calls (`generateStream()`) are not priced. The price is read by [`maxCostUsd`](./tool-calling#options) on `toolMode: 'auto'` calls and by the [built-in agent's `maxCostUsd`](../configuration/options#agent-spend-caps). `pricing` is accepted only on `generative` entries; on an `embedding` or `decision` entry it fails configuration validation. A price change applies on config reload, except on an entry whose `backend` is a module rather than a built-in, which takes it on restart like any other change to that entry. It does not affect [calibration](./calibration). diff --git a/reference/models/tool-calling.md b/reference/models/tool-calling.md index 4483a2c7b..b7d3a3c1e 100644 --- a/reference/models/tool-calling.md +++ b/reference/models/tool-calling.md @@ -78,17 +78,17 @@ A handler receives the parsed arguments and a context object `{ signal, accounti All options below apply only with `toolMode: 'auto'`. -| Option | Type | Default | Description | -| -------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `toolHandlers` | `Record` | — | Dispatch table keyed by tool name. A tool declared in `tools` with no handler here is a configuration error (400) | -| `maxToolIterations` | `number` | `10` | Hard cap on model → tools → model rounds | -| `maxToolTokens` | `number` | — | Cumulative prompt+completion token cap across rounds. Best-effort: requires the backend to report usage; if it does not, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | -| `maxCostUsd` | `number` | — | From v5.4.0, cumulative USD cap across rounds, from the [pricing](./overview#pricing) of the entry that served each round. If a round is unpriced, Harper warns once and `maxToolIterations` remains the bound; a cap of `0` still stops after the first round. Not supported on `generateStream()` (throws) | -| `toolParallelism` | `'parallel'` \| `'serial'` | `'parallel'` | When one round requests multiple tool calls, run handlers concurrently or in order | -| `toolResultMaxBytes` | `number` | `65536` | Per-result byte cap (JSON-stringified). Larger results are truncated with a marker; the model sees the truncated form, the trace records the original size | -| `toolErrorMode` | `'recover'` \| `'abort'` | `'recover'` | `'recover'` feeds a handler error back to the model as the tool result so it can react; `'abort'` stops the loop and throws with the trace attached | -| `includeToolTrace` | `boolean` | `false` | Populate `result.trace` with one entry per tool invocation (iteration, name, arguments, result size, duration, error) | -| `conversation` | `ConversationAppender` | — | Optional persistence hook — see below | +| Option | Type | Default | Description | +| -------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `toolHandlers` | `Record` | — | Dispatch table keyed by tool name. A tool declared in `tools` with no handler here is a configuration error (400) | +| `maxToolIterations` | `number` | `10` | Hard cap on model → tools → model rounds | +| `maxToolTokens` | `number` | — | Cumulative prompt+completion token cap across rounds. Best-effort: requires the backend to report usage; if it does not, Harper warns once and `maxToolIterations` remains the bound. Not supported on `generateStream()` (throws) | +| `maxCostUsd` | `number` | — | From v5.4.0, cumulative USD cap across rounds, from the [pricing](./overview#pricing) of the entry that served each round. If a round is unpriced, Harper warns once and `maxToolIterations` remains the bound; a cap of `0` still stops after the first round. This is the opposite of the built-in agent's `maxCostUsd`, which stops rather than run unpriced. Not supported on `generateStream()` (throws) | +| `toolParallelism` | `'parallel'` \| `'serial'` | `'parallel'` | When one round requests multiple tool calls, run handlers concurrently or in order | +| `toolResultMaxBytes` | `number` | `65536` | Per-result byte cap (JSON-stringified). Larger results are truncated with a marker; the model sees the truncated form, the trace records the original size | +| `toolErrorMode` | `'recover'` \| `'abort'` | `'recover'` | `'recover'` feeds a handler error back to the model as the tool result so it can react; `'abort'` stops the loop and throws with the trace attached | +| `includeToolTrace` | `boolean` | `false` | Populate `result.trace` with one entry per tool invocation (iteration, name, arguments, result size, duration, error) | +| `conversation` | `ConversationAppender` | — | Optional persistence hook — see below | ### Budgets and errors diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index b836c3b97..0c18c4e61 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1710,7 +1710,7 @@ A session's `status` is one of: | `aborted` | Cancelled by an operator via `cancel_agent_run` | | `error` | The run failed or a spend cap stopped it; `lastError` carries the message | -`completed` also covers hitting the `agent.maxTurns` ceiling — in that case `lastError` reads `Reached maxTurns= without a final answer.`, so check it before treating a completed session as finished. +`completed` also covers hitting the `agent.maxTurns` ceiling — in that case `lastError` reads `Reached maxTurns= without a final answer.`, so check it before treating a completed session as finished. From v5.4.0 a final answer that reached a [spend cap](../configuration/options.md#agent-spend-caps) also ends `completed` with `lastError` set, naming the cap. ### `agent_prompt` diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 6dc113b5e..01624807d 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -32,4 +32,4 @@ A `models.generative` entry can now declare `pricing: { inputPerMtok, outputPerM ### Spend Caps for the Built-in Agent -The agent records the usage of each model call on its session, and `get_agent_session` and `list_agent_sessions` report a session's totals as `usage`. Two caps bound a whole session, across every run in it: the new `agent.maxSessionTokens`, and `agent.maxCostUsd`, which is now enforced from model pricing and no longer defaults to `5.00`. A session at a cap makes no further model call: it ends `error`, or `completed` with `lastError` naming the cap when the call that reached it was the final answer. A cap that cannot be measured stops the session rather than letting it run uncapped. If your configuration already sets `agent.maxCostUsd`, declare `pricing` on the generative entry the agent uses, and start new sessions: a session created before 5.4.0 recorded no usage, so it cannot be continued under a cap. See [Agent spend caps](/reference/v5/configuration/options#agent-spend-caps). +The agent records the usage of each model call on its session, and `get_agent_session` and `list_agent_sessions` report a session's totals as `usage`. Two caps bound a whole session, across every run in it: the new `agent.maxSessionTokens`, and `agent.maxCostUsd`, which is now enforced from model pricing and no longer defaults to `5.00`. A session at a cap makes no further model call: it ends `error`, or `completed` with `lastError` naming the cap when the call that reached it was the final answer. A cap that cannot be measured stops the session rather than letting it run uncapped. Caps count calls that return: a call that fails, even one the provider billed, is not counted. If your configuration already sets `agent.maxCostUsd`, declare `pricing` on the generative entry the agent uses, and start new sessions: a session created before 5.4.0 recorded no usage, so it cannot be continued under a cap. See [Agent spend caps](/reference/v5/configuration/options#agent-spend-caps). From 089f32866386b0dbb2c9cb26ed858412fb2c5bd3 Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 6 Oct 2026 13:57:25 -0400 Subject: [PATCH 09/15] Say that an approved action already running when a cancel arrives finishes Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/operations-api/operations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 0c18c4e61..013f26806 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1805,7 +1805,7 @@ Whether a tool is treated as destructive at all is governed by `agent.allowDestr Cancels a session's run, clears any followups it scheduled, and marks the session `aborted`. Works on a paused (`awaiting_approval`) or `idle` session as well as an actively running one. -From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An action approved before the cancel therefore never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. +From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An approved action that has not started by then never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. One already running finishes, and its result is recorded. ```json { "operation": "cancel_agent_run", "session_id": "3f7c..." } From 4a72a5e0a55eb01327adb16f07acbdeee6c1594d Mon Sep 17 00:00:00 2001 From: David C Date: Mon, 12 Oct 2026 11:38:09 -0400 Subject: [PATCH 10/15] Say maxTurns is validated, and that a cancelled session stays aborted Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/configuration/options.md | 4 ++-- reference/operations-api/operations.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/reference/configuration/options.md b/reference/configuration/options.md index 9a0039861..3f09ed0c9 100644 --- a/reference/configuration/options.md +++ b/reference/configuration/options.md @@ -404,7 +404,7 @@ agent: - `enabled` — Enable the agent component; _Default_: `false` - `provider` — Recorded on the session but **not yet used to route the model call** — only `model` reaches the provider. Set the provider through the [`models`](../models/overview.md#configuration) configuration instead - `model` — Model id override, passed through to the model call; _Default_: the [`models`](../models/overview.md#configuration) generative default -- `maxTurns` — Maximum tool-call iterations in a single run; _Default_: `50` +- `maxTurns` — Maximum tool-call iterations in a single run; a positive integer, validated from v5.4.0. A bare `maxTurns:` keeps the default; _Default_: `50` - `maxSessionTokens` — Per-session cap on prompt plus completion tokens, summed over every model call in the session that returns; a positive integer. See [Spend caps](#agent-spend-caps); _Default_: none - `maxCostUsd` — Per-session cap in USD, priced from the [`pricing`](../models/overview.md#pricing) on the `models.generative` entry that served each call; a number of `0` or more, and `0` stops every session before its first call. See [Spend caps](#agent-spend-caps). Before v5.4.0 this setting was stored but never enforced, and defaulted to `5.00`; _Default_: none - `autoApprove` — Run without per-action approval gates; _Default_: `false` @@ -431,7 +431,7 @@ A cap is checked when a run starts, before it executes an approved action, and a A cap is a threshold checked after each call, not a guarantee: the call that reaches it can overshoot it. Only calls that return are counted. A call that fails, for example on a timeout or a provider error and whether or not another model then serves it, is not, even if the provider billed it. -A cap that is not a usable number fails the agent's start: Harper logs the error and registers no agent operation. Through `set_agent_config` the same value is rejected with a 400. +A cap, or a `maxTurns`, that is not a usable number fails the agent's start: Harper logs the error and registers no agent operation. Through `set_agent_config` the same value is rejected with a 400. If you upgrade with `maxCostUsd` already set in your configuration, it starts being enforced, and needs `pricing` on the generative entry the agent uses. diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 6a1ee3e5e..5cc1d8501 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1839,7 +1839,7 @@ Whether a tool is treated as destructive at all is governed by `agent.allowDestr Cancels a session's run, clears any followups it scheduled, and marks the session `aborted`. Works on a paused (`awaiting_approval`) or `idle` session as well as an actively running one. -From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An approved action that has not started by then never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. One already running finishes, and its result is recorded. +From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An approved action that has not started by then never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. One already running finishes, and its result is recorded. The session stays `aborted`: a run caught mid-step finishes that step without marking the session `completed`, `error` or `awaiting_approval`, and a tool call it then asks to have approved is answered `cancelled_by_operator` instead. ```json { "operation": "cancel_agent_run", "session_id": "3f7c..." } @@ -1857,7 +1857,7 @@ One gap is worth knowing: changing `allowDestructive` with [`set_agent_config`]( ### `set_agent_config` -Updates agent settings and returns the resulting configuration. Accepts any of `enabled`, `provider`, `model`, `maxTurns`, `maxSessionTokens`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend`; keys not supplied are left unchanged. Set `maxSessionTokens` or `maxCostUsd` to `null` to remove that cap; a value that is not a usable number is rejected with a 400, and nothing in the request is applied. Each field is described under [`agent`](../configuration/options.md#agent). A request that includes `httpFetch` is rejected with a 400 and nothing in it is applied: the [`http_fetch` policy](../configuration/options.md#restricting-http_fetch) is read at startup only. +Updates agent settings and returns the resulting configuration. Accepts any of `enabled`, `provider`, `model`, `maxTurns`, `maxSessionTokens`, `maxCostUsd`, `autoApprove`, `allowDestructive`, and `systemPromptAppend`; keys not supplied are left unchanged. Set `maxSessionTokens` or `maxCostUsd` to `null` to remove that cap. `maxTurns` must be a positive integer. A value that is not usable is rejected with a 400, and nothing in the request is applied. Each field is described under [`agent`](../configuration/options.md#agent). A request that includes `httpFetch` is rejected with a 400 and nothing in it is applied: the [`http_fetch` policy](../configuration/options.md#restricting-http_fetch) is read at startup only. ```json { "operation": "set_agent_config", "autoApprove": false, "maxTurns": 20 } From ee7415c5ff46e868c43e9bfcbd7789ea1b1b1ddc Mon Sep 17 00:00:00 2001 From: David C Date: Mon, 12 Oct 2026 11:38:24 -0400 Subject: [PATCH 11/15] Add agent cancellation and maxTurns validation to the 5.4 release notes Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- release-notes/v5-lincoln/5.4.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/release-notes/v5-lincoln/5.4.md b/release-notes/v5-lincoln/5.4.md index 01624807d..4ed870108 100644 --- a/release-notes/v5-lincoln/5.4.md +++ b/release-notes/v5-lincoln/5.4.md @@ -33,3 +33,7 @@ A `models.generative` entry can now declare `pricing: { inputPerMtok, outputPerM ### Spend Caps for the Built-in Agent The agent records the usage of each model call on its session, and `get_agent_session` and `list_agent_sessions` report a session's totals as `usage`. Two caps bound a whole session, across every run in it: the new `agent.maxSessionTokens`, and `agent.maxCostUsd`, which is now enforced from model pricing and no longer defaults to `5.00`. A session at a cap makes no further model call: it ends `error`, or `completed` with `lastError` naming the cap when the call that reached it was the final answer. A cap that cannot be measured stops the session rather than letting it run uncapped. Caps count calls that return: a call that fails, even one the provider billed, is not counted. If your configuration already sets `agent.maxCostUsd`, declare `pricing` on the generative entry the agent uses, and start new sessions: a session created before 5.4.0 recorded no usage, so it cannot be continued under a cap. See [Agent spend caps](/reference/v5/configuration/options#agent-spend-caps). + +### Agent Cancellation and Limits + +`cancel_agent_run` now answers every pending approval with a `cancelled_by_operator` result in the same write that marks the session `aborted`, so an approved action that has not started never runs, then or on the session's next prompt, and the session stays `aborted` even when a run was mid-step. `agent.maxTurns` is now validated as a positive integer at startup and by `set_agent_config`, where a value such as `null` used to make every later run end without a model call. See [`cancel_agent_run`](/reference/v5/operations-api/operations#cancel_agent_run). From e65e021729afb25a1a2fca1a2aeca26493a4e1f7 Mon Sep 17 00:00:00 2001 From: David C Date: Mon, 12 Oct 2026 11:43:53 -0400 Subject: [PATCH 12/15] Say a cancelled run makes no further model call, and that maxTurns is captured at run start Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/operations-api/operations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 5cc1d8501..a7a42097a 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1839,7 +1839,7 @@ Whether a tool is treated as destructive at all is governed by `agent.allowDestr Cancels a session's run, clears any followups it scheduled, and marks the session `aborted`. Works on a paused (`awaiting_approval`) or `idle` session as well as an actively running one. -From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An approved action that has not started by then never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. One already running finishes, and its result is recorded. The session stays `aborted`: a run caught mid-step finishes that step without marking the session `completed`, `error` or `awaiting_approval`, and a tool call it then asks to have approved is answered `cancelled_by_operator` instead. +From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An approved action that has not started by then never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. One already running finishes, and its result is recorded. The session stays `aborted`: a run caught mid-step finishes that step without marking the session `completed`, `error` or `awaiting_approval`, a tool call it then asks to have approved is answered `cancelled_by_operator` instead, and it makes no further model call. ```json { "operation": "cancel_agent_run", "session_id": "3f7c..." } @@ -1866,7 +1866,7 @@ Updates agent settings and returns the resulting configuration. Accepts any of ` Three limits are worth knowing: - **The change is in-memory and not persisted.** It applies for the life of the process and is lost on restart; edit `harper-config.yaml` for a durable change. -- **A run already in flight keeps the settings it started with** — its toolset, `autoApprove`, `model`, `systemPromptAppend` and spend caps are all captured at start. Changes take effect on the next run. To stop a run immediately, use `cancel_agent_run`. +- **A run already in flight keeps the settings it started with** — its toolset, `autoApprove`, `model`, `maxTurns`, `systemPromptAppend` and spend caps are all captured at start. Changes take effect on the next run. To stop a run immediately, use `cancel_agent_run`. - **`enabled` is not a kill switch.** It cannot turn the agent on — if it was off at startup, this operation does not exist. Setting it to `false` only makes subsequent `agent_prompt` calls return 409; a run already in flight continues, and `approve_agent_action` still resumes a paused one. Use `cancel_agent_run` to stop a run. ### MCP access From c00c6850129a3716f35bc91af82371346bbb5089 Mon Sep 17 00:00:00 2001 From: David C Date: Mon, 12 Oct 2026 11:50:29 -0400 Subject: [PATCH 13/15] Say agent_prompt answers 409 while a cancelled run finishes, and that a mid-turn cancel answers the rest of the turn Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/operations-api/operations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index a7a42097a..3cdd37e7d 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1768,7 +1768,7 @@ Response: { "session_id": "3f7c...", "status": "running" } ``` -A session that is `running` or `awaiting_approval` rejects a new prompt with a 409 — resolve the pending approval or cancel the run first. +A session that is `running` or `awaiting_approval` rejects a new prompt with a 409 — resolve the pending approval or cancel the run first. From v5.4.0 a session also answers 409 while a cancelled run is still finishing its current model call or tool; send the prompt again shortly. ### `get_agent_session` @@ -1839,7 +1839,7 @@ Whether a tool is treated as destructive at all is governed by `agent.allowDestr Cancels a session's run, clears any followups it scheduled, and marks the session `aborted`. Works on a paused (`awaiting_approval`) or `idle` session as well as an actively running one. -From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An approved action that has not started by then never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. One already running finishes, and its result is recorded. The session stays `aborted`: a run caught mid-step finishes that step without marking the session `completed`, `error` or `awaiting_approval`, a tool call it then asks to have approved is answered `cancelled_by_operator` instead, and it makes no further model call. +From v5.4.0 the cancel also answers every pending approval, decided or not, with a `cancelled_by_operator` result, in the same write that marks the session `aborted`. An approved action that has not started by then never runs, neither in the cancelled run nor when you next prompt the session, and nothing is left awaiting a decision. One already running finishes, and its result is recorded. A cancel between a turn's tool calls answers the rest of that turn `cancelled_by_operator`. The session stays `aborted`: a run caught mid-step finishes that step without marking the session `completed`, `error` or `awaiting_approval`, a tool call it then asks to have approved is answered `cancelled_by_operator` instead, and it makes no further model call. ```json { "operation": "cancel_agent_run", "session_id": "3f7c..." } From ca925cf3ca623fee7ec63afa7cb80461d8361081 Mon Sep 17 00:00:00 2001 From: David C Date: Mon, 12 Oct 2026 11:55:41 -0400 Subject: [PATCH 14/15] Say how long the 409 after a cancel can last Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/operations-api/operations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index 3cdd37e7d..f86bc50ca 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1768,7 +1768,7 @@ Response: { "session_id": "3f7c...", "status": "running" } ``` -A session that is `running` or `awaiting_approval` rejects a new prompt with a 409 — resolve the pending approval or cancel the run first. From v5.4.0 a session also answers 409 while a cancelled run is still finishing its current model call or tool; send the prompt again shortly. +A session that is `running` or `awaiting_approval` rejects a new prompt with a 409 — resolve the pending approval or cancel the run first. From v5.4.0 a session also answers 409 while a cancelled run is still finishing its current model call or tool. The session already reads `aborted` then, so retry with backoff; the wait lasts as long as that call, at most the model's `requestTimeoutMs` or the tool's own run time. ### `get_agent_session` From 5c38284793b897a1f9312507d76b7a9ec6f65ec8 Mon Sep 17 00:00:00 2001 From: David C Date: Mon, 12 Oct 2026 12:23:58 -0400 Subject: [PATCH 15/15] Say a prompt that races a cancel is recorded but not run Refs HarperFast/harper#3040 Co-Authored-By: Claude Opus 5.5 --- reference/operations-api/operations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reference/operations-api/operations.md b/reference/operations-api/operations.md index f86bc50ca..4a29af862 100644 --- a/reference/operations-api/operations.md +++ b/reference/operations-api/operations.md @@ -1768,7 +1768,7 @@ Response: { "session_id": "3f7c...", "status": "running" } ``` -A session that is `running` or `awaiting_approval` rejects a new prompt with a 409 — resolve the pending approval or cancel the run first. From v5.4.0 a session also answers 409 while a cancelled run is still finishing its current model call or tool. The session already reads `aborted` then, so retry with backoff; the wait lasts as long as that call, at most the model's `requestTimeoutMs` or the tool's own run time. +A session that is `running` or `awaiting_approval` rejects a new prompt with a 409 — resolve the pending approval or cancel the run first. From v5.4.0 a session also answers 409 while a cancelled run is still finishing its current model call or tool. The session already reads `aborted` then, so retry with backoff; the wait lasts as long as that call, at most the model's `requestTimeoutMs` or the tool's own run time. A prompt accepted while a [`cancel_agent_run`](#cancel_agent_run) for the same session arrives is recorded but not run, and answers 409 too. ### `get_agent_session`