@sema-agent/core 7.13.0 → 7.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +80 -0
- package/dist/agents/subagent.d.ts +4 -9
- package/dist/agents/subagent.js +16 -12
- package/dist/agents/teacher.js +1 -1
- package/dist/brain/reasoning.d.ts +23 -0
- package/dist/brain/reasoning.js +35 -4
- package/dist/brain/stream-engine.js +35 -17
- package/dist/brain/timeout.d.ts +25 -4
- package/dist/brain/timeout.js +1 -1
- package/dist/core/ask-origin.d.ts +28 -9
- package/dist/core/ask-origin.js +10 -5
- package/dist/core/auto-mode.d.ts +3 -3
- package/dist/core/checkpoint-seat.d.ts +18 -0
- package/dist/core/checkpoint-seat.js +6 -0
- package/dist/core/checkpoint-store.d.ts +9 -16
- package/dist/core/checkpoint-store.js +7 -4
- package/dist/core/engine-notice.d.ts +3 -1
- package/dist/core/env-knob-announce.d.ts +16 -0
- package/dist/core/env-knob-announce.js +14 -0
- package/dist/core/gate-fold.js +2 -0
- package/dist/core/gate-lanes.js +21 -12
- package/dist/core/gate-outcome.d.ts +42 -3
- package/dist/core/gate-outcome.js +22 -0
- package/dist/core/governance-codes.js +1 -1
- package/dist/core/hooks.d.ts +5 -16
- package/dist/core/hooks.js +6 -4
- package/dist/core/mcp.d.ts +3 -2
- package/dist/core/mcp.js +5 -11
- package/dist/core/memory-engine/engine.d.ts +2 -2
- package/dist/core/runner/advertised-writable-dirs.d.ts +74 -0
- package/dist/core/runner/advertised-writable-dirs.js +48 -0
- package/dist/core/runner/assemble-result.d.ts +5 -0
- package/dist/core/runner/denial-limit-arms.d.ts +7 -9
- package/dist/core/runner/denial-limit-arms.js +8 -10
- package/dist/core/runner/gate-exit.d.ts +15 -3
- package/dist/core/runner/gate-exit.js +6 -4
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +34 -4
- package/dist/core/runner/prepare-caps-and-workflow.js +47 -12
- package/dist/core/runner/prepare-gate-stations.d.ts +1 -1
- package/dist/core/runner/prepare-hands-readface.d.ts +10 -4
- package/dist/core/runner/prepare-hands-readface.js +5 -14
- package/dist/core/runner/prepare-inherited-gate.d.ts +5 -3
- package/dist/core/runner/prepare-inherited-gate.js +1 -1
- package/dist/core/runner/prepare-memory.d.ts +31 -34
- package/dist/core/runner/prepare-memory.js +73 -51
- package/dist/core/runner/prepare-policy-chain.js +5 -2
- package/dist/core/runner/prepare-prompt-inputs.d.ts +4 -0
- package/dist/core/runner/prepare-prompt-inputs.js +2 -2
- package/dist/core/runner/prepare-question-face.d.ts +10 -8
- package/dist/core/runner/prepare-question-face.js +1 -3
- package/dist/core/runner/prepare-safety-scan.js +1 -1
- package/dist/core/runner/prepare-task.js +57 -48
- package/dist/core/runner/prepare-wiring-manifest.d.ts +2 -2
- package/dist/core/runner/prepare-wiring-manifest.js +1 -1
- package/dist/core/runner/run-compaction-machinery.js +2 -0
- package/dist/core/runner/run-harness-handlers.js +3 -1
- package/dist/core/runner/stream-settle-backstop.js +1 -1
- package/dist/core/runtime-caps.d.ts +21 -0
- package/dist/core/runtime-caps.js +5 -1
- package/dist/core/task-event.d.ts +11 -2
- package/dist/core/task-registry-shared.js +8 -6
- package/dist/core/task-result.d.ts +15 -0
- package/dist/core/terminal-cause.d.ts +6 -2
- package/dist/core/tool-policy.d.ts +34 -28
- package/dist/core/tool-policy.js +29 -5
- package/dist/core/tool-roster.js +2 -0
- package/dist/core/tool-spec.d.ts +13 -12
- package/dist/core/types.d.ts +2 -1
- package/dist/core/types.js +1 -0
- package/dist/core/wiring-manifest.d.ts +16 -9
- package/dist/core/wiring-manifest.js +8 -3
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/orchestration/run-workflow-tool.d.ts +8 -8
- package/dist/orchestration/run-workflow-tool.js +1 -1
- package/dist/orchestration/workflow-primitives.d.ts +4 -3
- package/dist/orchestration/workflow-primitives.js +3 -3
- package/dist/orchestration/workflow-types.d.ts +14 -0
- package/dist/orchestration/workflow.d.ts +41 -4
- package/dist/orchestration/workflow.js +23 -5
- package/dist/tools/fs/fs-shared.js +9 -3
- package/dist/tools/fs/read-deny.d.ts +24 -8
- package/dist/tools/fs/read-deny.js +20 -1
- package/package.json +2 -1
- package/test/export-surface.snapshot.json +25 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,85 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.14.0 — 2026-09-11
|
|
4
|
+
|
|
5
|
+
### Narrowing — the agent's own key-bearing settings files are refused on the structured read faces by default (#685; P0; ruling B: the session record stays verbatim, the fix is on the READ face; sema form — CC 2.1.250 has no read-face deny table, the table itself is the registered divergence; @server @cli @test)
|
|
6
|
+
- **Before.** The built-in read deny table (`READ_FACE_BUILTIN_DENY_TABLE`, `src/tools/fs/read-deny.ts`) guarded credential directories (`.ssh`, `.aws`, `.netrc`, `.npmrc`, …) and named `.claude*`/`.mcp.json` as deliberately NOT listed ("agent config is routinely read for debugging"). Under default wiring a `Read ~/.sema/settings.json` (the shell's user settings — its `env` block carries the engine URL and provider API keys) returned the file verbatim: the key landed in the tool result, in the model context, and in the session record; a cli replay audit found it there in cleartext.
|
|
7
|
+
- **After (one tier, no new rule).** A fifth built-in tier **`agent-config`** joins the closed set `READ_DENY_BUILTIN_TIERS` (`credentials`, `shell-history`, `browser`, `wallet`, `agent-config`) and is default-ON through the existing derived default expression (every tier except `shell-history`). Its rows name the agent's own configuration FILES whose env / server blocks carry API keys — `.sema/settings.json`, `.sema/settings.local.json`, `.sema.*` (`.sema.json`), `.claude/settings.json`, `.claude/settings.local.json`, `.claude.*` (`.claude.json`), `.mcp.json`, `.ai-agent/.env` and `.sema/engine-data/.env` (the engine host's documented data-root key file under the default and the shell's layouts), `.codex/config.toml`, `.cursor/mcp.json`, `.continue/config.json`, `.continue/config.yaml`, `.aider.conf.yml`, `.gemini/settings.json` (every one a file whose `env` / server block takes literal values). Token STORES of the same tooling join the existing **`credentials`** tier, not the new one — `.credentials.json` (the shell's OAuth store under either brand's config home), `.codex/auth.json`, `.config/github-copilot`, `.gemini/oauth_creds.json` — so a deployment that switches `agent-config` off to debug its agent configuration does not expose the OAuth stores beside it. Rows are FILES, not the config directories: under the shell's layout `~/.sema/engine-data` is the engine data root and hosts the memory library the model reads and edits (Edit requires a prior Read), and `.sema/agents`, `.claude/skills`, `.cursor/rules`, `.codex/sessions` are working material — a directory-wide row would have needed a data-root passthrough (one more rule; the write face carries exactly that special case and this change does not copy it). The disposition is the credentials tier's existing one, unchanged in kind: a structured face whose own target is a listed file (Read; Grep/Glob/RepoMap given that path) refuses typed (`read_path_denied`, the pattern named, zero content bytes, no ask); a Grep/Glob TRAVERSAL over a scope containing one excludes it and says so in a trailing note (`entries matching the sensitive-path read deny list … were excluded`), no error, the file's bytes never in the result; the classify shell gate's reader arm (`grep needle .claude/settings.json`, `cat …`) demotes a listed operand to a MANDATED ask (B-057: the read boundary's question — neither a stored allow rule nor the read-only arm retires it; a person's yes releases the read). Rules: +0 (a tier row is data on the one compiled matcher every judgment point already consumes); the JSDoc "NOT listed" sentence for `.claude*`/`.mcp.json` is withdrawn.
|
|
8
|
+
- **User-visible path.** A default-wired run that reads `.claude/settings.json` / `.sema/settings.json` / `.mcp.json` now gets a typed refusal from Read (and a permission card from a `grep`/`cat` of it under `shellGate:"classify"`) where it used to get the file. **Deployment opt-out (existing knob, no new switch):** `RunnerDeps.readDenyBuiltinTiers` / `HandsToolkitOptions.readDenyBuiltinTiers` = `["credentials", "browser", "wallet"]` restores the previous face for the settings files while keeping the token stores refused; `readDenyBuiltinExclude` removes single rows by their pattern text (e.g. `[".mcp.json"]`). Additions (`readDenyPatterns`) are unchanged and remain add-only.
|
|
9
|
+
- **Fail-open reverse check — which reads of a key-bearing file still do NOT ask or refuse?** (a) a workspace `.env` / `.env.*` — deliberately unchanged (the standing ruling: workspace material; the write guard covers it; only the engine host's own data-root `.env` is listed); (b) a settings file under a relocated config home (`SEMA_CONFIG_DIR` / `CLAUDE_CONFIG_DIR`) or a relocated data root (`AGENT_DATA_DIR`) — the rows name the default spellings, a deployment that relocates adds its own row (the write guard has the same shape); (c) **the shell reader arm under the DEFAULT `shellGate`.** The fact, stated as the deployments will meet it: the read face's built-in `agent-config` tier blocks the STRUCTURED reads (Read; Grep/Glob/RepoMap targets and traversals); the Bash reader arm is covered under `shellGate:"classify"` (the probe mandates the ask for a listed operand, as above) and `"always"` (every shell call asks; a stored `Bash(cat:*)` allow rule does NOT clear it — the operator doctrine is a mandated ask); when `shellGate` is absent or `"off"` a Bash read goes through NO probe — `cat .claude/settings.json` runs with zero asks and the key reaches the session record — exactly as the `credentials` tier has always behaved on that arm (the module header's declared scope: a structured-face floor, the full shell is the approval chain's jurisdiction). Measured and pinned (`test/backlog685-agent-config-read-deny.test.ts`, the "declared scope" block). **This is the default deployment's shape today**: the server's `MANUAL_MODE_SHELL_GATE` is opt-in and unset by default, the cli mints no `shellGate` of its own, so `TaskSpec.shellGate` is absent ⇒ `"off"`. Not widened by this change (the arm is the existing design, same for every tier); follow-up **#696** (P1 design: whether the read-face floor should be decoupled from the shellGate tier and judge the shell reader arm unconditionally). @server @cli: this is the sentence to read for the P0's remaining arm; (d) `.sema/agents/*`, `.claude/skills/**`, `.cursor/rules/**`, `.codex/sessions/**` and `.claude/projects/**` transcripts — working material, or transcripts (a different class — the shell-history posture — not this change); (e) a `settings.json` outside an agent-config directory (`config/settings.json`, `.vscode/settings.json`) — reads normally (pinned as the control).
|
|
10
|
+
- **Export surface.** No public name added or removed. Closed-set member +1: `READ_DENY_BUILTIN_TIERS` (`ReadDenyBuiltinTier` widens by `"agent-config"`); `READ_FACE_BUILTIN_DENY_TABLE` +19 rows (4 `credentials`, 15 `agent-config`); `READ_FACE_DEFAULT_DENY_ENTRIES` grows by the same 19. A consumer that pinned the tier vocabulary or the default entry list as a closed set re-pins. `readDenyBuiltinTiers` configs listing the four old names keep working (exactly-these semantics: they now deliberately leave `agent-config` off).
|
|
11
|
+
- **Downstream.** `@server`: mirror the tier in any admin face that renders `READ_DENY_BUILTIN_TIERS` / the default table; a deployment that relocates `AGENT_DATA_DIR` should add its own `<root>/.env` row via `readDenyPatterns`; a persisted checkpoint's read-face section carries additions only (built-ins resolve at resume from the deployment), so no stored row changes shape. `@cli`: the Read refusal text names the pattern (`sensitive-path read deny list (pattern ".sema/settings.json")`) — the existing `read_path_denied` card applies; the classify card for `grep …/settings.json` is the existing B-057 mandated-ask card (no "don't ask again" offer, by design); `.mcp.json` as a basename also covers plugin-marketplace manifests (`…/plugins/marketplaces/*/external_plugins/*/.mcp.json` — they carry `env` blocks too), so a structured Read of one now refuses and a Grep over a plugin tree excludes them with the note. `@test`: G1–G8 in the release criteria (draft in the car receipt).
|
|
12
|
+
- Pins: `test/backlog685-agent-config-read-deny.test.ts` (table: tier membership, both brand spellings / case fold / both separator families, the readable working-material list incl. the memory library under `.sema/engine-data` and workspace `.env`, tier-off leaves the credentials rows; Read face under default wiring: three files refused with zero asks and zero key bytes in the session record, a covering stored `Read(//root/**)` rule does not reach the in-tool refusal, controls read normally and the key does flow from an unlisted file, tier-off reads the settings file while `.credentials.json` stays refused, Grep traversal excludes the rows; classify shell reader arm: no ⇒ one mandated ask and a clean record, yes ⇒ the read happens, a stored exact allow rule does not clear it, unlisted-file and tier-off controls at zero asks). Also pinned: the declared scope — `shellGate` absent ⇒ `cat .claude/settings.json` runs with zero asks and the key reaches the record; `"always"` ⇒ one ask, a stored `Bash(cat:*)` rule does not clear it. Mutation measured: dropping the 15 agent-config rows reds 9 of 19 (every refusal / mandate pin), the 10 controls / scope pins stay green. `test/backlog245-deny-builtin-config.test.ts` tier-vocabulary pin re-spelled for the fifth tier.
|
|
13
|
+
### Behaviour — one rule for every prompt-advertised writable directory: the fence admits what the prompt teaches (#691; P1 组合病; cli [6900]; @server @cli @test)
|
|
14
|
+
- **Before.** The `# Memory` instruction taught "write to it directly with the Write tool" at `{{MEMORY_DIR}}` while the fs root fence knew nothing of that directory: on a default wiring (no `additionalDirectories`, memory root under `~/.sema/engine-data/memory`) every memory Write was refused `path_not_in_root` ("resolves outside the allowed root"). The scratchpad had the opposite half of the same rule hand-rolled in the hands phase (admit + materialize), and the instruction's supply condition checked only that a `Write` tool was mounted and reachable — two judgments of one fact.
|
|
15
|
+
- **After (the rule, one sentence).** A directory the prompt advertises as writable is a directory the root fence ADMITS, and the SAME admission decides the hole and the teaching — `AdvertisedWritableDirs` (`src/core/runner/advertised-writable-dirs.ts`), one seat, two advertisers: the memory phase admits its write plane's root (`MemorySessionHandle.writableRoot`, the very path the instruction names) iff the write channel's first conjunct holds (`Write` mounted, not excluded, and on the filesystem the store lives on — a remote env needs `memoryPersistenceCapable: true`) and a write face exists (`writeScope !== null`); the `# Memory` write instruction and the preference discipline are taught iff that admission held. The hands phase admits the scratchpad through the same seat (its exclusive-create marker materialization unchanged) and folds the seat's canonical roots into the write-capable containment allowlist before the toolkit compiles. Admission is fail-closed canonicalization through the fence's own ExecutionEnv; a refused admission is announced ONCE — `<advertiser> advertised writable directory skipped (cannot canonicalize): <path> — <code>: <message>` on `onError` (phase `config`) — and the advertiser then does not advertise (memory: no instruction; the scratchpad's silent narrowing is now loud on the same outlet, its env-block spelling unchanged).
|
|
16
|
+
- **Phase order (driver-internal).** The memory phase now runs BEFORE the hands phase in `prepareTask` (the engine owns the write root's coordinate; the toolkit compiles its allowlist once), so the memory write gate is installed before the band compiles. The two roster facts the memory phase needs from the not-yet-mounted band (`writeToolsMounted`, `rosterCanPersist`) read the band's catalog PLAN — the hands rows whose declared effect is not `read` (the file-write trio and the full shell), planned iff hands are enabled and not read-only — beside the assembled caller/MCP tools; the #181-F5 pins (handsReadOnly / excludeTools Write / hands-less caller Write) guard the plan against the compile. `PrepareMemoryInput` and `PrepareHandsReadFaceInput` gain `advertisedWritableDirs?` (phase-api floor 947 → 949).
|
|
17
|
+
- **Observable (pinned both ways, `test/backlog691-advertised-writable-dir.test.ts`, 9 pins; 6 targeted mutations each red ≥1).** Default wiring: the model's Write into the advertised memory dir lands, zero `path_not_in_root`, the `# Environment` additional-directories line now lists the memory root beside any scratchpad (the same disclosure the scratchpad already had); the engine's content gate still refuses a framing break-out inside the admitted root (the hole is not a content door). `writeScope: null` and `handsReadOnly`: no hole, no teaching, the read-only notice as before. Remote env undeclared: no admission attempt, no teaching, the notice once. Remote env declared but the sandbox cannot resolve the host path: skipped loudly, not taught. Remote env declared + resolvable: taught. Scratchpad ghost: skipped loudly; scratchpad + memory root on one allowlist.
|
|
18
|
+
- **Fail-open reverse check — which directory does the fence open that the prompt does not advertise?** None: `roots` is filled only through `admit`, and every `admit` caller decides its advertisement off the returned verdict; the memory phase asks only when it would teach (write face + first conjunct), so a read-only layering, a read-only band or an excluded `Write` opens no hole. Rules 2 → 1 (the scratchpad's inline admit and the instruction's roster-only supply condition fold into the seat).
|
|
19
|
+
- **Review round 1 (adversarial; four REAL findings adopted, one noted).** ① The write plane's root is REFUSED (loud `onError`, channel closed, not taught) when it physically contains another scope's directory: the root scope owns `memoryDir` and later scopes nest beneath it (layout.ts `registerScope`), and the file tools' fence is directory-granular — admitting the root would have exposed sibling scopes (another user's, an unadmitted org layer) to Read/Write. The check is `MemoryEngine.readonlyDirNamesUnderRoot` (made public), the same set the harvest already excludes. Consequence: a deployment that stacks several scopes in ONE memory dir (the pre-v2 form) gets no file-tool memory writes and hears why — the layout's non-disjoint scope roots are the design defect, ledgered. ② `AdvertisedWritableDir.expectCanonical`: the fence's canonical spelling must equal the coordinate the engine's write gate and harvest key on (`writableRoot`); a sandbox that exposes the host root under another name is a skip naming both spellings, never a hole the content gate cannot see. ③ The rule's other half for the scratchpad: a skipped scratchpad is NOT advertised — the `# Scratchpad Directory` section renders only when the seat did not skip it (`verdictOf("scratchpad")`, prepare-prompt-inputs). ④ The memory phase is split into MOUNT (before hands) and `composeInjection({ rosterCanPersist })` (after hands, at the phase's former position): the one destructive read — the announcement drain inside `inject()` — no longer precedes a hands-phase refusal, and the roster-wide persistence verdict reads the MOUNTED roster exactly as before (a StubExecutionEnv mounts no shell; a catalog plan counted one — pinned). `PrepareMemoryInput` loses `rosterCanPersist`; `PrepareMemoryResult` loses `memoryBlock`/`recallDisciplineSegment`/`seedFiles` and gains `composeInjection` (driver-internal phase contract; direct callers are the test rigs). Noted: the seat's judgment never throws on the env's Result answers; its skip outlet is the caller's `onError` sink and is deliberately unguarded, like every other use of that sink.
|
|
20
|
+
- **Review round 2 (adversarial; one adopted, two ledgered).** Adopted: a write root that contains the engine's CONTROL PLANE (a `FileMemoryEngineBackend` pinning `controlDir` inside the memory dir — the dual-root branch refused that overlap at mount, the single-plane branches never did) is refused by the same one-list refusal as sibling scopes ("what the root contains that is not the model's to touch"). Ledgered, not fixed: (a) the sibling-scope check is a prepare-time registry snapshot — a scope registered by ANOTHER session mid-run nests a new directory under an already-admitted root (the same non-disjoint layout defect; the design ticket below is the fix, not a per-write re-read); (b) on a case-insensitive volume a memory dir configured with case-variant spelling splits the engine's coordinate (`layout.canonicalize` = JS `realpathSync`, casing-preserving) from the fence's (`NodeExecutionEnv.canonicalPath` = libuv `realpath`, on-disk casing) — today that is a loud `expectCanonical` skip; before #691 the same configuration silently ran the memory write gate against a key it never matched (measured on this volume: `realpathSync` keeps `LOWER/DIR`, `fs.promises.realpath` returns `lower/dir`). One canonicalizer for both is the fix (engine-wide, its own ticket).
|
|
21
|
+
- **Review rounds 3–4.** Round 3 caught that the round-2 guard had been edited but not committed (the mutation harness restored the file while the edit was uncommitted); re-landed in its own commit. Round 4 (verification): approve, no material findings — the guard reaches all three mount branches with the paired engine/handle, both refusal pins match, and every earlier axis reads clean.
|
|
22
|
+
- Pins after round 2: `test/backlog691-advertised-writable-dir.test.ts` 15 (every adoption pinned; 11 targeted mutations, each red ≥1 — including push-before-compare on the seat and the dropped control-plane check); `memory-engine-runner.test.ts` declared-capable remote arm now models the shared mount (the sandbox resolves the host root).
|
|
23
|
+
- Not fixed, recorded: the memory layout's non-disjoint scope roots (root scope = `memoryDir` itself) — the reason ① refuses multi-scope single-dir deployments; the fix is a layout that gives every scope its own subdirectory (migration + registry adoption), a design ticket. The memory write-gate ref cell (`memoryWriteGateRef`) could now be a direct value (the memory phase runs first) — a pure cleanup with six test call sites.
|
|
24
|
+
### Contract batch — every fact a downstream branches on is a closed-set structural position; prose only renders (#688 C1–C5, #692 C-a–C-d; per-key dispositions in `docs/wire-consumption/7.14.0.md`; @server @cli @client-core @test)
|
|
25
|
+
- **C1 · the classifier deny's FORM.** Before: the three ways an auto-mode classifier round ends (could not run / answered outside its contract / ruled a block) reached the wire as one layer word `deniedBy: "classifier"` plus prose, and the "could not run" half rode a separate `classifierUnavailable: { cause }` seat on the deny observer's payload (never on `tool_end.gate`). After: `GateDisposition.denied.cause?: "unavailable" | "parse_error"` (`CLASSIFIER_DENY_CAUSES`, `isClassifierDenyCause`; layer table `DENIED_BY_MAY_CARRY_CAUSE`, invariant I5 in `screenGateOutcome`) — attested by the two classifier stations on the deny they mint (call-bound, identity-keyed, transferred across the fold's rewrite clone), read once into `GatePass.denyCause`, minted onto the disposition at the exit; a ruling block carries none. An ancestor's frozen classifier reached through the inherited chain keeps its `policy` attribution (station row 14) and carries the cause beside it. The WHY (`error` / `timeout`) stays on the deny sentence and the `auto_mode.classified` trace frame. The `classifierUnavailable` seats are retired (below) — the ask-side ones had not been engine-written since #661, the deny-side one spelled three forms as one word.
|
|
26
|
+
- BREAKING · `PermissionDeniedPayload -classifierUnavailable` (read `payload.gate.disposition.cause === "unavailable"`).
|
|
27
|
+
- BREAKING · `PermissionResult -classifierUnavailable` (both arms; the fact is engine-attested, never a caller member).
|
|
28
|
+
- BREAKING · `PreToolUseResult -classifierUnavailable` (the alias face of the previous line).
|
|
29
|
+
- BREAKING · `AskRequest -classifierUnavailable` (the tool_approval card / inbox row key; dead since #661).
|
|
30
|
+
- BREAKING · `AskCarry -classifierUnavailable` (the park closure's structural seat).
|
|
31
|
+
- BREAKING · `PendingAction -tool_approval.classifierUnavailable` (the durable row; a stored key on an old row is ignored by the projection).
|
|
32
|
+
- BREAKING · `CheckpointSummary -classifierUnavailable` (the parked-ask summary projection).
|
|
33
|
+
- **C2 · `ASK_ORIGINS` member `unresolvable` → `ancestor_marked`.** Before: the word said "unresolvable" for an ask whose whole point is that an ancestor MARKED it for the park (its resolution is the park's business). After: the word names WHO raised it, like every other member; the ladder arm and both lane tables re-keyed; no alias, no mapping.
|
|
34
|
+
- BREAKING · `ASK_ORIGINS -unresolvable` (+`ancestor_marked`; a durable row parked under the old word reads as a non-member at the resume screens — `isAskOrigin` — while its `org` fact still governs it).
|
|
35
|
+
- **C3 · the mechanism behind `rule_store_unavailable`.** Before: one origin word for two mechanisms (the store could not be read / the CALL could not be read against the person's rows), told apart only by the ask's message text. After: `RULE_STORE_UNREADABLE_KINDS = ["store", "call"]` (`RuleStoreUnreadable`, `isRuleStoreUnreadable`); the gate reads the lane's answer ONCE into `AskOriginFacts.ruleStore` (the `"unavailable"` input word is gone — a type-level input, not a wire key), stamps `ruleStoreUnreadable` beside `origin` on the surviving ask (a policy's self-declared value is removed there) and carries it onto `AskRequest`, `AskCarry`, `PendingAction.tool_approval`, `CheckpointSummary`. Present ⇔ `origin === "rule_store_unavailable"`. Additive.
|
|
36
|
+
- **C4 · `WorkflowRun.errorCode?: string`.** Before: a run that failed — on a parked child in particular — carried `status: "failed"` and free text; only `WorkflowAgentRun.errorCode` was machine-readable. After: the run-level code in the same vocabulary, attributed ① from the thrown value's own `code` (a park ⇒ `workflow.agent_parked`, a refusal ⇒ its class code, a re-thrown typed error ⇒ its code), ② else the FIRST failed child's code (earliest `endedAt`, spawn order on a tie), ③ else absent (never `""`; absent on every completed run). Additive.
|
|
37
|
+
- **C5 · `blocked` is TERMINAL.** `TerminalCause.blocked` / `TaskStatus` JSDoc and `docs/INTEGRATION-CORE.md` §3.1's five-state table now say it in one sentence: the agent's own `report_blocked` self-report ends the leg and waits for nobody; the waiting-on-a-person words are `suspended` (pre-action approval) / `needs_review` (review pause), `parked` on the registry face. Docs only; pinned against the closed set.
|
|
38
|
+
- **C-a · `memory.consolidation_withheld` audience `user` → `operator` (value-domain).** Before: a `user` row minted where no user can be reached — a consolidation run is a host-driven maintenance run outside any session; `detail` carries scope / runId / counts and no `sessionId`, and no scope→session route exists (a memory scope is shared by many sessions). After: the operator's row, like its five siblings; the reader-facing half rides the receipt (`withheldFromCleanArm` / `withheldNeverSent`). Chosen over "add a routable position" because the position does not exist to add.
|
|
39
|
+
- **C-b · `TaskResult.stats.usageMissing?: true`.** Before: a leg whose provider reported no usage frame, and every host-minted zero result (a refused spawn's journal entry, a prepare-time refusal, a synthetic abort), wrote `tokens: 0` indistinguishably from "free". After: the key is present when at least one model round carried the brain's `usageMissing` mark (the `turn_end` frame's word, now on the leg's stats once) or the result never ran a measurable round; the numbers stay required and stay what was counted — a LOWER BOUND beside the key. Absent ⇔ every round reported usage; never `false`. Additive.
|
|
40
|
+
- **C-c · `reasoningEffortLevelsOf(model)` (export).** Before: the per-API effort acceptance set the clamp applies lived only inside `resolveEffort`'s callers; a host offering "supported tiers" read a global default it could not vouch for. After: the one table, two reads — anthropic effort lane = the declared `effortLevels`, budget lane = `undefined` (a token gradient, no tier set), Responses / completions = the sanitized `reasoningEffortLevels` or `DEFAULT_EFFORT_LEVELS`, binary formats and `supportsReasoningEffort:false` (openrouter excepted) = `undefined`. `sanitizeEffortLevels` is shared with `resolveEffort`, so `resolveReasoning(...).effective ∈ reasoningEffortLevelsOf(model)` wherever the set is defined. Per API, not per model id — `Model` grows no key.
|
|
41
|
+
- **C-d · which plane denied auto mode.** Before: `RuntimeCaps.autoMode: false` arrived already folded from the resolver (org / local / settings indistinguishable) and the manifest said `denied`. After: `RuntimeCaps.autoModeDenySource?: "org" | "local" | "settings"` (`AUTO_MODE_DENY_SOURCES`, `isAutoModeDenySource`) — the resolver's own word beside the bit, screened with it (a source beside a non-false bit, or outside the set ⇒ `onError` + deny coined + `resolver_fault`, never defaulted); `autoModeArmReasonOf` mints the whole `AutoModeArmFact { armed, reason, deniedSource? }` (exported) and `WiringManifest.autoMode.deniedSource` carries it only beside a STATED `denied` (`readAutoModeFact` refuses it elsewhere). Additive.
|
|
42
|
+
- **Fail-open reverse check — which fact still lives only in prose after this batch?** The WHY behind a classifier unavailability (`error` vs `timeout`) is deliberately prose + trace (not a branching fact); the lexer's reason behind `ruleStoreUnreadable: "call"` is prose (the mechanism is the fact; the sentence is the rendering); a workflow body's own codeless throw stays `error` text with no code (there is no fact to structure). Everything else the two integration audits named now has a position.
|
|
43
|
+
- Pins: `test/backlog616-classifier-unavailable.test.ts` (C1, rewritten), `test/backlog688-contract-batch.test.ts` (C3 / C4 / C5 / C-a / C-b / C-c / C-d, each double-sided), re-pinned: backlog661, design503, gate-outcome-stations S7, backlog564, gate-outcome-adversarial-r3, backlog625, backlog642 (park ⇒ `workflow.agent_parked`), design383 §1.4, governance-codes, prepare-wiring-manifest, backlog529 (unchanged, green). Export surface +12 (snapshot re-baselined). Ratchet raises: gate-lanes 1425→1445, assemble-result 620→630, run-harness-handlers 583→590, prepare-caps-and-workflow 787→800.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
### Behaviour — a child inherits the parent's RESOLVED checkpoint seat, both delegation lanes, one rule (#683; P1 design; test [6896] G-1/2/3/10; server B-060; @server @cli @test)
|
|
47
|
+
- **Before.** Both delegation lanes handed a child only the parent's OFF switch: the Task lane copied `checkpointStore: "disabled"` off `ctx.checkpointStoreDisabledForChildren`, the `run_workflow` lane off `RunWorkflowToolDeps.parentCheckpointStoreDisabled` / `buildWorkflowPrimitives(…, parentCheckpointStoreDisabled)`. A child whose seat was left unset re-resolved `RunnerDeps.checkpointStore` on its own — so a parent parking through a SPEC-level store (a deployment that wires the store per task, no store on the Runner deps) spawned children that found no store: the background child's and the workflow child's asks fell to the fail-closed deny (`delegation.ask_unresolvable`, `parkLaneExisted: false`) while the parent could park.
|
|
48
|
+
- **After (the rule, in one sentence).** The driver mints the run's checkpoint SEAT once, right after the config doors (`checkpointSeatOf(spec, resolveCheckpointStore(spec, deps))`, `src/core/checkpoint-seat.ts`): the store object the run parks in, the word `"disabled"` when the caller disarmed the run, or NO seat when the run has no store anywhere. A seat rides the trusted ctx seat `ToolExecuteContext.checkpointStoreForChildren` and the Workflow mount dep `RunWorkflowToolDeps.parentCheckpointStore` and lands on every child spec VERBATIM (Task sync / background / fork: `subagent.ts` one spread; workflow: `buildWorkflowPrimitives(…, parentCheckpointStore)`); no seat ⇒ no key, and the child's own door resolves its runner's deployment exactly as before (a split child-execution runner may carry a store the host has none of — `test/agent-park-jian4-durableapproval-forwarding.test.ts:643`, the design/153 件4 E2E chain, pins that topology and is unchanged). Rules 2 → 1: "deps baseline + only the OFF switch travels" is replaced by "a parent with a seat hands the whole seat down". Tighten-only in both directions: `"disabled"` lands UNCONDITIONALLY (a deployment baseline store does not re-arm a run the caller disarmed); a store lands only when the child's seat is unset — a baseline-pinned or trusted-script store is an explicit author and keeps winning (the baseline-principal precedent); a governed script's own `checkpointStore` is stripped and announced as before (`workflow.governance_key_stripped`, not on the whitelist). The child's own door re-reads an inherited store through the same `execution.outcome` predicate (idempotent on the object the parent already passed; no "inherited ⇒ skip" special case).
|
|
49
|
+
- **Review round (adversarial, two REAL findings, both fixed and pinned).** ① `workflowAgentCallKey` hashed the WHOLE child spec — a store object carries its own live state (an in-memory store's row map), so every park moved the journal key of the very call that parked, and an unchanged script's resume read its parked ordinal as a changed call. The checkpoint seat is deployment wiring, not behaviour: it is stripped from the identity like `signal`/`taskId`/`sessionId` (`workflow.ts`). ② The background lane's park family read `background.checkpointStore` (the tool option) for eligibility, TaskStop arbitration, compensation and claim rollback while the child now parks in the inherited seat — with two different instances a stop reported a phantom resume while the row stayed parked. The lane now addresses the store the child was HANDED (`ctx.checkpointStoreForChildren`, none for `"disabled"`); the option is read only when no host seat exists (a tool invoked outside a Runner task — its JSDoc says so). Round 2 (two more REAL, both fixed and pinned): ③ the observer sidecar (`observerSpec`, engine-built like every child) never carried the seat — a `"disabled"` parent's observer could park in the deployment store; it now carries the seat by the same spread. ④ the parked resume's config was rebuilt from the drive's ctx (the bare `/decide` ctx hands no seat), so with no Runner store the resume reported `no CheckpointStore wired` while the checkpoint stayed pending — the resume config now binds the store the lane arbitrates in (`checkpointStore: resumeRest.checkpointStore ?? childParkStore`; an explicit `"disabled"` still refuses). Consequence pinned in `agent-park-jian4-durableapproval-forwarding.test.ts` (RB-482 #31): a `background.checkpointStore` facade that KEEPS throwing now fails the drive loudly (rolled back, row still parked and redeemable) instead of proceeding through a second handle; the disclose-and-proceed shape is the transient (throw-once) form. Round 3 (one adopted, one refused): ⑤ a `background.checkpointStore` naming a DIFFERENT store than the host seat let a bare `/decide` drive resume against the option while the row lived in the seat — it is now refused at the first spawn (`config.invalid_checkpoint_store`, the checkpoint-seat refusal family; same instance or unset option both spawn). ⑥ REFUSED: "keep legacy journal keys" — `workflowAgentCallKey` documents that a spec-shape change moves every key and the resume of a pre-upgrade journal RE-RUNS live rather than replaying (design/164, declared); a compatibility key is the compat layer this codebase does not carry. **Upgrade note (@server):** a workflow journal written by ≤7.13.0 whose child specs carried `checkpointStore` (a `"disabled"` host, or a baseline-pinned store) misses on resume under 7.14.0 and its suffix re-runs live — finish or discard such parked workflow runs before upgrading if their children hold external writes. Not fixed, recorded: a caller tool whose non-string name is a `BigInt` AND whose `modelGate` class removes it for the selected model hits `JSON.stringify` in the model-gate disclosure before the roster-bound door (a second door would add a rule; the proper fix is to make the declaration door the FIRST reader).
|
|
50
|
+
- **Type keys (BREAKING on two exported types, no runtime seam).** `ToolExecuteContext.checkpointStoreDisabledForChildren?: true` → `checkpointStoreForChildren?: CheckpointStore | "disabled"` (Runner-filled whenever the run has a seat — an ordinary run on a deployment store now carries the store object where it carried no key; a run with no store anywhere carries no key, as before); `RunWorkflowToolDeps.parentCheckpointStoreDisabled?: boolean` → `parentCheckpointStore?: CheckpointStore | "disabled"`. `SubagentToolOptions.background.checkpointStore` keeps its key with a narrower meaning (read only without a host seat). Phase Inputs: `PrepareInheritedGateInput` / `PrepareCapsAndWorkflowInput` gain `checkpointSeat`, `PrepareQuestionFaceInput` gains `checkpointStore` (the question face no longer resolves its own; its `spec`/`deps` Picks drop `checkpointStore`).
|
|
51
|
+
- **Observable (pinned both ways).** Host store on the SPEC only: background child parks (pending row, `sourceTaskId` = the child session, zero notices); workflow child parks (wa* row `parked` + token, zero notices). Host `"disabled"` + deployment store: neither lane parks, ONE `delegation.ask_unresolvable` with `parkLaneExisted:false`. Host unset + deployment store: byte-for-byte the #642 six-grid (both lanes park; the ctx seat IS the deployment store object). Host seat = store A while `background.checkpointStore` = B: the child parks in A, TaskStop expires A's checkpoint (`killed`), B never sees a row.
|
|
52
|
+
- **Fail-open reverse check — which parent state lets a child be WIDER than its parent?** None: a `"disabled"` parent presses on the subtree; a parent with a store hands the child the same store; a parent with NO seat leaves the child exactly where it was before this change (its own door, its runner's deployment — the deployment's authored topology, pinned by the 件4 E2E chain).
|
|
53
|
+
- Pins: `test/backlog683-child-inherits-resolved-checkpoint-seat.test.ts` (①②③④ real-run through both lanes + the two review findings), `test/checkpoint-store-null.test.ts` (the three Task lanes + the workflow primitives + the Runner-filled seat, four seat states), `test/run-workflow-tool.test.ts`, `test/prepare-inherited-gate-transcript.test.ts`, `test/prepare-caps-and-workflow.test.ts`.
|
|
54
|
+
- **Not this car:** the teacher/student helper legs (`teacher.ts`) keep copying only the OFF switch — those legs cannot carry a pause (they read `r.result` and release the session), so a store must NOT reach them; their rule is stated there. Observer sidecar legs carry the seat since round 2 (see above).
|
|
55
|
+
|
|
56
|
+
### Fix — a caller tool without a string `name` is refused by name, not crashed on (#684; test [6896] G-12)
|
|
57
|
+
- `rosterMemberBoundProblem` (the ONE roster-bound judge, `tool-roster.ts`) now judges the name's TYPE first: a member whose `name` is not a string (a class instance minted without an own `name`, `null`, a number) returns `config.tool_roster_bound` with `tool has no string name (got <typeof>)` — the declaration door (`prepare-safety-scan.ts`) and the row mint both read it, so the refusal is typed wherever the entry enters. Previously the door threw a bare `TypeError: Cannot read properties of undefined (reading 'length')`. The door's own frame is now `A caller tool cannot ride a roster row: <the judge's sentence>` (the judge's sentence already names the tool; the frame no longer re-reads `t.name`). Pin: `test/backlog684-tool-without-name-refused.test.ts` (+ positive control: the same class with an own `name` mounts).
|
|
58
|
+
### Behaviour / wire — a stall watchdog is no longer reported as a dropped connection (#682 臂②; @cli @server @test)
|
|
59
|
+
- **`BrainRetryErrClass` +`stall` (closed set +1, additive).** `connect_refused | transport | stall | rate_limit | server | http | output_cap`. `stall` says NOTHING was observed on the link: this process cancelled its own request because one of its stall watchdogs reached its deadline. `transport` keeps its old meaning minus that case — something that happened TO the call. The split is about WHO GAVE UP, and claims nothing about the remedy: a blackholed link (no reset, no close) also produces silence, and silence is all a watchdog measures, so a `stall` may still turn out to be a network fault — what it always states truthfully is that the request was cancelled here, at a configured deadline, instead of lost out there.
|
|
60
|
+
- **The mid-stream retry announcement now belongs to the FAILURE, not to the tier.** Both re-sending tiers (B — only thinking streamed; C — nothing substantive) read one mapping, `midstreamRetryAnnouncement` (`src/brain/stream-engine.ts`). A watchdog cut announces `errClass:"stall"` with `detail` naming the watchdog, the deadline it fired at and the knob that arms it — `first-token watchdog fired after 120000ms (firstTokenTimeoutMs), retrying` / `stream idle watchdog fired after 90000ms (idleTimeoutMs), retrying`. A real mid-stream link failure still announces `errClass:"transport"` / `connection lost, reconnecting`. Before: BOTH tiers wrote the connection wording for both species, so a gateway whose prefill legitimately ran past the first-token deadline reported `transport` + "connection lost, reconnecting" and sent the operator looking for a network fault. The operator-facing `brain.retry` telemetry reads the same bucket as the user-facing status frame, as it did before.
|
|
61
|
+
- **Unchanged:** `phase` stays `reconnecting` for both species (the brain IS opening a fresh request); the `waiting_first_token` frame (`elapsedMs` / `timeoutMs`) is untouched; the tier-A partial-finalize warning keeps its CC-verbatim wording; the CONNECT-leg timeout keeps `errClass:"transport"` (a different watchdog and a different knob — not in this change). No watchdog default value moves *in this section* — the first-token default is raised by 臂① below, and nothing here depends on its value.
|
|
62
|
+
- **Downstream.** `@cli` `@server`: `errClass` gained a member — a renderer that switches on it must add a `stall` arm (the old wording arrives under a new class, so a `transport`-keyed branch stops firing for watchdog cuts); a consumer keying its "connection lost" prose off `detail` must re-read it. `@test`: the watchdog-cut retry frame's `detail` and `errClass` are both new text.
|
|
63
|
+
- Pins: `test/backlog682-stall-retry-announcement.test.ts` — ① first-token watchdog ⇒ tier C, `stall` + the watchdog/deadline/knob prose; ② idle watchdog ⇒ tier C (dangling tool call), same shape; ③ 反控 a real mid-stream link failure ⇒ `transport` + "connection lost, reconnecting"; ④ tier B (thinking-only) reads the SAME mapping, on tier B's own budget.
|
|
64
|
+
|
|
65
|
+
### Behaviour — the first-token stall watchdog's default rises 120s → 600s (#682 臂①; ruling A; @server @cli @test)
|
|
66
|
+
- `STALL_FIRST_TOKEN_MS` (`src/brain/timeout.ts`), the ADVISORY per-call first-token bound the Runner supplies on every provider call, goes from `120_000` to `600_000`. The old value rested on a reason that is false for a real class of backend: a self-hosted server working through a long prefill emits literally nothing for 2–3 minutes and is perfectly healthy, so the watchdog cancelled real work and the re-send burned the same prefill again. 600s is the same order of magnitude as the reference implementation's own request timeout; the shape the bound exists for (a gateway that accepted the request and then died) is still caught, just later, and the task's own hard abort remains the outer bound.
|
|
67
|
+
- **Unchanged:** `STALL_CONNECT_MS` (30s — a different segment, the headers wait) and `STALL_IDLE_MS` (90s — silence after output started); the ADVISORY priority chain (a brain's construction-time `firstTokenTimeoutMs`, including an explicit `0`, still wins — a raised default is not a raised floor); the Runner's unconditional per-call supply.
|
|
68
|
+
- **The outer watchdog moved with it — `WORKFLOW_AGENT_STALL_MS` is now DERIVED (180_000 → `STALL_CONNECT_MS + STALL_FIRST_TOKEN_MS` = 630_000; #693).** `runWorkflow`'s per-agent progress watchdog is a COARSER instrument than the engine's re-send: it aborts the attempt and restarts the whole agent on a fresh session, so tool effects from before the stall can be repeated. At 120s the engine acted first in the ordinary case; raising the engine's bound past 180s inverted that systematically, and the same byte-silent provider call produced two agent runs on two sessions instead of one run with a re-sent call (measured). Deriving it means moving either engine bound moves this one too. A deployment that wants the old aggression passes `stallMs` explicitly (still honoured). CC divergence: `MTy` is 180000 upstream, and this is now a sema value — the alternative is an engine default whose own outer layer cancels it.
|
|
69
|
+
- **Read the sum as a floor, NOT as a proof of ordering (measured, adversarial round).** The two clocks do not start together and the outer one does not reset per inner retry, so it can still fire first: it starts before the Runner prepares the attempt (an execution-env factory, a hook, a store read all run inside it and outside the engine's window), while the first-token timer only starts once headers arrive — preparation plus connect time is exactly the margin by which the inner deadline can land later. Two silent provider attempts exceed the outer window by construction. The derivation removes the case that was GUARANTEED to invert; it does not make inversion impossible, and a deployment wanting a real guarantee sets `stallMs` from its own preparation budget. Also note the `run_workflow` TOOL's own whole-workflow cap (`totalTimeoutMs`, default 600_000) is now SMALLER than this window, so under that tool the total cap is what a long stall meets first.
|
|
70
|
+
- **A second meaning had to be split off it: `WORKFLOW_THROTTLE_SLOW_MS` (new, 90_000).** The throttle-shaped-degradation predicate read `stallMs * 0.5` — CC's own spelling, and exactly 90s only while the window was CC's 180_000. Deriving the window silently moved that threshold to 315s, so a 2-minute empty answer stopped earning its 45s-sleep-and-retry. The threshold is now its own constant at CC's value, floored by half the stall window (`min(stallMs / 2, 90_000)`) so a deployment that shortens `stallMs` still tightens it. Exported from `src/orchestration/workflow.ts`; NOT added to the package export surface (`handoff:diff` 0/0). The existing throttle pins derived their own durations from the stall constant and therefore moved with the regression — they now use a fixed 120_000.
|
|
71
|
+
- **What 600s still does NOT buy you (measured; disclosed, not fixed).** On Node's own `fetch` — the default transport for both adapters — undici destroys a body that has produced no bytes for 300s (`bodyTimeout`, default `300e3`), so byte-silence past 300s ends the attempt there and arrives as `errClass:"transport"` (correctly — the transport did cut it). A first token at 400s is therefore still unreachable on default Node fetch; the ruling's target case (a 2–3 minute prefill) sits inside 300s and is fully covered. **The seat to lift it exists:** `fetchImpl` on both `createOpenAIBrain` / `createAnthropicBrain` configs takes any `fetch`, so a deployment needing the full 600s supplies one built on an undici `Agent` with its own `bodyTimeout`. Filed as #694.
|
|
72
|
+
- **Downstream.** `@server`: mirror the catalog default for the settings key that steers this (`MODEL_FIRST_TOKEN_TIMEOUT_MS`) in 7.71.0. `@cli`: a deployment that set that key to work around the old bound can drop the override. `@test`: a run against a slow-prefill backend that used to fail `[network] first-token timeout after 120000ms` now waits.
|
|
73
|
+
- Pins: `test/backlog682-stall-retry-announcement.test.ts` 臂① (the supplied default is 600s, both sibling legs untouched; 反控 a construction-time `firstTokenTimeoutMs` still wins) and ① (the workflow window IS the derived sum; a byte-silent first provider call recovers in ONE agent run with TWO provider calls; 反控 an explicit `stallMs` below the engine's bound restarts the agent instead and is still honoured). Re-pinned: `test/walltime-stall-watchdog.test.ts` 量纲 sanity, `test/stream-options-forwarding.test.ts` (both per-call supply legs), `test/workflow-resilience.test.ts` (the window and the stall-exhaustion message read the constant instead of a literal 180000; the five throttle legs now spend a FIXED 120_000 so they stop moving with the threshold they test, plus two BOUNDARY legs either side of the exact threshold — without them the duration condition could be deleted outright and the suite stayed green).
|
|
74
|
+
|
|
75
|
+
### Behaviour — the two output-length knobs say so when they discard a value (#690)
|
|
76
|
+
- `BASH_MAX_OUTPUT_LENGTH` (non-finite / ≤ 0) and `TASK_MAX_OUTPUT_LENGTH` (not a plain positive integer) fell back to their built-in defaults (30000 / 32000 characters) in SILENCE, with no written reason for the silence — while their sibling `MAX_MCP_OUTPUT_TOKENS`, same shape and same kind of resolver, already announced. Both now emit the sibling's line on the same process-level channel, once per deployment fact: `BASH_MAX_OUTPUT_LENGTH=25k was ignored — it is not a positive whole number of characters. Using 30000 instead.` For every value class the old resolvers already rejected this adds loudness and nothing else — the number in force does not move. One further class DOES move, and it is described in the next bullet.
|
|
77
|
+
- **One value class DOES change, and it is a bad-value class.** `BASH_MAX_OUTPUT_LENGTH` now requires a positive WHOLE number — the rule its own announcement states. Before, `Number.isFinite(raw) && raw > 0` let a fraction through to `Math.floor`, so `BASH_MAX_OUTPUT_LENGTH=0.5` resolved to a budget of ZERO and the shell's clip returned the empty string for a command that had already run and produced output — silently, and on the `TaskOutput` leg with no recovery file either. `0.5` / `.5` / `1e-3` / `1.5` now announce and take the 30000 default. Every whole-number value, the ceiling clamp, and the absence rule are untouched (adversarial round; found by the independent reviewer).
|
|
78
|
+
- Absence stays silent — unset, empty, or blank is not a value (the written exception the shell's timeout knobs already state).
|
|
79
|
+
- The announcer moved out of `src/core/mcp.ts` into `src/core/env-knob-announce.ts` (`announceEnvKnob`) so the outlet is a property of the shape — a process-level env knob whose resolver holds no structured notice seat — rather than of whichever domain built it first. No public export changes.
|
|
80
|
+
- The shared announcer is swallow-guarded (the posture every other advisory sink here already holds), which the MCP knobs inherit: a host that replaces `console.warn` with a logger that throws, or a `Console` built with `ignoreErrors:false` on a full stream, now costs the announcement and nothing else. Unguarded, it would have thrown out of the shell's output clip — which runs AFTER the command ran — destroying a completed command's result and inviting a re-run of work whose effects had already landed (adversarial round 2).
|
|
81
|
+
- Pins: `test/backlog690-output-length-knob-loud.test.ts` (per knob: four/five bad values ⇒ one line naming knob, raw text and the default in force + the second read stays silent; 反控 a legal value, a clamped-but-legal value, and absence ⇒ zero lines).
|
|
82
|
+
|
|
3
83
|
## 7.13.0 — 2026-09-10
|
|
4
84
|
|
|
5
85
|
### Narrowing — a run that has already given its answer is no longer cancellable (#677; ruling 「最后一次 `turn_end` 后 run 不可取消:三轴同律;窗内停机终态记 completed;`interrupt()` 改有明确回执」; sema form, no CC 2.1.250 mirror — DV-677-1; @server @cli @test)
|
|
@@ -650,15 +650,10 @@ export interface SubagentToolOptions {
|
|
|
650
650
|
* writes a durable row (restart-surviving TaskOutput reads; the S1b lifecycle flip keys off the
|
|
651
651
|
* same wiring). Absent ⇒ pre-151 in-memory-only lifecycle, unchanged. */
|
|
652
652
|
agentStore?: import("../core/background-agent-store.js").BackgroundAgentStore;
|
|
653
|
-
/**
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* suspend mints its checkpoint there; a different instance could not see the token — the F-6
|
|
658
|
-
* dual-wiring hazard, documented symptom: parks succeed but stops/reconciliation read "missing").
|
|
659
|
-
* Absent ⇒ a durably-suspending bg child keeps the pre-153 lifecycle (settled `failed` with
|
|
660
|
-
* `unexpected.suspended`), byte-for-byte.
|
|
661
|
-
*/
|
|
653
|
+
/** design/153 §7.3 — the park lane's checkpoint store where no host seat rides (a tool invoked outside a Runner task, and the
|
|
654
|
+
* bare /decide drive of a parked child). Inside a task the child parks in the seat it was HANDED (`ToolExecuteContext.
|
|
655
|
+
* checkpointStoreForChildren`); an option naming a different store is refused at spawn (`config.invalid_checkpoint_store`).
|
|
656
|
+
* Absent with no host seat ⇒ a durably-suspending bg child keeps the pre-153 lifecycle. */
|
|
662
657
|
checkpointStore?: import("../core/checkpoint-store.js").CheckpointStore;
|
|
663
658
|
/**
|
|
664
659
|
* design/153 §7.3 (r4 F-09) — the CAPABILITY protocol that attests the child's transcript
|
package/dist/agents/subagent.js
CHANGED
|
@@ -1897,6 +1897,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1897
1897
|
...(observerDef.systemPrompt !== undefined && !resume ? { systemPrompt: observerDef.systemPrompt } : {}),
|
|
1898
1898
|
...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
|
|
1899
1899
|
...(ctx.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: ctx.getApiKeyAndHeaders } : {}),
|
|
1900
|
+
...(ctx.checkpointStoreForChildren !== undefined ? { checkpointStore: ctx.checkpointStoreForChildren } : {}),
|
|
1900
1901
|
tools: [reportTool],
|
|
1901
1902
|
...(ctx.excludeTools !== undefined ? { excludeTools: [...ctx.excludeTools] } : {}),
|
|
1902
1903
|
...(ctx.deferTools !== undefined ? { deferTools: [...ctx.deferTools] } : {}),
|
|
@@ -2154,7 +2155,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2154
2155
|
...(def?.memory ? { memory: def.memory } : {}),
|
|
2155
2156
|
...(def?.skills?.length ? { skills: def.skills } : {}),
|
|
2156
2157
|
...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
|
|
2157
|
-
...(ctx.
|
|
2158
|
+
...(ctx.checkpointStoreForChildren !== undefined ? { checkpointStore: ctx.checkpointStoreForChildren } : {}),
|
|
2158
2159
|
...(childOnAsk !== undefined ? { onAsk: childOnAsk } : {}),
|
|
2159
2160
|
...(ctx.onQuestion !== undefined ? { onQuestion: ctx.onQuestion } : {}),
|
|
2160
2161
|
...(ctx.interactiveTools === false ? { interactiveTools: false } : {}),
|
|
@@ -2922,6 +2923,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2922
2923
|
}
|
|
2923
2924
|
if (wantsBackground) {
|
|
2924
2925
|
const bg = opts.background;
|
|
2926
|
+
if (ctx.checkpointStoreForChildren !== undefined && ctx.checkpointStoreForChildren !== "disabled" && bg.checkpointStore !== undefined && bg.checkpointStore !== ctx.checkpointStoreForChildren)
|
|
2927
|
+
throw configError("background.checkpointStore is not the store this run's children park in (the host's resolved checkpoint seat, TaskSpec/RunnerDeps.checkpointStore) — wire the one store, or leave the option unset and let the seat ride.", "config.invalid_checkpoint_store");
|
|
2928
|
+
const childParkStore = ctx.checkpointStoreForChildren === undefined ? bg.checkpointStore : ctx.checkpointStoreForChildren === "disabled" ? undefined : ctx.checkpointStoreForChildren;
|
|
2925
2929
|
const notify = ctx.onTaskNotification ?? bg.notify;
|
|
2926
2930
|
const abort = new AbortController();
|
|
2927
2931
|
const reviveRow = reviveClaim?.row;
|
|
@@ -3030,7 +3034,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3030
3034
|
initialStatus: "parked",
|
|
3031
3035
|
parkedCheckpointToken: parkedResume.ticket.token,
|
|
3032
3036
|
resolveParkedStop: async () => {
|
|
3033
|
-
const cpS =
|
|
3037
|
+
const cpS = childParkStore;
|
|
3034
3038
|
if (cpS === undefined)
|
|
3035
3039
|
return false;
|
|
3036
3040
|
const cp = (await cpS.get(parkedResume.ticket.token));
|
|
@@ -3173,7 +3177,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3173
3177
|
}
|
|
3174
3178
|
const forwardDurableApproval = ctx.durableApprovalForChildren !== undefined &&
|
|
3175
3179
|
bg.agentStore !== undefined &&
|
|
3176
|
-
|
|
3180
|
+
childParkStore !== undefined &&
|
|
3177
3181
|
bg.ensureChildSessionDurable !== undefined;
|
|
3178
3182
|
const bgSpecBase = {
|
|
3179
3183
|
...buildChildSpec(abort.signal),
|
|
@@ -3279,7 +3283,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3279
3283
|
};
|
|
3280
3284
|
const driveParkedResume = async () => {
|
|
3281
3285
|
const pr = parkedResume;
|
|
3282
|
-
const claimStores = { agentStore: bg.agentStore, checkpointStore:
|
|
3286
|
+
const claimStores = { agentStore: bg.agentStore, checkpointStore: childParkStore };
|
|
3283
3287
|
const rollbackAndThrow = async (e) => {
|
|
3284
3288
|
let disp = "retry";
|
|
3285
3289
|
try {
|
|
@@ -3303,9 +3307,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3303
3307
|
const code = e.code;
|
|
3304
3308
|
if (code === "not_found") {
|
|
3305
3309
|
try {
|
|
3306
|
-
const cp = (await
|
|
3310
|
+
const cp = (await childParkStore.get(pr.ticket.token));
|
|
3307
3311
|
if (cp !== null)
|
|
3308
|
-
await
|
|
3312
|
+
await childParkStore.expire(pr.ticket.token, cp.scope);
|
|
3309
3313
|
}
|
|
3310
3314
|
catch {
|
|
3311
3315
|
}
|
|
@@ -3333,10 +3337,10 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3333
3337
|
};
|
|
3334
3338
|
let childResult;
|
|
3335
3339
|
try {
|
|
3336
|
-
const { objective: _o, sessionId: _s, ...
|
|
3340
|
+
const { objective: _o, sessionId: _s, ...resumeRest } = bgSpec, resumeConfig = { ...resumeRest, checkpointStore: resumeRest.checkpointStore ?? childParkStore };
|
|
3337
3341
|
if (resumeConfig.durableApproval === undefined) {
|
|
3338
3342
|
try {
|
|
3339
|
-
const cp0 = (await
|
|
3343
|
+
const cp0 = (await childParkStore.get(pr.ticket.token));
|
|
3340
3344
|
const rec = cp0?.durableApproval;
|
|
3341
3345
|
if (rec !== undefined && typeof rec.scope === "string" && rec.scope.length > 0) {
|
|
3342
3346
|
resumeConfig.durableApproval = { scope: rec.scope, ...(typeof rec.ttlMs === "number" ? { ttlMs: rec.ttlMs } : {}) };
|
|
@@ -3369,7 +3373,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3369
3373
|
if (childResult.terminal.kind === "failed") {
|
|
3370
3374
|
let reopened = false;
|
|
3371
3375
|
try {
|
|
3372
|
-
const cp = (await
|
|
3376
|
+
const cp = (await childParkStore.get(pr.ticket.token));
|
|
3373
3377
|
reopened = cp !== null && cp.status === "pending";
|
|
3374
3378
|
}
|
|
3375
3379
|
catch {
|
|
@@ -3379,7 +3383,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3379
3383
|
bg.registry.parkBackgroundAgent(taskId, {
|
|
3380
3384
|
checkpointToken: pr.ticket.token,
|
|
3381
3385
|
resolveStop: async () => {
|
|
3382
|
-
const cpS =
|
|
3386
|
+
const cpS = childParkStore;
|
|
3383
3387
|
const cp = (await cpS.get(pr.ticket.token));
|
|
3384
3388
|
if (cp === null)
|
|
3385
3389
|
return false;
|
|
@@ -3444,7 +3448,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3444
3448
|
let unparkedPause;
|
|
3445
3449
|
if (isDurablePause(child.terminal)) {
|
|
3446
3450
|
const pausedToken = child.terminal.token;
|
|
3447
|
-
const cpStore =
|
|
3451
|
+
const cpStore = childParkStore;
|
|
3448
3452
|
const expireByStoreScope = async (token2) => {
|
|
3449
3453
|
try {
|
|
3450
3454
|
const cp0 = (await cpStore.get(token2));
|
|
@@ -3476,7 +3480,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
3476
3480
|
checkpointToken: token,
|
|
3477
3481
|
seq: seqAtSettle ?? 1,
|
|
3478
3482
|
resolveStop: async () => {
|
|
3479
|
-
const cpS =
|
|
3483
|
+
const cpS = childParkStore;
|
|
3480
3484
|
const cp = (await cpS.get(token));
|
|
3481
3485
|
if (cp === null)
|
|
3482
3486
|
return false;
|
package/dist/agents/teacher.js
CHANGED
|
@@ -409,7 +409,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
|
|
|
409
409
|
sessionId: sid ?? "aborted",
|
|
410
410
|
terminal: { kind: "failed", message: "student run did not settle promptly after stuck-abort" },
|
|
411
411
|
result: "",
|
|
412
|
-
stats: { turns: 0, tokens: 0 },
|
|
412
|
+
stats: { turns: 0, tokens: 0, usageMissing: true },
|
|
413
413
|
});
|
|
414
414
|
const abortFallback = (sid) => {
|
|
415
415
|
let timer;
|
|
@@ -267,6 +267,29 @@ export declare function resolveReasoning(requested: ThinkingLevel, model: {
|
|
|
267
267
|
* valid member); individually invalid MEMBERS (garbage strings, sparse holes, `"off"` — not a legal
|
|
268
268
|
* member of the 6-tier compat effort sets) are dropped, so they can never become a clamp target.
|
|
269
269
|
*/
|
|
270
|
+
/**
|
|
271
|
+
* #692 C-c — the effort tiers that actually REACH THE WIRE for this model: the table {@link resolveEffort} clamps
|
|
272
|
+
* against, keyed by the model's API family exactly as {@link resolveReasoning} dispatches, gated by the request
|
|
273
|
+
* entry predicate (a `reasoning: false` model accepts no tier) and minus the tiers the model's `thinkingLevelMap`
|
|
274
|
+
* suppresses (`null` = no effort value on the wire) — the same arms, so a host reading this and the trace's
|
|
275
|
+
* `effective` can never disagree:
|
|
276
|
+
* - `anthropic-messages` with declared `compat.effortLevels` → that set (the `output_config.effort` lane);
|
|
277
|
+
* without it → `undefined` (the BUDGET lane is a token gradient, there is no tier set to clamp to);
|
|
278
|
+
* - the Responses family and the completions family → `compat.reasoningEffortLevels` sanitized
|
|
279
|
+
* (invalid members dropped, an empty/garbage declaration reads as undeclared) or the conservative
|
|
280
|
+
* {@link DEFAULT_EFFORT_LEVELS}; `supportsReasoningEffort: false` (openrouter excepted, whose unified
|
|
281
|
+
* knob ignores it) and the binary formats (`qwen` / `zai` / `qwen-chat-template`) → `undefined` (the tier
|
|
282
|
+
* is not honored as a gradient on that wire).
|
|
283
|
+
* `undefined` therefore means "this wire clamps no tier set", never "unknown": a host that offers a person the
|
|
284
|
+
* supported tiers reads THIS, per model, instead of a global default it cannot vouch for. Per API, not per
|
|
285
|
+
* model id: the acceptance set is a property of the endpoint's request schema; `Model` grows no key for it.
|
|
286
|
+
*/
|
|
287
|
+
export declare function reasoningEffortLevelsOf(model: {
|
|
288
|
+
api?: string;
|
|
289
|
+
reasoning?: boolean;
|
|
290
|
+
compat?: unknown;
|
|
291
|
+
thinkingLevelMap?: Readonly<Partial<Record<ThinkingLevel, string | null>>>;
|
|
292
|
+
}): readonly ThinkingLevel[] | undefined;
|
|
270
293
|
export declare function resolveEffort(requested: ThinkingLevel, allowed?: readonly ThinkingLevel[]): ReasoningResolution;
|
|
271
294
|
/**
|
|
272
295
|
* Resolve a requested intensity for a binary enable-only endpoint (qwen / zai / qwen-chat-template): the
|
package/dist/brain/reasoning.js
CHANGED
|
@@ -91,8 +91,7 @@ function dispatchReasoning(requested, model, facts) {
|
|
|
91
91
|
if (BINARY_FORMATS.has(format)) {
|
|
92
92
|
return { ...resolveBinary(requested), format, endpoint };
|
|
93
93
|
}
|
|
94
|
-
|
|
95
|
-
if (!supportsEffort && format !== "openrouter") {
|
|
94
|
+
if (!completionsEffortHonored(compat, format)) {
|
|
96
95
|
return { requested, effective: requested, graded: false, clamped: false, format, endpoint };
|
|
97
96
|
}
|
|
98
97
|
const mint = mintEffortWireValue(requested, model, compat.reasoningEffortLevels);
|
|
@@ -101,9 +100,41 @@ function dispatchReasoning(requested, model, facts) {
|
|
|
101
100
|
}
|
|
102
101
|
return { ...mint.resolution, format, endpoint };
|
|
103
102
|
}
|
|
104
|
-
export function
|
|
103
|
+
export function reasoningEffortLevelsOf(model) {
|
|
104
|
+
if (!model.reasoning)
|
|
105
|
+
return undefined;
|
|
106
|
+
const compat = (model.compat ?? {});
|
|
107
|
+
if (model.api === "anthropic-messages") {
|
|
108
|
+
const declared = declaredEffortLevels(compat.effortLevels);
|
|
109
|
+
return declared === undefined ? undefined : sanitizeEffortLevels(declared);
|
|
110
|
+
}
|
|
111
|
+
let clampSet;
|
|
112
|
+
if (model.api !== undefined && RESPONSES_APIS.has(model.api)) {
|
|
113
|
+
if (compat.supportsReasoningEffort === false)
|
|
114
|
+
return undefined;
|
|
115
|
+
clampSet = sanitizeEffortLevels(compat.reasoningEffortLevels);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const format = compat.thinkingFormat ?? "openai";
|
|
119
|
+
if (BINARY_FORMATS.has(format))
|
|
120
|
+
return undefined;
|
|
121
|
+
if (!completionsEffortHonored(compat, format))
|
|
122
|
+
return undefined;
|
|
123
|
+
clampSet = sanitizeEffortLevels(compat.reasoningEffortLevels);
|
|
124
|
+
}
|
|
125
|
+
const honored = clampSet.filter((lvl) => model.thinkingLevelMap?.[lvl] !== null);
|
|
126
|
+
return honored.length > 0 ? honored : undefined;
|
|
127
|
+
}
|
|
128
|
+
function completionsEffortHonored(compat, format) {
|
|
129
|
+
const supportsEffort = compat.supportsReasoningEffort ?? true;
|
|
130
|
+
return !!supportsEffort || format === "openrouter";
|
|
131
|
+
}
|
|
132
|
+
function sanitizeEffortLevels(allowed) {
|
|
105
133
|
const declared = Array.isArray(allowed) ? allowed.filter((lvl) => isThinkingLevel(lvl) && lvl !== "off") : [];
|
|
106
|
-
|
|
134
|
+
return declared.length > 0 ? declared : DEFAULT_EFFORT_LEVELS;
|
|
135
|
+
}
|
|
136
|
+
export function resolveEffort(requested, allowed = DEFAULT_EFFORT_LEVELS) {
|
|
137
|
+
allowed = sanitizeEffortLevels(allowed);
|
|
107
138
|
if (requested === "off" || allowed.includes(requested)) {
|
|
108
139
|
return { requested, effective: requested, graded: true, clamped: false };
|
|
109
140
|
}
|
|
@@ -246,6 +246,11 @@ async function sleepAnnouncingRetry(totalMs, signal, frame) {
|
|
|
246
246
|
return;
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
+
function midstreamRetryAnnouncement(failure) {
|
|
250
|
+
return failure.kind === "stalled"
|
|
251
|
+
? { errClass: "stall", detail: `${failure.watchdog} watchdog fired after ${failure.timeoutMs}ms (${failure.knob}), retrying` }
|
|
252
|
+
: { errClass: "transport", detail: "connection lost, reconnecting" };
|
|
253
|
+
}
|
|
249
254
|
export function runStreamingBrain(args) {
|
|
250
255
|
const { model, doFetch, signal, config, httpLabel, buildRequest, makeParser, stallTimeouts } = args;
|
|
251
256
|
const out = createAssistantMessageEventStream();
|
|
@@ -471,7 +476,7 @@ export function runStreamingBrain(args) {
|
|
|
471
476
|
const decoder = new TextDecoder();
|
|
472
477
|
let buffer = "";
|
|
473
478
|
let firstTokenSeen = false;
|
|
474
|
-
let
|
|
479
|
+
let firedFirstTokenDeadlineMs;
|
|
475
480
|
let ftTimer;
|
|
476
481
|
let waitTimer;
|
|
477
482
|
const clearWaitTimer = () => {
|
|
@@ -482,7 +487,7 @@ export function runStreamingBrain(args) {
|
|
|
482
487
|
};
|
|
483
488
|
if (firstTokenTimeoutMs && firstTokenTimeoutMs > 0) {
|
|
484
489
|
ftTimer = setTimeout(() => {
|
|
485
|
-
|
|
490
|
+
firedFirstTokenDeadlineMs = firstTokenTimeoutMs;
|
|
486
491
|
clearWaitTimer();
|
|
487
492
|
void reader.cancel().catch(() => undefined);
|
|
488
493
|
}, firstTokenTimeoutMs);
|
|
@@ -492,7 +497,7 @@ export function runStreamingBrain(args) {
|
|
|
492
497
|
const scheduleWaitFrame = (delayMs) => {
|
|
493
498
|
waitTimer = setTimeout(() => {
|
|
494
499
|
waitTimer = undefined;
|
|
495
|
-
if (firstTokenSeen ||
|
|
500
|
+
if (firstTokenSeen || firedFirstTokenDeadlineMs !== undefined || Date.now() - waitStartedAt >= firstTokenTimeoutMs)
|
|
496
501
|
return;
|
|
497
502
|
announcedWait = true;
|
|
498
503
|
emitWaitFrame({
|
|
@@ -507,16 +512,16 @@ export function runStreamingBrain(args) {
|
|
|
507
512
|
scheduleWaitFrame(WAITING_FIRST_TOKEN_AFTER_MS);
|
|
508
513
|
}
|
|
509
514
|
}
|
|
510
|
-
let
|
|
515
|
+
let firedIdleDeadlineMs;
|
|
511
516
|
let idleTimer;
|
|
512
517
|
const bumpIdle = () => {
|
|
513
518
|
if (!idleTimeoutMs || idleTimeoutMs <= 0)
|
|
514
519
|
return;
|
|
515
|
-
|
|
520
|
+
firedIdleDeadlineMs = undefined;
|
|
516
521
|
if (idleTimer)
|
|
517
522
|
clearTimeout(idleTimer);
|
|
518
523
|
idleTimer = setTimeout(() => {
|
|
519
|
-
|
|
524
|
+
firedIdleDeadlineMs = idleTimeoutMs;
|
|
520
525
|
void reader.cancel().catch(() => undefined);
|
|
521
526
|
}, idleTimeoutMs);
|
|
522
527
|
};
|
|
@@ -525,7 +530,7 @@ export function runStreamingBrain(args) {
|
|
|
525
530
|
if (firstTokenSeen)
|
|
526
531
|
return;
|
|
527
532
|
firstTokenSeen = true;
|
|
528
|
-
|
|
533
|
+
firedFirstTokenDeadlineMs = undefined;
|
|
529
534
|
if (ftTimer) {
|
|
530
535
|
clearTimeout(ftTimer);
|
|
531
536
|
ftTimer = undefined;
|
|
@@ -615,11 +620,23 @@ export function runStreamingBrain(args) {
|
|
|
615
620
|
clearTimeout(idleTimer);
|
|
616
621
|
idleTimer = undefined;
|
|
617
622
|
}
|
|
618
|
-
if (!failure &&
|
|
619
|
-
failure = {
|
|
623
|
+
if (!failure && firedFirstTokenDeadlineMs !== undefined) {
|
|
624
|
+
failure = {
|
|
625
|
+
kind: "stalled",
|
|
626
|
+
watchdog: "first-token",
|
|
627
|
+
knob: "firstTokenTimeoutMs",
|
|
628
|
+
timeoutMs: firedFirstTokenDeadlineMs,
|
|
629
|
+
err: new BrainError("network", `first-token timeout after ${firedFirstTokenDeadlineMs}ms`, undefined, { apiError: true }),
|
|
630
|
+
};
|
|
620
631
|
}
|
|
621
|
-
if (!failure &&
|
|
622
|
-
failure = {
|
|
632
|
+
if (!failure && firedIdleDeadlineMs !== undefined) {
|
|
633
|
+
failure = {
|
|
634
|
+
kind: "stalled",
|
|
635
|
+
watchdog: "stream idle",
|
|
636
|
+
knob: "idleTimeoutMs",
|
|
637
|
+
timeoutMs: firedIdleDeadlineMs,
|
|
638
|
+
err: new BrainError("network", `stream idle timeout after ${firedIdleDeadlineMs}ms`, undefined, { apiError: true }),
|
|
639
|
+
};
|
|
623
640
|
}
|
|
624
641
|
if (!failure) {
|
|
625
642
|
buffer += decoder.decode();
|
|
@@ -641,22 +658,23 @@ export function runStreamingBrain(args) {
|
|
|
641
658
|
throw failure.err;
|
|
642
659
|
cleanup?.();
|
|
643
660
|
cleanup = undefined;
|
|
661
|
+
const announcement = midstreamRetryAnnouncement(failure);
|
|
644
662
|
if (snap.hasOnlyThinking) {
|
|
645
663
|
if (thinkingRetries >= THINKING_RETRY_BUDGET)
|
|
646
664
|
throw failure.err;
|
|
647
665
|
thinkingRetries++;
|
|
648
666
|
parser.sealForRetry();
|
|
649
667
|
const delayMs = 100 * thinkingRetries;
|
|
650
|
-
emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream", errClass:
|
|
668
|
+
emitBrainTelemetry({ kind: "retry", attempt: thinkingRetries, phase: "midstream", errClass: announcement.errClass, nextDelayMs: delayMs });
|
|
651
669
|
announcedRetry = true;
|
|
652
670
|
await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
|
|
653
671
|
phase: "reconnecting",
|
|
654
|
-
detail:
|
|
672
|
+
detail: announcement.detail,
|
|
655
673
|
retryInSec: Math.ceil(remainingMs / 1000),
|
|
656
674
|
retryInMs: remainingMs,
|
|
657
675
|
attempt: thinkingRetries,
|
|
658
676
|
maxRetries: THINKING_RETRY_BUDGET,
|
|
659
|
-
errClass:
|
|
677
|
+
errClass: announcement.errClass,
|
|
660
678
|
}));
|
|
661
679
|
continue;
|
|
662
680
|
}
|
|
@@ -664,16 +682,16 @@ export function runStreamingBrain(args) {
|
|
|
664
682
|
throw failure.err;
|
|
665
683
|
const delayMs = retryBackoffMs(baseDelay, attempt);
|
|
666
684
|
attempt++;
|
|
667
|
-
emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream", errClass:
|
|
685
|
+
emitBrainTelemetry({ kind: "retry", attempt, phase: "midstream", errClass: announcement.errClass, nextDelayMs: delayMs });
|
|
668
686
|
announcedRetry = true;
|
|
669
687
|
await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
|
|
670
688
|
phase: "reconnecting",
|
|
671
|
-
detail:
|
|
689
|
+
detail: announcement.detail,
|
|
672
690
|
retryInSec: Math.ceil(remainingMs / 1000),
|
|
673
691
|
retryInMs: remainingMs,
|
|
674
692
|
attempt,
|
|
675
693
|
maxRetries,
|
|
676
|
-
errClass:
|
|
694
|
+
errClass: announcement.errClass,
|
|
677
695
|
}));
|
|
678
696
|
}
|
|
679
697
|
}
|
package/dist/brain/timeout.d.ts
CHANGED
|
@@ -38,17 +38,38 @@ export declare function resolveStallTimeoutMs(value: number | undefined, knob: s
|
|
|
38
38
|
* - connect 30s: bounds the fetch→response-HEADERS wait — the one segment every post-headers
|
|
39
39
|
* watchdog is blind to. On a streaming API the model's time is spent AFTER headers, so 30s with
|
|
40
40
|
* none at all is a hung gateway, not a slow model.
|
|
41
|
-
* - first-token
|
|
41
|
+
* - first-token 600s: bounds the headers→first-delta wait, NOT the model's thought (the first
|
|
42
42
|
* THINKING delta already counts as the first token, design/17 O5 — see
|
|
43
|
-
* {@link BrainTimeoutConfig.firstTokenTimeoutMs}), so a slow reasoner is safe
|
|
44
|
-
* literally NOTHING is a hung gateway
|
|
43
|
+
* {@link BrainTimeoutConfig.firstTokenTimeoutMs}), so a slow reasoner is safe. The old 120s rested
|
|
44
|
+
* on "2 minutes with literally NOTHING is a hung gateway", which is FALSE for a real class of
|
|
45
|
+
* backend: a self-hosted server working through a long prefill emits nothing at all for 2–3
|
|
46
|
+
* minutes, and the watchdog cancelled it — burning the prefill and re-sending a request that then
|
|
47
|
+
* burned it again. A guardrail whose reason is false for healthy traffic is not a guardrail. 600s
|
|
48
|
+
* is the same order of magnitude as the reference implementation's own request timeout, and the
|
|
49
|
+
* shapes this bound exists for (a gateway that accepted the request and then died) are still
|
|
50
|
+
* caught, just later; the task's own hard abort remains the outer bound for everything else.
|
|
51
|
+
* HONEST CEILING (measured, adversarial round): this watchdog is not the only clock over a
|
|
52
|
+
* byte-silent stream, so 600s is the bound this engine ASKS FOR, not the silence a deployment is
|
|
53
|
+
* necessarily granted. On Node's own `fetch` — the default transport for both adapters, and the
|
|
54
|
+
* one a deployment gets by supplying no `fetchImpl` — undici destroys a body that has produced no
|
|
55
|
+
* bytes for 300s (`bodyTimeout`, default `300e3`), so byte-silence past 300s ends the attempt
|
|
56
|
+
* there and arrives as a read rejection reported `transport`. That report is CORRECT (the
|
|
57
|
+
* transport really did cut it) and the seat to change it exists: `fetchImpl` on both brain configs
|
|
58
|
+
* takes any `fetch`, so a deployment needing the full 600s passes one built on an undici
|
|
59
|
+
* `Agent`/`Dispatcher` with its own `bodyTimeout`. Named here so the next reader who measures ~300s
|
|
60
|
+
* of tolerance does not conclude this number is a lie.
|
|
61
|
+
* The OTHER clock that used to sit below this one is fixed rather than disclosed:
|
|
62
|
+
* {@link import("../orchestration/workflow.js").WORKFLOW_AGENT_STALL_MS} is now DERIVED from
|
|
63
|
+
* {@link STALL_CONNECT_MS} + this value, so the coarse agent-restart no longer sits UNDER a single
|
|
64
|
+
* silent provider call by construction. Move this number and that one follows. It is a floor, not
|
|
65
|
+
* a guarantee of ordering — see that constant's own comment for the cases that still invert.
|
|
45
66
|
* - idle 90s: mid-stream silence after output started — a healthy stream emits steadily
|
|
46
67
|
* (comfortably above any provider keep-alive cadence).
|
|
47
68
|
* ADVISORY: a brain whose construction-time {@link BrainTimeoutConfig} sets the corresponding field
|
|
48
69
|
* keeps it (deployment sovereignty; see the `StreamOptions.stallTimeouts` priority chain).
|
|
49
70
|
*/
|
|
50
71
|
export declare const STALL_CONNECT_MS = 30000;
|
|
51
|
-
export declare const STALL_FIRST_TOKEN_MS =
|
|
72
|
+
export declare const STALL_FIRST_TOKEN_MS = 600000;
|
|
52
73
|
export declare const STALL_IDLE_MS = 90000;
|
|
53
74
|
export interface BrainTimeoutConfig {
|
|
54
75
|
/** Abort the request if `fetch` doesn't return response headers within this many ms. Retryable. */
|
package/dist/brain/timeout.js
CHANGED
|
@@ -15,7 +15,7 @@ export function resolveStallTimeoutMs(value, knob) {
|
|
|
15
15
|
return value;
|
|
16
16
|
}
|
|
17
17
|
export const STALL_CONNECT_MS = 30_000;
|
|
18
|
-
export const STALL_FIRST_TOKEN_MS =
|
|
18
|
+
export const STALL_FIRST_TOKEN_MS = 600_000;
|
|
19
19
|
export const STALL_IDLE_MS = 90_000;
|
|
20
20
|
export function createConnectController(connectTimeoutMs, outerSignal) {
|
|
21
21
|
const ac = new AbortController();
|