@sema-agent/core 7.9.2 → 7.11.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 +102 -0
- package/dist/agents/child-model-seat.d.ts +71 -14
- package/dist/agents/child-model-seat.js +23 -7
- package/dist/agents/subagent.d.ts +2 -2
- package/dist/agents/subagent.js +8 -5
- package/dist/agents/teacher.js +2 -2
- package/dist/core/ask-unresolvable-notice.d.ts +52 -0
- package/dist/core/ask-unresolvable-notice.js +25 -0
- package/dist/core/auto-mode-defaults.d.ts +15 -3
- package/dist/core/auto-mode-defaults.js +1 -0
- package/dist/core/auto-mode.d.ts +86 -23
- package/dist/core/auto-mode.js +43 -12
- package/dist/core/checkpoint-store.d.ts +14 -0
- package/dist/core/checkpoint-store.js +2 -1
- package/dist/core/engine-notice.d.ts +28 -7
- package/dist/core/gate-fold.js +1 -0
- package/dist/core/gate-lanes.d.ts +6 -1
- package/dist/core/gate-lanes.js +60 -18
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +4 -0
- package/dist/core/hooks.d.ts +37 -1
- package/dist/core/hooks.js +2 -0
- package/dist/core/permission-rule-model.d.ts +53 -16
- package/dist/core/permission-rule-model.js +59 -21
- package/dist/core/permission-rules.d.ts +6 -4
- package/dist/core/permission-rules.js +14 -14
- package/dist/core/persisted-rule-arms.js +4 -3
- package/dist/core/read-only-shell-table.d.ts +87 -0
- package/dist/core/read-only-shell-table.js +485 -0
- package/dist/core/read-only-shell.d.ts +42 -0
- package/dist/core/read-only-shell.js +316 -0
- package/dist/core/roles.d.ts +3 -2
- package/dist/core/runner/contracts.d.ts +26 -2
- package/dist/core/runner/denial-limit-arms.d.ts +14 -3
- package/dist/core/runner/denial-limit-arms.js +15 -5
- package/dist/core/runner/gate-exit.d.ts +5 -0
- package/dist/core/runner/permission-rule-lanes.d.ts +7 -1
- package/dist/core/runner/permission-rule-lanes.js +9 -3
- package/dist/core/runner/prepare-caps-and-workflow.d.ts +1 -1
- package/dist/core/runner/prepare-caps-and-workflow.js +35 -14
- package/dist/core/runner/prepare-gate-stations.d.ts +3 -2
- package/dist/core/runner/prepare-gate-stations.js +12 -0
- package/dist/core/runner/prepare-policy-chain.js +7 -6
- package/dist/core/runner/prepare-task.js +1 -1
- package/dist/core/runner/prepare-turn-wiring.js +1 -1
- package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -1
- package/dist/core/runner/prepare-wiring-manifest.js +8 -1
- package/dist/core/runner/runtask.d.ts +33 -32
- package/dist/core/runner/runtask.js +63 -33
- package/dist/core/runner-deps.d.ts +9 -2
- package/dist/core/shell-lexer.d.ts +18 -0
- package/dist/core/shell-lexer.js +17 -10
- package/dist/core/shell-wrapper-table.js +8 -5
- package/dist/core/swappable-deps.d.ts +90 -0
- package/dist/core/swappable-deps.js +55 -0
- package/dist/core/tool-policy.d.ts +30 -1
- package/dist/core/tool-policy.js +6 -2
- package/dist/core/tools.d.ts +28 -7
- package/dist/core/tools.js +44 -4
- package/dist/core/trace.d.ts +15 -0
- package/dist/core/wiring-manifest.d.ts +15 -1
- package/dist/core/wiring-manifest.js +10 -2
- package/dist/core/workflow-journal-store.d.ts +21 -2
- package/dist/core/workflow-journal-store.js +1 -1
- package/dist/engine/execution-env/node-execution-env.d.ts +2 -0
- package/dist/engine/execution-env/node-execution-env.js +2 -1
- package/dist/engine/harness/agent-harness.d.ts +3 -1
- package/dist/engine/harness/agent-harness.js +1 -1
- package/dist/engine/harness/types.d.ts +15 -2
- package/dist/index.d.ts +8 -4
- package/dist/index.js +7 -3
- package/dist/orchestration/run-workflow-tool.d.ts +22 -2
- package/dist/orchestration/run-workflow-tool.js +14 -1
- package/dist/orchestration/workflow-governance.d.ts +3 -2
- package/dist/orchestration/workflow-observe.d.ts +1 -1
- package/dist/orchestration/workflow-observe.js +2 -0
- package/dist/orchestration/workflow-primitives.d.ts +4 -1
- package/dist/orchestration/workflow-primitives.js +1 -6
- package/dist/orchestration/workflow-types.d.ts +37 -2
- package/dist/orchestration/workflow-types.js +16 -0
- package/dist/orchestration/workflow.d.ts +53 -6
- package/dist/orchestration/workflow.js +319 -51
- package/dist/prompt-assembly/turn-snapshot.d.ts +4 -2
- package/dist/stores/file/workflow-journal-store.js +10 -3
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +85 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,107 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.11.0 — 2026-09-09
|
|
4
|
+
|
|
5
|
+
### Fixes — the auto-mode classifier reads its verdict the way CC 2.1.250 does, and re-asks once when a capped reply came back empty (#661 ① ⑤; B-056; @cli 1.0.106 @test @server)
|
|
6
|
+
- **The field failure.** A local gateway model under auto mode answered every ask with "好的。`<block>no</block>`\n(理由…)" — or, when its thinking could not be switched off, with nothing at all — and the engine logged `parse_error` three times, opened the session breaker, and sent every later ask to a person.
|
|
7
|
+
- **① The verdict reader is the CC form (`wCe` over `xCe`-stripped text).** `parseAutoModeResponse` collects the `<block>yes|no` hits (word-boundary form, closing tag optional, ANY position); zero hits ⇒ `parse_error`; two distinct answers in one reply ⇒ `parse_error` (the RAW text — thinking included — is read for the contradiction first, so a scratch-space "yes" against a visible "no" is refused, never allowed); otherwise `no` = allow, `yes` = block. The previous reader demanded `^<block>` and a bare `<block>no</block>` with nothing after it ("declared stricter than CC"); that declaration is gone. Category stays the free-text rule NAME the prompt asks for; reason is the first `<reason>…</reason>` pair on the stripped text. Behavior moves: `<block>no</block> extra prose` ⇒ allow (was parse_error); a preamble before the tag ⇒ the verdict it carries (was parse_error); `<block>yes</block><block>no</block>` ⇒ parse_error (was block); `<block> yes </block>` — whitespace inside the tag — ⇒ parse_error (was block).
|
|
8
|
+
- **⑤ The empty-at-cap re-ask (CC `TCe`/`cEt` + the `E4`/`BHe` allowance).** The verdict-sized cap (`AUTO_MODE_CLASSIFIER_MAX_TOKENS`, 256) applies where the wire can say off, and a catalog entry with no `reasoning`/`compat` reads as "off is expressible" — so a model that reasons regardless spent the cap on thinking and answered nothing. That reply (text `""` ∧ stopReason `length`, or `error` with `errorKind:"length_empty"`) is now re-asked ONCE at 256 + `AUTO_MODE_CLASSIFIER_THINKING_ALLOWANCE_TOKENS` (2048, CC's allowance for a model that rejects disabled thinking), thinking still off. A second empty reply, a non-empty reply with no verdict, an empty reply with a clean stop, and an uncapped seat are not re-asked. The re-ask is inside the classify leg: the decider still sees ONE decision per ask, and no breaker-cause word was added. CC re-asks such a reply up to `maxRetries` = 4 times at the same cap; one re-ask at the widened cap is the subset the engine spends. Known residual: a model that reasons IN its visible text and is cut at the cap (non-empty, no verdict) stays a `parse_error` — #621's compat-level declaration is the fix for that shape.
|
|
9
|
+
- `@cli` 1.0.106: pick up this version — an auto-mode session on a gateway model that prefixes or suffixes its verdict keeps classifying instead of falling back to the person after three asks; a model that cannot switch thinking off keeps classifying when its thinking FITS the widened cap (256 + 2048) — one that exhausts that too still fails the contract once per ask and trips the breaker after three (the ② breaker is out of this change's scope); the card's "why am I being asked" rendering is the shell's half. `@test`: a stub gateway answering three prose-wrapped verdicts (`好的。<block>no</block>\n(理由…)`) — pre-fix `parse_error` ×3 ⇒ `auto_mode_breaker_open`; post-fix allow ×3, no trip; a stub answering empty text with `finish_reason:"length"` then `<block>no</block>` — post-fix one allow, two requests, the second at `max_tokens` 2304 with thinking off; pure prose with no `<block>` stays `parse_error` (negative control). `@server`: zero surface (no wire key, no closed-set change; `AUTO_MODE_CLASSIFIER_THINKING_ALLOWANCE_TOKENS` is module-internal).
|
|
10
|
+
- Pins: `test/backlog661-auto-mode-parse-cc-form.test.ts` (positive controls, contradiction arms, the pure-prose negative control, the gateway-shaped reply through the real decider and the Runner's gate); `test/backlog661-classifier-cap-retry.test.ts` (both empty-at-cap forms ⇒ one re-ask at 256+2048 with reasoning off ⇒ allow; both empty ⇒ two requests, one parse_error; three negative controls). Re-pinned in `test/auto-mode.test.ts` and `test/backlog618-classifier-request-shape.test.ts` (the contract-failure stimulus is now a verdict-free reply).
|
|
11
|
+
|
|
12
|
+
### Fixes — an UNDECLARED thinking level no longer becomes an explicit off: the provider's default reasoning is back (#659; B-054; @cli 1.0.106 @server @test)
|
|
13
|
+
- **The defect.** The harness seat folded "nothing declared" into `"off"` (`thinking ?? "off"` in prepare-task, and the harness's own `?? "off"`). Harmless while off and unset were the same wire bytes; since 7.6.2 an explicit off is SPELLED on the wire (anthropic/deepseek `thinking:{type:"disabled"}`, qwen/zai `enable_thinking:false`, openrouter/together `reasoning:{enabled:false}`, the responses lane's declared off tier), so every main run without an effort setting told a default-on model to stop reasoning (field: deepseek-v4-flash on the Anthropic-compatible endpoint, `reasoning_delta` 12421 frames → 0).
|
|
14
|
+
- **The rule — two intents, two values, one seat.** `undefined` (spec/role/model declared nothing) rides UNCHANGED to the brain and writes NO thinking key — provider / model-tier default, the CC form when no effort is set; `"off"` is a caller's word and keeps the disable key (#618: the auto-mode classifier's explicit off, a definition's off, a caller's off are unchanged). The brains are untouched. `model.defaultThinking` still applies when the spec is silent (design/111 chain).
|
|
15
|
+
- **Readers of the harness level.** `AgentHarness.getThinkingLevel()`, the turn state, the systemPrompt callback's `thinkingLevel` and `thinking_level_select.previousLevel` now read `ThinkingLevel | undefined` (engine-internal faces; the public `ToolExecuteContext.thinkingLevel` was already optional). The child thinking seat's parent rung reads undefined ⇒ a child of an undeclared parent inherits UNSET (no key either; #651/#655 pins re-pinned — they had named the fold "the harness contract"). The compaction request already read the resolved level (unset there was never off). The turn snapshot's request-policy identity carries `null` for undeclared, so unset and off are different identities (they are different requests). Unchanged and out of scope: the persisted transcript's `SessionContext.thinkingLevel` still derives `"off"` when no `thinking_level_change` entry was ever written (a transcript face `@server` audit reads as a string; never fed back into a request).
|
|
16
|
+
- `@cli` 1.0.106: pick up this version and the no-`/effort` session reasons at the provider's default again — no shell-side default compensation. `@server`: zero surface. `@test` live criterion: deepseek-v4-flash via the Anthropic-compatible endpoint, a long-reasoning objective with no effort ⇒ `reasoning_delta` > 0; `thinking:"off"` ⇒ 0 (negative control) — `test/live-thinking-default.test.ts` is in the live gate's default surface.
|
|
17
|
+
- **Declared identity move (workflow resume journals; @server).** A workflow call's identity hashes the host's entry-time thinking snapshot when the authored spec left `thinking` unset; on a host that declared no level that snapshot was the fold's `"off"` and is now absent, so a journal written by an earlier release for such a host misses on its agent entries and the suffix RE-RUNS live instead of replaying — the same declared form as the design/164 spec-shape move (`workflowAgentCallKey`): the recorded results belong to child runs that were launched with reasoning explicitly disabled, a request this engine no longer produces for that spec, so a replay would be the stale-result bug the identity exists to prevent. A host with a declared level, or a script/definition that sets `thinking`, keeps its keys.
|
|
18
|
+
- Pins: `test/backlog659-thinking-unset-provider-default.test.ts` (four wire lanes × {unset, off, high} through the real Runner; the seat, the tool-context read, the harness contract, the child seat, the fork arm, the snapshot identity, the workflow call identity).
|
|
19
|
+
- **A fork runs as the caller on the thinking axis too (#660, found by the review of this fix).** The delegation station's fork arm used to hand the caller's level to the child seat's EXPLICIT slot; with an undeclared caller that slot is empty and the seat fell through to the deployment's stated `roles.subagent.thinking` — a stated `off` made the fork send the disable key while its caller reasoned at the provider default, a stated `high` changed the tier. Unreachable before this fix (the caller was always an explicit off). Now the fork copies the caller's level verbatim — undeclared included — exactly as its model chain is the caller's own (no role identity, no stated rung); the non-fork arm still walks the seat (definition > stated > caller). Pinned: undeclared caller × stated {off, high} × fork ⇒ the child request carries no reasoning; declared caller `low` × fork ⇒ `low`. Known residual (the #658 family, not changed here): a fork's spec carries the caller's explicit model with no role identity, so when the caller declared nothing the role chain's `roles.default.thinking` preset still applies to the fork (a caller running under `modelRole: "subagent"` with a `roles.default.thinking` preset set) — tracked with #658.
|
|
20
|
+
|
|
21
|
+
### Gates — `gate:closed-sets` mint obligations gain an exemption instrument BY IDENTITY (#629 ruled (A)); the G8 floors catch up with the census
|
|
22
|
+
- **The ruling.** The producer leg's discriminator stays "the code word decides a site, not the class" — full coverage of the mint face (subclasses, aliases, `ns[K]`, assertions) outranks a narrower discriminator's blind spots. Its one stated price — a construction that carries an obligated code as DATA (`new RegExp("checkpoint.unsupported_version")`) is judged as a mint — is answered the way this repo's other gates answer it: an exemption **by identity**, never a count baseline. New section `## Mint exemptions — by identity` in `docs/CLOSED-SETS.md`: `file` + `symbol` (the WHOLE lexical declaration chain enclosing the construction, dot-joined: `f`, `Class.method`, `f.value`, `OBJ.member`, `<module>` — so two functions' same-named locals are two identities; a review reproduced one row covering an unrelated mint under a nearest-name reading) + `code`; a row must match EXACTLY ONE construction — anonymous scopes (arrow arguments, IIFEs) add no chain segment, so a row matching two is refused as ambiguous instead of covering a construction its reason never argued for; hits are DISTINCT constructions (file + node start), so two obligations sharing one code word do not turn one exempted data-use into a false ambiguity — with a `reason` (≥ 20 characters), an `owner` and an `expires` date. An exempted construction is neither judged nor counted toward the population floor; a row that matches no construction reds (rot), an expired row reds, a malformed row is refused at load, and a document without the table is refused (the instrument stays declared even while empty — it IS empty on this tree).
|
|
23
|
+
- `scripts/verify-closed-sets.mjs`: `loadMintExemptions(root, today)`, `enclosingSymbolOf(node)`, every mint site now carries its `symbol`, `closedSetProblems(registry, root, obligations, exemptions)` (fourth parameter, default none), census `exemptions`. `KNOWN_ROWS` 10 → 18 and `KNOWN_SWITCHES` 3 → 5: re-measured — eight registered rows and two classified switches had joined the census without the floor following (the drift the #627 note already described once).
|
|
24
|
+
- Pins: `test/closed-sets-gate.test.ts` — a data-use reds nameless without a row and is clean (uncounted) with one; identity not file (the same code one symbol over still reds; a row naming a real mint exempts that mint by design and the floor then names the vanished site); rot and expiry red; the symbol forms; the real registry's empty table loads and a document without it is refused.
|
|
25
|
+
|
|
26
|
+
### Narrowing — a `defineTool()` PRODUCT on `TaskSpec.tools` runs with the run's trusted ctx, as a raw ToolSpec does (#656; @server manual `createRunWorkflowTool` mounts @test)
|
|
27
|
+
- **One mount law.** Every caller tool on `spec.tools` receives the run's ctx enrichment (`reportUsage`, `model`, `principal`, `onAsk`, `durableApprovalForChildren`, `inheritedGateForChildren`, `forwardEvent`, the tool-face / read-face / clamp seats, …). A raw ToolSpec always did; a product of the public `defineTool` factory was mounted AS-IS with the factory's minimal `{toolCallId, signal}` ctx because the AgentTool `execute(toolCallId, rawParams, signal, onUpdate)` contract has no ctx parameter to inject into. The product now carries a REBIND seat beside its brand (stamped together at the one stamp site, non-enumerable like the brand): the mount rebuilds the product's `execute` over its own spec with the run's ctx builder composed in (the run's seats first, a builder the product was built with refining them, the per-call identity re-stamped after both) — a fresh object per mount with the product's face, never a mutation of the product. Rules: 2 → 1 (the "product ⇒ as-is, unenriched" arm is gone; there is no enrich-by-identity table and no "cannot enrich" notice, because a branded object without the seat cannot be constructed).
|
|
28
|
+
- **The field shape (test [6744] G5):** a deployment mounting `createRunWorkflowTool(...)` by hand on `spec.tools` (instead of the `selfOrchestration` switch) had its Workflow tool read `ctx.onAsk ?? d.parentOnAsk` with BOTH absent — every workflow child's ask was denied fail-closed while the host's approver was never consulted, zero report. Now `ctx.onAsk`, `ctx.durableApprovalForChildren`, `ctx.inheritedGateForChildren`, `ctx.forwardEvent`, `ctx.autoModeReview`, the clamps and the rest reach the hand-mounted tool exactly as the switch's mount threads them through deps. `@server`: a deployment that mounts the Workflow (or any defineTool product) by hand and ALSO threads `parent*` deps sees the ctx seat win (the tool's documented `ctx.X ?? d.parentX` precedence); a deployment relying on the product being blind to the run (no known case) should author it as a raw ToolSpec that ignores its ctx. `@test`: hand-mount `createRunWorkflowTool` with an ask-gated child tool and a host `onAsk` returning `true` ⇒ the host approver is called and the child's tool runs (it used to be denied with "no reachable operator"); a defineTool product's `execute` sees `reportUsage` / `principal` / `onAsk` / `durableApprovalForChildren`.
|
|
29
|
+
- Narrowing half: a hand-mounted delegation product now inherits the host's tighten-only seats (`inheritedGateForChildren`, `handsReadOnly`, `interactiveTools:false`, read-face roots, read-deny additions) into its children — a subtree that used to escape the parent's gate through a hand mount no longer does.
|
|
30
|
+
- `teacher`'s logging wrapper (the one other legitimate brand stamper) forwards the seat: a mount rebinding the wrapper gets the same wrapper over the rebound product. Internal only — `rebindDefineToolCtx` / `stampDefineToolBrand` are not on the export face.
|
|
31
|
+
- Two review findings folded in: (a) the per-call identity is now written over the enriched ctx as own properties instead of a spread — the spread evaluated the Runner enricher's LIVE getters (the memory-capture floor seats) once at call start and handed every `enrichCtx` mount (the first-party SendMessage mount included) a frozen snapshot; a long-running Workflow call now sees a mid-call capture flip for the children it spawns later. (b) A product whose `execute` was replaced IN PLACE after the factory (a guard written over the property while the brand stayed) is refused at the mount with a message naming the supported wrapper form — rebuilding it from the spec would have run the original body and silently dropped the guard. Round 2 closed the class: the seal is applied by the stamp site itself (every stamper gets it), it compares the MOUNTED object to the stamped one, and the brand is an OWN-property read — a prototype child, a transparent Proxy or a descriptor copy carrying a borrowed brand is refused, never rebound to the original body; and the per-call ctx is a FRESH object carrying the enriched object's descriptors (getters kept), so an enricher handing back one shared or frozen object across calls never leaks call A's identity into call B. Round 3: the seal reads `execute` as an own DATA descriptor (an accessor answering the original "while allowed" is refused), the rebound copy is built from descriptors rather than a spread (no accessor runs during the rebind) and the seal is re-checked after the rebuild — the wrapper-bypass root ran three review rounds (in-place / borrowed brand / accessor + re-entrancy) and is closed at the descriptor level; a further variant would have to defeat `Object.getOwnPropertyDescriptor` itself.
|
|
32
|
+
- Pins: `test/backlog656-caller-mounted-product-ctx.test.ts` (generic product ctx; hand-mounted Workflow ⇒ host approver consulted, gated tool runs; the seat as a unit — composition order, identity re-stamp, re-rebind, spread loses it); RB-362 pins re-pinned (two symbols; the mount hands the wrapper a fresh rebound product).
|
|
33
|
+
|
|
34
|
+
### Fixes — an EXPLICIT-model child carries the subagent role identity; the fork is the caller continued (#658; @test @server)
|
|
35
|
+
- **One seat law, two arms.** `childModelSeat` now returns `modelRole: "subagent"` on EVERY engine-spawned seat — the explicit-model arm too (per-call / definition / tool-level `model`); the arms differ only in whether a model rides the seat (explicit or inherited) or none does (a stated `roles.subagent` tier resolves through the role chain). `resolveTaskModel`'s explicit branch keys the `systemPrompt` / `thinking` presets on `modelRole`, so a child spawned with an explicit model used to read `roles.default`'s presets — the deployment's MAIN persona and the default role's thinking — while its sibling with no explicit model read `roles.subagent`; the workflow station (which writes the stated presets onto the spec since #655) disagreed with the delegation station on the same input. The workflow launch site (`withWorkflowChildSeats`) writes the identity on an explicit-model spec as well (a trusted script's own `modelRole` is kept; the call identity is computed before the seats fold, so resume journals keep their keys).
|
|
36
|
+
- **The fork carries none** (`forkModelSeat`): a fork runs on the caller's model with no role identity — its system prompt is meant to be the parent's, so the presets it reads are the `default` role's, as the caller's own explicit-model run does; a deployment's stated subagent persona never reaches a fork. With no caller model readable (only outside a Runner-enriched ctx) the fork's seat is empty — the `default` role chain — where it used to fall to the subagent role.
|
|
37
|
+
- Observable: `@test` a delegation with `createSubagentTool({ model })` or an agent definition carrying `model` under `roles.subagent: { systemPrompt, thinking }` — the child's system prompt carries the stated preset and its thinking is the stated level (it used to be the default persona / the default role's level); a `subagent_type: "fork"` child under the same roles carries NEITHER. `@server`: no wire change; deployments that state a `roles.subagent.systemPrompt` see it applied to explicit-model children they used to see under the main persona.
|
|
38
|
+
- Pins: `test/backlog658-explicit-model-role-identity.test.ts` (delegation × {tool-level, definition} × {stated, unstated}, fork, workflow parity, the seat as data); seat-shape pins in `backlog641` / `backlog651` / `backlog655` re-pinned to the constant identity.
|
|
39
|
+
|
|
40
|
+
### Narrowing — a provably READ-ONLY shell command runs without a question: upstream's read-only tables, transcribed (#619; @cli @server @client-core @test)
|
|
41
|
+
- **The station** (`gate-lanes.ts`, contract `read_only_shell.every_segment`): the allow layer gains a SECOND member beside the persisted allow rule — for the shell tool (`Bash`), after every tightening lane (org, persisted deny/ask rows, policy deny/ask rules) and after the person's own allow rules, a surviving ask whose FINAL command `readOnlyShellVerdict` proves read-only becomes an allow (`decisionReason: "read_only"`, `DECISION_REASONS` +1) — no classifier round, no approver, no park. Same consumption predicate as a stored allow rule (`allowLayerMayClear`, one predicate for both members): never over a mandated ask (egress / the tool's own irreversibility / `shellGate:"always"`), a hook's ask, an explicit ask rule (`matchedAskRule`, either lane), governance (`requiresRealApproval`), the reserved question tool or a marked call. Anchored on CC 2.1.250's shell tool `isReadOnly` (`v2e`: parse, split into simple commands, EVERY one must be read-only) and its position in `F2e` (deny → ask → allow rules → read-only → "requires approval").
|
|
42
|
+
- **The reader** (`src/core/read-only-shell.ts`) runs over the tightening lexer (`readShellCommand`, which now also reports each segment's `redirections`, the line's `backgrounded` bit and its `strayRedirection` bit — a redirection on a piece that runs no program, `true; >x`, which the shell still opens; additive on `ShellSegment` / `ShellCommandShape`; a hand-built shape literal needs the three members): a line is read-only iff it reads whole (no unreadable segment, no group/subshell/control structure, no background `&`, no stray redirection), no word carries a `$…`/`` `…` ``/`$((…))`/process substitution (anywhere in the word), and every segment is: redirections only read or discard (`<` family, `/dev/null`, `N>&M`), leading assignments only from `READ_ONLY_ENV_NAMES` with literal values, and the program admitted by one of three readings in order — a GLOB segment only under `READ_ONLY_GLOB_PROGRAMS` (upstream `B3t`, 31); the structural forms (`READ_ONLY_BARE_PROGRAMS` = upstream `jpe` 48, the exact forms, `echo`/`ls`/`cd`/`find`/`printf`/`history`/`arch`/`ifconfig`); the flag-vetted `READ_ONLY_COMMAND_TABLE` (upstream `w3t` and its spreads: 24 git, 22 gh, 2 docker, rg/grep/egrep/fgrep, fd/fdfind, sort, file, tree, date, hostname, lsof, pgrep, tput, ss, ps, netstat, man, help, base64, xargs, pyright, test, 3 checksums — 74 rows; upstream carries 75 with `sed`) with each row's `additionalCommandIsDangerousCallback` transcribed. A `cd` beside a `git` in one line is never read-only (the `command`/`builtin`/`noglob` prefixes and leading assignments are peeled before the names are compared). Pipelines and `&&`/`;`/`||` lists of read-only segments ARE read-only (`git status | grep x`), as upstream. Deliberate narrowings vs upstream: a BRACE pattern (`-{delete,name}`) is read as a many-word glob (admitted only under the glob programs; upstream reads it as literal text); a pattern with a parenthesised part (an extended pattern, or under zsh a glob QUALIFIER `*(e:'cmd':)` that runs code) and the zsh-only `=cmd` / `~[…]` spellings are never read-only; `run_in_background: true` is the parameter spelling of `&` (the shell probe's ruled reading #125) — not read-only at the gate although the command text alone would be. Lexer fix beside it (tightening reader too): a test operand's EVERY array subscript is judged for a hidden run (`[[ 0 -eq 'a[0]+b[$(…)]' ]]` was read as literal — now `unreadable`).
|
|
43
|
+
- **An unanswered reversibility probe is a mandate.** The out-of-root read mandate is minted from the shell probe's answer alone, so a probe that times out or throws leaves the call's boundary UNKNOWN: the fold latches `GatePass.probeUnanswered`, `persistedRuleMandateOf` reads it as the new word `probe_unanswered`, and NEITHER allow-layer member (a stored allow rule, the read-only reader) clears the surviving ask — a person confirms it, and a matching rule is disclosed as shadowed with that sentence. (Before this version a hanging probe let a stored allow rule clear an out-of-root read.)
|
|
44
|
+
- **Stated gaps (fail-closed, never silent):** `sed` is NOT read-only here (upstream vets the sed SCRIPT with a dedicated analyzer this version does not carry — a follow-up); the Windows PowerShell table and every Windows-only arm are not transcribed; upstream's two filesystem probes beside the tables (bare-repository indicators, a `.git` redirection) have no seat here. The existing `bashReversibilityProbe` read-only classifier (`shellGate:"classify"`, `BASH_CLASSIFY_DEFAULT_ALLOW`) is untouched in this version — a sibling to unify onto this reader (follow-up ticket).
|
|
45
|
+
- **Wire (additive):** trace kind `permission.read_only_allowed` (`{ toolName, toolCallId, command }` — the allow-side attribution, twin of `permission.persisted_rule_allowed`); `ToolGateInput.onReadOnlyAllowed?` seat. `@cli`: default/auto mode — `ls -la`, `git status`, `cat f | head` run with zero cards and zero classifier rounds; render the trace as "allowed: read-only command"; `@server`: `decisionReason` closed set +1 word (`read_only`) on the allow face, trace whitelist +1 kind; `@client-core`: `DecisionReason` mirror +1; `@test` (G): `ls -la` / `git status` / `cat f` ⇒ zero cards, zero classifier rounds, one `permission.read_only_allowed` trace; `cat $F` / `find . -delete` / `ls > out.txt` / `rm -rf x` ⇒ the ordinary ask (classifier in auto, card in default); `deny:["Bash(cat secret.txt)"]` ⇒ still refused; `ask:["Bash(git status)"]` ⇒ still a card; `shellGate:"always"` ⇒ still a card; `git status | grep x` ⇒ zero cards.
|
|
46
|
+
- Exports +17 (`readOnlyShellVerdict`, the twelve table constants, `ReadOnlyShellVerdict`, `ReadOnlyFlagArity`, `ReadOnlyCommandRow`, `ShellRedirection`; export-surface snapshot regenerated, all additive). CLOSED-SETS +1 (`ReadOnlyFlagArity` with its `FLAG_VALUE_ACCEPTS` fence). Pins: `test/backlog619-read-only-shell.test.ts` (the reader), `test/backlog619-read-only-gate.test.ts` (the station).
|
|
47
|
+
|
|
48
|
+
### Narrowing — a `/**`-tailed bare path rule (`secrets/**`) reads at ANY depth for deny/ask, anchored for allow: upstream's peel kept verbatim (#657; @cli @server @test)
|
|
49
|
+
- **The rule** (`resolvePathPattern`, contract `path_rule.bare_name` extended): a trailing `/**` is peeled BEFORE the bare-name test — a cwd-relative deny/ask body that is a bare name plus the tail (`Edit(secrets/**)`, `Write(*.d/**)`, `./secrets/**`) names every such directory at any depth under the working directory (`<cwd>/**/secrets`), exactly as #654 already read the untailed bare name. A body that still carries a `/` after the peel (`a/b/**`, `dist/**/x`) stays anchored; the explicit `**/x/**` spelling, the home/root/absolute forms and `ruleBasesNeeded` are unchanged. Anchored on CC 2.1.250's `ki(pattern, isAllow)`: the tail comes off, and the bare remainder is fed to the gitignore engine as-is for deny/ask (any depth) and as `/name` for allow (anchored).
|
|
50
|
+
- **The ONE behavior asymmetry** — upstream's own shape, kept rather than levelled (ruled): for the peeled form ALONE an ALLOW rule is anchored at cwd. `pathRuleReachOf` now takes the rule's `behavior` off the rule it is handed (`Pick<PersistedRule, "match" | "command" | "behavior">` — every caller already passes a parsed rule; a hand-built rule object must now carry `behavior`). The allow arm has no rule form on any lane (parse `invalid.grammar` / `unsupported.tool`, compile `unsupported.file_glob`) — written for parity, pinned unreachable at both doors and pinned in shape at the function seam.
|
|
51
|
+
- **Narrowing half (deny/ask reach WIDENS = stricter):** a migrated `deny: ["Edit(secrets/**)"]` now also refuses `a/secrets/x`; a persisted `Read(secrets/**)` deny/ask row reaches every `secrets` directory under cwd. One reader serves the persisted lane, the content-rule lane and its compile step. `@cli`: the `/permissions` breadth wording for `name/**` deny/ask rules is "every `name` directory under the working directory"; `@server`: task-settings content rules ride the same lane, zero code change; `@test`: `deny:["Edit(secrets/**)"]` ⇒ `<cwd>/a/secrets/x` and `<cwd>/secrets/x` refused, `ask` same reach, `Edit(a/b/**)` still only `<cwd>/a/b`, `**/secrets/**` unchanged.
|
|
52
|
+
- **Widening half — EMPTY** (no allow gets wider: the allow arm cannot be constructed). Pins: `test/backlog657-tailed-bare-path-rule-depth.test.ts`; the #654 file's anchored example moved from `dist/**` to `dist/**/x`.
|
|
53
|
+
|
|
54
|
+
### Narrowing — a cwd-relative BARE path rule reads at ANY depth, the upstream file-rule (gitignore) anchoring (#654; @cli @server @test)
|
|
55
|
+
- **The rule** (`resolvePathPattern`, contract `path_rule.bare_name`): a cwd-relative pattern body with NO `/` — `Edit(.env)`, `Read(secret.txt)`, `Read(*.env)` — names that segment at any depth under the working directory (`<cwd>/**/<body>`); a body carrying a `/` (`dist/**`, `a/b`) stays anchored at cwd; `./x` is the same pattern as `x` (the `./` marker only spells the cwd word); the `~/x` / `/x` / `//x` forms are unchanged (anchored at home / root / absolute). Anchored on CC 2.1.250's matcher: the base-relative pattern is handed to a gitignore engine, so a slash-less pattern matches at every depth. `ruleBasesNeeded` is unchanged — a bare name still names `cwd` (the root stands in when cwd is absent).
|
|
56
|
+
- **Narrowing half (deny/ask get WIDER reach = stricter):** a migrated `deny: ["Edit(.env)"]` now refuses `nested/.env` too; a persisted `Read(secret.txt)` deny/ask row reaches every `secret.txt` under the working directory. ONE reader (`pathRuleReachOf`) serves the persisted store lane, the content-rule policy lane and its compile step, so all move together. `@cli`: the `/permissions` editor's breadth wording for slash-less path rules can drop the root-only caveat; `@server`: task-settings content rules ride the same lane, zero code change; `@test`: black-box `deny:["Edit(.env)"]` ⇒ `nested/.env` refused, `Read(*.env)` ask reaches `a/b/prod.env`, `Write(dist/**/x)` still only `<cwd>/dist/…/x` (the `dist/**` tail form is #657's), `./x` ≡ `x`.
|
|
57
|
+
- **Widening half — EMPTY:** an allow in the pattern form has no rule form on any lane (persisted parse `invalid.grammar` / `unsupported.tool`, policy compile `unsupported.file_glob`; only the `Read(//abs/**)` directory form is a standing approval), so no allow gets wider — pinned.
|
|
58
|
+
- The `/**`-tailed form (`secrets/**`) is the #657 entry below — the residual this entry once carried is closed there.
|
|
59
|
+
|
|
60
|
+
### Fixes — the child thinking seat's DEFINITION rung on the observer and workflow stations; the workflow child gets its role identity and the stated subagent persona (#655; B-053 residue; @test @server)
|
|
61
|
+
- **Observer sidecar:** `childThinkingSeat(observerDef.thinking, stated, parent)` — the watcher definition's own `thinking` is the explicit rung (it was hard-coded absent, so a definition's level never reached the observer).
|
|
62
|
+
- **Workflow lane — definition folds BEFORE inheritance fills.** The host level used to be written into the child spec inside `buildWorkflowPrimitives`, before `applyWorkflowAgentType` folded the agentType definition, so the fold's "when unset" guard was always false and a definition's `thinking` never reached the child. The fill is gone from the primitives; the host getter rides `RunWorkflowOptions.defaultThinking` (the `defaultModel` twin — snapshotted once at call entry whenever the authored spec left `thinking` unset and folded into the call identity exactly as the pre-fold primitives composed it, so resume journals written by earlier versions keep their keys; a definition's level applies at launch without entering the identity, as the model axis already does) and every child seat is applied at the launch site AFTER the fold by `withWorkflowChildSeats`. `buildWorkflowPrimitives` loses its `parentThinking` positional (internal helper, not on the export face). `RunWorkflowToolDeps.parentThinking` keeps its name and meaning.
|
|
63
|
+
- **Workflow child role identity:** the inherited-model arm now carries `modelRole: "subagent"` beside the host's Model (bare `{ modelRole: "subagent" }` when no host model is readable; an explicit model keeps the explicit shape; a trusted script's own `modelRole` is kept) — the same seat shape as the delegation and observer stations, so `resolveTaskModel` keys the SUBAGENT role's presets for a workflow child (a stated `roles.subagent.thinking` reaches it through the role chain as well as the written level).
|
|
64
|
+
- **Workflow child persona:** the deployment's STATED `roles.subagent.systemPrompt` (new reader `deploymentSubagentSystemPrompt`, the thinking reader's twin on the same seat) reaches a workflow child that named no persona — written before the persona frame, so the frame appends the return-contract NOTE to it as to any custom persona instead of replacing it with the default workflow-subagent persona. An agentType/script persona still outranks it; with nothing stated the default persona is byte-identical to before.
|
|
65
|
+
- Pins: `test/backlog655-child-thinking-seat-definition-layer.test.ts` — definition × {sync, background, observer, workflow} × {stated, unstated} × {parent none, parent low} = 32 cells through real Runners, plus the persona and seat-shape pins.
|
|
66
|
+
|
|
67
|
+
## 7.10.0 — 2026-09-09
|
|
68
|
+
|
|
69
|
+
### BREAKING — `Runner.swapModels` is retired into ONE hot-swap door, `Runner.swapDeps` (#628 + #616 seat door; @server S-174 @cli @client-core)
|
|
70
|
+
- **One door, a closed seat set.** `swapDeps(next)` hot-swaps any subset of `SWAPPABLE_DEP_SEATS = models | tiers | readFace` (new leaf `src/core/swappable-deps.ts`). The laws are stated once for every seat: the candidate is SCREENED whole before anything is written (`screenSwappableDeps`: the key set is the closed one, at least one seat, `tiers` rides with `models`, each seat's own value gate), the deps object is written ONCE (atomic across seats — a refused half leaves the other half unwritten), in-flight legs keep the snapshot they prepared under (the natural-snapshot sentence the catalog already had, now the rule for the whole set), and each seat is announced under its row in `SWAP_SEAT_NOTICE` (registered in CLOSED-SETS with its fence). Presence is the instruction: a present key is replaced (an explicit `undefined` clears an optional seat), an absent key is kept; an unknown key, an empty candidate and a non-object are refused loudly, never ignored.
|
|
71
|
+
- **Migration:** `runner.swapModels({ models, tiers })` → `runner.swapDeps({ models, tiers })` — same semantics, same `config.models_swapped` line, same base-URL advisory. No alias is kept. `@server` main.ts's one call site is the only known consumer.
|
|
72
|
+
- **The read-face seat is live (#628; @server S-174).** `swapDeps({ readFace: "open" | "roots" | undefined })` replaces `RunnerDeps.readFace` without a restart, through the very `assertReadFaceValue` gate every prepare door applies (same seat name, same message); a leg that already prepared keeps its face, every later prepare resolves against the new declaration. Announced as the new notice `config.read_face_swapped` (audience operator; `detail: { from, to, occurrenceId }`, `null` = no declaration; the SEAT is announced, not a leg's resolved face). `@server`: the `read-face` restart signal and the `readFaceEnvHeld` pipeline can be deleted — swap the late-arriving declaration in. Consumers: `ENGINE_NOTICE_CODES` +1 (a downstream audience mirror gains one row; server's `engine_notice` whitelist +1).
|
|
73
|
+
- Exports +6: `SWAPPABLE_DEP_SEATS`, `SWAP_SEAT_NOTICE`, `isSwappableDepSeat`, `screenSwappableDeps`, `SwappableDepSeat`, `SwappableDeps`.
|
|
74
|
+
|
|
75
|
+
### Wire (additive) — the auto-mode classifier's unavailability: the per-ask fact and the per-session breaker read face (#616; @cli L-147 @server S-165 @client-core @web-admin @test)
|
|
76
|
+
- **CC 2.1.250 anchor.** An unavailable classifier is a BLOCK with `denialKind: "automode-unavailable"` there — the fact rides the outcome's denial-KIND axis beside provenance, never the origin vocabulary. This engine keeps its ruled divergence (the ask reaches a person instead of being denied), so the fact stays a FACT beside the origin word: `ASK_ORIGINS` is untouched (no `classifier_unavailable` origin; `client-core` `ASK_ORIGINS` needs no mirror change).
|
|
77
|
+
- **① the per-ask fact.** `AskRequest.classifierUnavailable?: { cause }` and the park row's twin `PendingAction.tool_approval.classifierUnavailable?` (echoed on the checkpoint summary) — present ⇔ the classifier was consulted on this ask and could not run; `cause` is the verdict's own word from the closed set `AUTO_MODE_UNAVAILABLE_CAUSES = error | timeout | breaker_open` (the requested `route_unresolved` is NOT a member: no classify-time site mints it — a broken derived route falls back at prepare, before any decide). Stamped at the gate's classifier station and the three inherited-lane stations on an `unavailable` verdict only; `parse_error` stamps nothing (the classifier ran and answered outside its contract — a different sentence). Origin, `requiresRealApproval` and every other member are byte-unchanged. `@cli`: render "asked because the classifier was unavailable (<cause>)" off this member; a card with the member absent is an ordinary ask.
|
|
78
|
+
- **② the session breaker read face.** `WiringManifest.autoMode.breaker?: { openedAtMs, lastCause, failures, runId }` — the most recent one-way breaker trip recorded for the SESSION on this Runner (an EARLIER leg's decider tripped; a decider is minted per run, so a leg-start manifest never reports its own leg — there is no `open` boolean because it would read `false` on every mint; the reason vocabulary keeps `latch_open` reserved for a mid-leg re-read face). `lastCause` is from the closed set `AUTO_MODE_BREAKER_CAUSES = error | timeout | parse_error` (what trips the latch; `onBreakerOpen.lastCause` is now typed by it). Present only after a trip; EXCLUDED from `configFingerprint` (a per-session observation, like `mcp`). The engine records the trip BEFORE calling the deployment's `onBreakerOpen` hook, so a deployment that wires no alarm still gets the read face. `@server` S-165: project `autoMode.breaker` onto `wiring_manifest` / `capabilities.permissionModeAuto` — do not mint a second cause vocabulary.
|
|
79
|
+
- Exports +8: `AUTO_MODE_UNAVAILABLE_CAUSES`, `isAutoModeUnavailableCause`, `AUTO_MODE_BREAKER_CAUSES`, `isAutoModeBreakerCause`, `AutoModeBreakerLedger`, `AutoModeUnavailableCause`, `AutoModeBreakerCause`, `AutoModeBreakerTrip`; `RunInternals.autoModeBreakerLedger` (trusted channel, Runner-set). CLOSED-SETS +2 rows.
|
|
80
|
+
|
|
81
|
+
### Narrowing + BREAKING export — a `~/` path rule resolves against the EXECUTION ENVIRONMENT's home, never the engine process's (#644; @server S-184 @cli)
|
|
82
|
+
- **The rule.** The bases of a path pattern are a closed set, `PATH_RULE_BASES = cwd | root | home` (`PathRuleBases` keyed by it; `PATH_RULE_BASE_LABEL` names each). `home` is `ExecutionEnv.homeDir` — a NEW adapter-declared member (absolute path in the environment's own namespace); the local `NodeExecutionEnv` declares `os.homedir()` (it IS the host), so single-machine deployments are byte-identical. A pattern whose base the call cannot supply (absent, or not an absolute path) is now UNREADABLE — the persisted lane answers the fail-closed `rule_store_unavailable` ask (the reason names the base), the content-form lane its unreadable ask — never silence and never a guess against the engine host's home (the old `?? os.homedir()` fallback guarded the wrong directory on every remote/sandboxed leg). Zero rules that need the base ⇒ silence, as before. The same law now covers every base: a `/…` deny with no `root` used to reach nothing silently and is unreadable too; the `cwd` word's root stand-in (when no tracker moved) is the word's own meaning, unchanged.
|
|
83
|
+
- **Compile time too:** a content-form `~/` rule with no `pathBases.home` is refused as `unsupported.path_base` naming the base (the old `?? "/"` escape is gone).
|
|
84
|
+
- **The host's question.** `ruleBasesNeeded(rule): PathRuleBase[]` — which base a rule will need (`//abs` / subpath / command ⇒ none; `~/` ⇒ home; `/…` ⇒ root; relative ⇒ cwd), so a host can judge BEFORE prepare whether its environment can serve a rule. `createPermissionRuleLanes` takes the new required `home` seat; a declared `homeDir` that is not absolute is refused at prepare (`config.execution_env_home_dir_invalid`), never read as absent.
|
|
85
|
+
- **BREAKING export:** the boolean `pathRuleReaches` is REMOVED; `pathRuleReachOf(rule, target, bases): ProgramRunReachOutcome` (`reached` / `not_reached` / `unreadable` + reason) is the one path reach — a boolean invited a caller to read "cannot be judged" as "not reached". Exports −1 +5 (`pathRuleReachOf`, `ruleBasesNeeded`, `PATH_RULE_BASES`, `PATH_RULE_BASE_LABEL`, `PathRuleBase`). CLOSED-SETS +1 row. `@server` (remote-exec lane): declare `homeDir` on the remote ExecutionEnv adapter, or expect `~/` deny/ask rows to ask a person for those legs.
|
|
86
|
+
|
|
87
|
+
### Fixes — a child run's thinking level follows the same seat rule as its model (#651)
|
|
88
|
+
- `roles.subagent = { systemPrompt, thinking: "high" }` with a parent that declared no thinking ran every child with thinking OFF: the delegation station inherited the parent's RESOLVED run level (`"off"` — the harness contract) as if it were an explicit declaration, and `spec.thinking` outranks the role preset. The child THINKING seat is now the model seat's sister (`childThinkingSeat`, `deploymentSubagentThinking`): explicit for the spawn (the agent definition's `thinking`; a fork keeps the caller's level) > the deployment's stated `roles.subagent.thinking` > the parent run's effective level (a mid-run degrade/boost still carries) > nothing. Three stations read it: the delegation station, the observer sidecar, the workflow mount's `parentThinking` getter. Nothing stated anywhere is byte-identical to before (the child runs at the parent's level).
|
|
89
|
+
|
|
90
|
+
### Wire (additive) — the workflow lane joins the park family (#642 · B-049; @server S-185 @cli @client-core @web-admin)
|
|
91
|
+
- **A workflow child's ask PARKS instead of denying fail-closed.** The host's `durableApproval` opt-in now rides the Workflow mount beside `parentOnAsk` (`RunWorkflowToolDeps.parentDurableApproval`, minted from the same `spec.durableApproval` the delegation lanes read as `ctx.durableApprovalForChildren`) and is folded as `RunWorkflowOptions.defaultDurableApproval` into every spawned agent whose seat the deployment baseline left unpinned — both spawn lanes, the `authInherit` twin — **iff a `WorkflowRunStore` is wired** (the wa* row's durable home; `startWorkflow` refuses the pair without one, loudly; the tool never passes it storeless). With it, the workflow child whose inherited ask the host approver answers `unavailable` (or a durable mandate) parks exactly like a background delegation child under the §7.3 park family: a pending checkpoint whose `sourceTaskId` is the child session, the tool never runs, nobody is handed the deny sentence. Storeless deployments keep the previous lifecycle (the deny — now with the #648 notice).
|
|
92
|
+
- **Status word +1 (`WorkflowItemStatus`): `"parked"`** — an AGENT row's word only (phases/groups never park). **wa* row key +1: `parkedCheckpointToken`** (present iff `status:"parked"`; the mirror of `BackgroundAgentRecord.parkedCheckpointToken`) — the **workflow-origin join a host routes by**: a checkpoint whose `sourceTaskId` equals a `wa*` row's `sessionId` is a workflow child's park, and the row's token is its redemption key; **the checkpoint row itself gains no key** (the same one rule as the background join). `agent_end.status` may read `"parked"`; `deriveAgentDisplayStatus` gains `"parked"` (`AgentDisplayStatus` +1). The tool's `<usage>` block gains `agents_parked` when > 0. `@server`: the SQL `agents[]` JSON column takes the key with zero DDL; sdk `WorkflowItemStatus` +1 word +1 key. `@cli`: a `parked` wa* row renders as such (raw word until the dedicated card); the HITL path is the bg row's (`sourceTaskId` join).
|
|
93
|
+
- **The run SUSPENDS on a park (the team-discussion `teamPause` shape).** `ctx.agent()` / `ctx.agentStream().result()` throw `WorkflowAgentParkedError` (new export; code `workflow.agent_parked`, `WORKFLOW_AGENT_PARKED_ERROR_CODE`) — every later spawn is refused with the same error, and the park is the run's terminal whatever the script returns or throws (`status:"failed"`, `error` names the park, no `agentFailures` tally for a parked row). Previously the pause was mapped to a `failed` row (`unexpected.suspended`) and the script ran on past a pinned session and a checkpoint nobody could route — the stranded-suspend class. The completion notification's failed-path summary names the park ("workflow parked … do not re-issue the same call; the deployment resumes this run") instead of "workflow failed … re-invoke with resumeFromRunId".
|
|
94
|
+
- **Resume journal: a new entry arm, one-way.** `WorkflowJournalEntry` is now `{ callKey, result } | { callKey, parked }` — a parked ordinal is journaled under `parked` (the child's paused `TaskResult`: token, gate, pinned session, spend) with NO `result`, so a journal reader from before this release finds no terminal cause and REFUSES the resume (`WorkflowJournalIncompatibleError`) instead of admitting a paused cause it would re-run live beside the pinned child. **Type face:** `WorkflowJournalEntry` moves from `interface` to a discriminated `type`; `result` is optional on the union — a consumer reading `entry.result.terminal` must narrow (`entry.parked !== undefined` first). `@server`: the journal store's row schema must admit an entry with `parked` and no `result` (core's file store writes `{scope, ordinal, callKey, parked}` lines; the in-memory store is unchanged in shape).
|
|
95
|
+
- **A resume drives the parked child on.** `RunWorkflowOptions.parkedResume?: WorkflowParkedResume[]` (new export: `{ token, outcome, inheritedGate? }`; requires `resumeFromRunId`) — at a parked ordinal with a matching call key, a decision for its token makes the call `runner.resumeStream(token, outcome, <the script call's rebuilt spec>, …)` on the pinned session (single attempt; stall/throttle retries never fire for a drive) and settle the result at that ordinal, so the script continues from the parked call and the journal's ordinal becomes a completed result; **without a decision the ordinal RE-PARKS** (same token, no live run, the run suspends again). The ordinals after a driven one (a fan-out sibling that settled after the park) still replay — a drive is not a divergence. `RunInternals.workflowParkedResume?: Array<{ runId, token, outcome, inheritedGate? }>` (`workflowDepth`'s sibling, TRUSTED) is the per-run channel the Runner-mounted Workflow tool reads: a host that decided a workflow-origin park launches the host-session run that re-invokes `Workflow({resumeFromRunId})` with the decisions here, and the tool applies the ones naming the resumed run (`RunWorkflowToolDeps.parkedResume` getter). `@server S-185`: the decide route's third lane — route by the wa* join, then either the library `startWorkflow({resumeFromRunId, parkedResume})` or the run channel.
|
|
96
|
+
- **The resume chain never loses a park (two adversarial rounds, all findings adopted).** A parked ordinal is consulted outside the prefix-divergence latch; a parked ordinal whose CALL changed is refused (`workflow.parked_call_changed`, surfaced verbatim by the tool as a script fault) rather than respawned; a sibling ordinal parked in the prior journal re-parks even after the latch closed, and every prior-run park this run did not resolve is carried into its journal at the terminal, so a third-generation resume still finds it; a drive the resume ladder refuses pre-CAS re-parks on the prior token; a suspending run waits for its outstanding calls (counted from the record push) before its terminal, bounded by the run's cancel/deadline signal; a call queued behind a parked sibling under `concurrency:1` is refused, never launched. A park whose journal append fails past three attempts is cross-checked at resume against the prior run's row store (a `parked` row with no journal entry — or an unreadable store — refuses the resume).
|
|
97
|
+
- **Round 3 (adopted):** a driven leg whose terminal says the approved action never started (`resume.reopened_unstarted` / `resume.reopen_failed_unstarted`) re-parks — the checkpoint is not consumed work; a queued `agentStream` drive refused at the token ceiling re-parks (never a synthetic failed entry that would stop the carry); the carry-forward is bounded (the run's own signal; the claim-release cap per append); an oversize tombstone counts as this run's word at its ordinal.
|
|
98
|
+
- **Not in this slice (stated):** the workflow holds no checkpoint-store seat, so a re-park records what the journal says without probing the checkpoint (a park decided through the task-level `runner.resume` leg is reconciled by the host's join, not here); the row store is best-effort, so a STALE prior-run row beside a lost journal append is one documented residual, and a carry-forward whose appends all fail (the successor holds neither entry nor row for an unreached ordinal) is its second face — one design question (park truth from the checkpoint store rather than from best-effort records), left to a ruling. A parked wa* row emits no BCE `terminal` frame (the a* lane's park emits none either) — the row's state is the run record's.
|
|
99
|
+
|
|
100
|
+
### Wire (additive) — `delegation.ask_unresolvable`: the fail-closed unavailable deny gets its person-facing half (#648; @server @cli @client-core)
|
|
101
|
+
- **Closed set +1.** `ENGINE_NOTICE_CODES` gains `delegation.ask_unresolvable` (`NOTICE_AUDIENCE` row **`user`**): an `ask` reached its FINAL deny with nobody having judged it — the approver consulted for the call (the run's own seat, or an ancestor's frozen seat on a delegated child: the sync / workflow lanes, or a background child outside the park family) answered `unavailable` and no durable park took the call afterwards. The deny is unchanged (`tool_end.gate.settlement.kind:"approver_unavailable"`, the sentence as the tool result); this is the half a person watching notices could not see. `detail: { sessionId, toolName, toolCallId, settlementKind, parkLaneExisted }` — `sessionId` REQUIRED (the child's session on a delegated child; lifted onto the typed key by the one throat), `parkLaneExisted` = a park lane was armed and declined/failed (`true`) or none existed (`false`). Once per tool call.
|
|
102
|
+
- **One mint.** `askUnresolvableNotice(facts)` (new export, `src/core/ask-unresolvable-notice.ts`, `@contract delegation.ask_unresolvable.single_mint`) composes it from the gate's deny-exit facts at the ONE exit both unavailable deny arms reach — the marked-call intercept (prepare-ask-lane) and the resolver's own arm (tool-policy) both land in gate-lanes after the park re-route has had its attempt (`ToolGateInput.onAskUnresolvable`, wired by prepare-gate-stations). A host driving the gate directly composes the same notice from the same facts.
|
|
103
|
+
- **Consumers:** a downstream audience mirror diffing against the catalog gains one `user` row; `@server`'s `engine_notice` whitelist +1; `@cli` renders one user-level warn line off `detail` (the child's session is the attribution — the card's `fromSubagent`/`sourceTaskId` badge path applies).
|
|
104
|
+
|
|
3
105
|
## 7.9.2 — 2026-09-09
|
|
4
106
|
|
|
5
107
|
### Fixes — a delegated child runs on the parent run's model, not the deployment's boot default (#641 · B-050; @cli @server)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Model, ModelRef, ModelRoles } from "../core/types.js";
|
|
1
|
+
import type { Model, ModelRef, ModelRoles, ThinkingLevel } from "../core/types.js";
|
|
2
2
|
/** The deployment role map behind the runner (`Runner.agentCatalog.roles`) — same posture as `runnerModelCatalog` (subagent.ts):
|
|
3
3
|
* host code, a throwing/absent stand-in reads as "no role map", never as a failed delegation. */
|
|
4
4
|
export declare function runnerRoleMap(runner: unknown): ModelRoles | undefined;
|
|
@@ -10,6 +10,48 @@ export declare function runnerRoleMap(runner: unknown): ModelRoles | undefined;
|
|
|
10
10
|
* a role entry carrying only a `thinking` / `systemPrompt` preset states no model either.
|
|
11
11
|
*/
|
|
12
12
|
export declare function deploymentStatesSubagentModel(roles: ModelRoles | undefined): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Did the deployment STATE a thinking level for its subagents — and which? The `thinking` member of an
|
|
15
|
+
* object-form `roles.subagent` entry; a string ref, a `Model` object or an entry without the member states
|
|
16
|
+
* none (`undefined`). The thinking axis's twin of {@link deploymentStatesSubagentModel}: the SAME seat is
|
|
17
|
+
* read (`roles.subagent` only — never the fallback chain, whose thinking the parent's own effective level
|
|
18
|
+
* already reflects when the parent ran on it).
|
|
19
|
+
*/
|
|
20
|
+
export declare function deploymentSubagentThinking(roles: ModelRoles | undefined): ThinkingLevel | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Did the deployment STATE a system prompt for its subagents — and which? The `systemPrompt` member of an
|
|
23
|
+
* object-form `roles.subagent` entry; a string ref, a `Model` object or an entry without the member states
|
|
24
|
+
* none. The third axis, read off the SAME seat as {@link deploymentSubagentThinking}. It has no parent rung:
|
|
25
|
+
* a child never inherits its PARENT's system prompt, only the deployment's statement for subagents. Which
|
|
26
|
+
* station needs to read it: one whose default persona is written onto the spec itself (the workflow lane
|
|
27
|
+
* frames an empty `systemPrompt` with the workflow-subagent persona, so the preset must be written first, as
|
|
28
|
+
* a custom persona to that frame); the delegation station's default rides the trusted internals channel
|
|
29
|
+
* below the role chain, so prepare reads the preset there without this helper.
|
|
30
|
+
*/
|
|
31
|
+
export declare function deploymentSubagentSystemPrompt(roles: ModelRoles | undefined): string | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* The child run's THINKING seat — {@link childModelSeat}'s sister, the same rung order on the other axis
|
|
34
|
+
* (#651): an EXPLICIT level for this spawn wins (the agent definition's `thinking`; a fork's explicit is the
|
|
35
|
+
* caller's own level, as its model is the caller's); else the deployment's STATED subagent level
|
|
36
|
+
* ({@link deploymentSubagentThinking}); else the PARENT run's effective level (the harness's current level —
|
|
37
|
+
* a mid-run degrade/boost carries into new children); else nothing (the role chain decides, as before).
|
|
38
|
+
*
|
|
39
|
+
* The stated level is WRITTEN onto the spec rather than left to the role chain, unlike the model axis,
|
|
40
|
+
* because the two axes resolve differently inside `resolveTaskModel`: the role chain reads `thinking` off
|
|
41
|
+
* the role that SUPPLIED THE MODEL (the `subagent` entry when it states one, else the fallback role that
|
|
42
|
+
* did), so a `roles.subagent` entry stating a level but no model would have its level skipped in favour of
|
|
43
|
+
* the fallback role's. Writing the literal is the same value the seat rule resolves, on every seat shape;
|
|
44
|
+
* the seat's role identity (`modelRole: "subagent"`, on every engine-spawned seat) still keys the
|
|
45
|
+
* `systemPrompt` preset, which has no such fallback drift.
|
|
46
|
+
*
|
|
47
|
+
* Why the parent's level used to be wrong here: the parent's RESOLVED run level (`"off"` when the parent
|
|
48
|
+
* declared nothing — the harness contract) was inherited as if it were an explicit declaration, and
|
|
49
|
+
* `spec.thinking` outranks the role preset, so a deployment that stated `roles.subagent.thinking` watched
|
|
50
|
+
* every child run with thinking off. The parent's level is the LAST rung now, where the model axis has it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function childThinkingSeat(explicit: ThinkingLevel | undefined, deploymentStated: ThinkingLevel | undefined, parent: ThinkingLevel | undefined): {
|
|
53
|
+
thinking?: ThinkingLevel;
|
|
54
|
+
};
|
|
13
55
|
/**
|
|
14
56
|
* The deployment's stated subagent tier as a resolved `Model` — for a spawn lane that needs the concrete
|
|
15
57
|
* model up front (the workflow lane keys call identity and the display label on it), so both lanes read the
|
|
@@ -23,16 +65,22 @@ export declare function deploymentSubagentTierModel(deps: {
|
|
|
23
65
|
} | undefined): Model | undefined;
|
|
24
66
|
/**
|
|
25
67
|
* The child run's model seat — ONE rule for every engine-spawned run (the delegation child at
|
|
26
|
-
* `buildChildSpec`, the observer sidecar): an EXPLICIT model for this spawn wins
|
|
27
|
-
* agent definition's `model`, the tool-level `model`); else a deployment that STATED
|
|
28
|
-
* it (
|
|
29
|
-
* else the child runs on the PARENT run's effective model (the caller's current `Model` object,
|
|
30
|
-
* per-model routing/credentials ride along); else — no parent model readable, which only happens outside
|
|
68
|
+
* `buildChildSpec`, the observer sidecar, the workflow launch site): an EXPLICIT model for this spawn wins
|
|
69
|
+
* (per-call `model`, the agent definition's `model`, the tool-level `model`); else a deployment that STATED
|
|
70
|
+
* a subagent tier gets it (no model on the seat — the role chain resolves the stated entry, `select` forms
|
|
71
|
+
* included); else the child runs on the PARENT run's effective model (the caller's current `Model` object,
|
|
72
|
+
* so per-model routing/credentials ride along); else — no parent model readable, which only happens outside
|
|
31
73
|
* a Runner-enriched ctx — the bare role, whose chain ends at the deployment default.
|
|
32
74
|
*
|
|
33
|
-
*
|
|
34
|
-
* `resolveTaskModel` keys the role's `systemPrompt` / `thinking` presets on
|
|
35
|
-
* `roles
|
|
75
|
+
* THE ROLE IDENTITY IS CONSTANT: every seat carries `modelRole: "subagent"`, the explicit-model seat too.
|
|
76
|
+
* `resolveTaskModel` keys the role's `systemPrompt` / `thinking` presets on `modelRole` in BOTH of its
|
|
77
|
+
* branches (an explicit model reads `roles[modelRole]`'s presets; a role-resolved model reads them along the
|
|
78
|
+
* chain), so a deployment's `roles.subagent` preset reaches a child whatever decided its model. An explicit
|
|
79
|
+
* seat without the identity read the `default` role's presets instead — a child spawned with a per-call or
|
|
80
|
+
* definition model silently ran under the deployment's MAIN persona while its sibling with no explicit model
|
|
81
|
+
* ran under the subagent one, and the workflow station (which writes the stated persona onto the spec)
|
|
82
|
+
* disagreed with the delegation station on the same input. The two arms below differ ONLY in whether a
|
|
83
|
+
* model rides the seat; the identity never varies. A FORK is not a seat of this rule — see {@link forkModelSeat}.
|
|
36
84
|
*
|
|
37
85
|
* Why "default" was the wrong last resort: `FALLBACK.subagent = ["default"]` resolves to the deployment's
|
|
38
86
|
* boot default, which is not the model the parent run was switched to — a run whose main turn was moved to
|
|
@@ -40,12 +88,21 @@ export declare function deploymentSubagentTierModel(deps: {
|
|
|
40
88
|
* from the roster spawned children that failed at prepare). The parent's effective model is the honest
|
|
41
89
|
* default; the deployment's stated tier keeps outranking it because that statement is operator intent.
|
|
42
90
|
*/
|
|
43
|
-
/** The seat:
|
|
91
|
+
/** The seat: the role identity, with the model that rides it (explicit or inherited) or none (the stated tier resolves through the role chain). */
|
|
44
92
|
export type ChildModelSeat = {
|
|
45
|
-
model: ModelRef;
|
|
46
|
-
modelRole?: undefined;
|
|
47
|
-
} | {
|
|
48
93
|
modelRole: "subagent";
|
|
49
|
-
model?:
|
|
94
|
+
model?: ModelRef;
|
|
50
95
|
};
|
|
51
96
|
export declare function childModelSeat(explicit: ModelRef | undefined, deploymentStated: boolean, parent: Model | undefined): ChildModelSeat;
|
|
97
|
+
/**
|
|
98
|
+
* The FORK's model seat — the caller CONTINUED, not a subagent to the role chain. A fork always runs on the
|
|
99
|
+
* caller's model (the caller's current `Model`, else the delegation tool's own `model`), and it carries NO
|
|
100
|
+
* role identity: its system prompt is meant to be the PARENT's (the delegation station leaves the fork's
|
|
101
|
+
* `systemPrompt` unset and threads no default persona, so prepare resolves the same role presets the
|
|
102
|
+
* caller's own explicit-model run reads — the `default` role's — never the subagent persona a deployment
|
|
103
|
+
* stated for its workers). No caller model readable (only outside a Runner-enriched ctx) ⇒ an empty seat: the
|
|
104
|
+
* role chain's `default` start, exactly what a caller run with no `modelRole` of its own resolves.
|
|
105
|
+
*/
|
|
106
|
+
export declare function forkModelSeat(callerModel: ModelRef | undefined): {
|
|
107
|
+
model?: ModelRef;
|
|
108
|
+
};
|
|
@@ -20,15 +20,31 @@ export function deploymentStatesSubagentModel(roles) {
|
|
|
20
20
|
return cfg.model !== "default";
|
|
21
21
|
return cfg.select !== undefined;
|
|
22
22
|
}
|
|
23
|
+
export function deploymentSubagentThinking(roles) {
|
|
24
|
+
return subagentRolePresets(roles)?.thinking;
|
|
25
|
+
}
|
|
26
|
+
export function deploymentSubagentSystemPrompt(roles) {
|
|
27
|
+
return subagentRolePresets(roles)?.systemPrompt;
|
|
28
|
+
}
|
|
29
|
+
function subagentRolePresets(roles) {
|
|
30
|
+
const s = roles?.subagent;
|
|
31
|
+
if (s == null || typeof s === "string")
|
|
32
|
+
return undefined;
|
|
33
|
+
if ("id" in s && "api" in s)
|
|
34
|
+
return undefined;
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
export function childThinkingSeat(explicit, deploymentStated, parent) {
|
|
38
|
+
const level = explicit ?? deploymentStated ?? parent;
|
|
39
|
+
return level !== undefined ? { thinking: level } : {};
|
|
40
|
+
}
|
|
23
41
|
export function deploymentSubagentTierModel(deps) {
|
|
24
42
|
return deps !== undefined && deploymentStatesSubagentModel(deps.roles) ? resolveTaskModel({ modelRole: "subagent" }, deps).model : undefined;
|
|
25
43
|
}
|
|
26
44
|
export function childModelSeat(explicit, deploymentStated, parent) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return { modelRole: "subagent", model: parent };
|
|
33
|
-
return { modelRole: "subagent" };
|
|
45
|
+
const model = explicit ?? (deploymentStated ? undefined : parent);
|
|
46
|
+
return { modelRole: "subagent", ...(model !== undefined ? { model } : {}) };
|
|
47
|
+
}
|
|
48
|
+
export function forkModelSeat(callerModel) {
|
|
49
|
+
return callerModel !== undefined ? { model: callerModel } : {};
|
|
34
50
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { RunInternals, RunnerSelfSeat } from "../core/runner/contracts.js";
|
|
2
2
|
import type { AgentDefinition, Model, ModelRef, TaskLimits, TerminalCause, ToolSpec } from "../core/types.js";
|
|
3
|
-
export { deploymentSubagentTierModel } from "./child-model-seat.js";
|
|
3
|
+
export { childThinkingSeat, deploymentSubagentThinking, deploymentSubagentTierModel } from "./child-model-seat.js";
|
|
4
4
|
import { type ExecutionEnv } from "../internal/harness.js";
|
|
5
5
|
import type { TaskNotificationPayload } from "../core/task-notification.js";
|
|
6
6
|
import { RETAIN_DEFAULT_TTL_MS, RETAIN_DEFAULT_MAX } from "../config/defaults.js";
|
|
@@ -563,7 +563,7 @@ export declare function delegationEntryLedgerFootprint(registry: object): {
|
|
|
563
563
|
* they were prepared with.
|
|
564
564
|
*
|
|
565
565
|
* The MODEL roster ({@link SubagentToolOptions.models}) deliberately does NOT follow that rule when it
|
|
566
|
-
* is left to the runner: a hot-swapped catalog is a first-class runner verb (`Runner.
|
|
566
|
+
* is left to the runner: a hot-swapped catalog is a first-class runner verb (`Runner.swapDeps`) with
|
|
567
567
|
* no rebuild step, so the tool re-reads it at each judgement instead of requiring a remount. Only the
|
|
568
568
|
* schema/announce projection of it stays a mount-time snapshot (cache-prefix stability). Passing
|
|
569
569
|
* `models` explicitly opts back into snapshot semantics for that table — see the field.
|
package/dist/agents/subagent.js
CHANGED
|
@@ -7,8 +7,8 @@ import { newDelegationProvenanceAggregate, reduceDelegationAttestation } from ".
|
|
|
7
7
|
import { registerDelegationLaunch, replayExternalSettlementEffects, settleDelegation } from "../core/memory-engine/delegation-settlement.js";
|
|
8
8
|
import { enqueueMemoryAnnouncement } from "../core/memory-engine/layout.js";
|
|
9
9
|
import { resolveModel, resolveModelDisplayLabel } from "../core/roles.js";
|
|
10
|
-
import { childModelSeat, deploymentStatesSubagentModel, runnerRoleMap } from "./child-model-seat.js";
|
|
11
|
-
export { deploymentSubagentTierModel } from "./child-model-seat.js";
|
|
10
|
+
import { childModelSeat, childThinkingSeat, deploymentStatesSubagentModel, deploymentSubagentThinking, forkModelSeat, runnerRoleMap } from "./child-model-seat.js";
|
|
11
|
+
export { childThinkingSeat, deploymentSubagentThinking, deploymentSubagentTierModel } from "./child-model-seat.js";
|
|
12
12
|
import { OUTPUT_TOOL_NAME, REPORT_BLOCKED_TOOL_NAME } from "../core/runner/synthetic-tools.js";
|
|
13
13
|
import { TOOL_SEARCH_NAME } from "../core/runner/tool-disclosure.js";
|
|
14
14
|
import { OFFLOAD_TOOL_NAME } from "../core/tool-result-store.js";
|
|
@@ -1808,7 +1808,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1808
1808
|
}
|
|
1809
1809
|
}
|
|
1810
1810
|
}
|
|
1811
|
-
const childSeat = childModelSeat(childModel, deploymentStatedSubagentModel, ctx.model);
|
|
1811
|
+
const childSeat = wantsFork ? forkModelSeat(childModel) : childModelSeat(childModel, deploymentStatedSubagentModel, ctx.model);
|
|
1812
1812
|
const seatModel = childSeat.model;
|
|
1813
1813
|
const spawnModel = typeof seatModel === "string"
|
|
1814
1814
|
? (() => {
|
|
@@ -1893,6 +1893,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
1893
1893
|
...(resume ? { requireExistingSession: true } : {}),
|
|
1894
1894
|
objective: objectiveText,
|
|
1895
1895
|
...childModelSeat(undefined, deploymentStatesSubagentModel(runnerRoleMap(opts.runner)), ctx.model),
|
|
1896
|
+
...childThinkingSeat(observerDef.thinking, deploymentSubagentThinking(runnerRoleMap(opts.runner)), ctx.thinkingLevel),
|
|
1896
1897
|
...(observerDef.systemPrompt !== undefined && !resume ? { systemPrompt: observerDef.systemPrompt } : {}),
|
|
1897
1898
|
...(ctx.principal !== undefined ? { principal: ctx.principal } : {}),
|
|
1898
1899
|
...(ctx.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: ctx.getApiKeyAndHeaders } : {}),
|
|
@@ -2132,7 +2133,9 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2132
2133
|
...(reviveClaim === undefined && ctx.fileHistoryLineage !== undefined ? { fileHistoryLineage: ctx.fileHistoryLineage } : {}),
|
|
2133
2134
|
...(ctx.onSubagentSpawn ? { onSubagentSpawn: ctx.onSubagentSpawn } : {}),
|
|
2134
2135
|
};
|
|
2135
|
-
const childThinking =
|
|
2136
|
+
const childThinking = wantsFork
|
|
2137
|
+
? ctx.thinkingLevel !== undefined ? { thinking: ctx.thinkingLevel } : {}
|
|
2138
|
+
: childThinkingSeat(def?.thinking, deploymentSubagentThinking(runnerRoleMap(opts.runner)), ctx.thinkingLevel);
|
|
2136
2139
|
const provenanceAgentName = agentName ?? def?.name;
|
|
2137
2140
|
const childMemoryPersistenceCapable = ctx.memoryPersistenceCapable === false ? false : (def?.memoryPersistenceCapable ?? ctx.memoryPersistenceCapable);
|
|
2138
2141
|
const childOnAsk = ctx.onAsk !== undefined
|
|
@@ -2145,7 +2148,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2145
2148
|
const buildChildSpec = (signal) => ({
|
|
2146
2149
|
objective: prompt,
|
|
2147
2150
|
...childSeat,
|
|
2148
|
-
...
|
|
2151
|
+
...childThinking,
|
|
2149
2152
|
tools: childTools,
|
|
2150
2153
|
systemPrompt: childSystemPrompt,
|
|
2151
2154
|
...(def?.memory ? { memory: def.memory } : {}),
|
package/dist/agents/teacher.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { MAX_STRATEGY_INJECTION_TOTAL_BYTES, MAX_STRATEGY_PROBLEM_BYTES, MAX_STRATEGY_TEXT_BYTES } from "../core/strategy-store.js";
|
|
3
3
|
import { createSafeNotifier, observeThenableRejection } from "../core/safe-notify.js";
|
|
4
4
|
import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
|
|
5
|
-
import { isDefineToolProduct, stampDefineToolBrand } from "../core/tools.js";
|
|
5
|
+
import { isDefineToolProduct, rebindDefineToolCtx, stampDefineToolBrand } from "../core/tools.js";
|
|
6
6
|
import { delimitUntrusted, inlineUntrusted } from "../core/untrusted-text.js";
|
|
7
7
|
import { buildCumulativeStats } from "./cumulative-stats.js";
|
|
8
8
|
export const TEACHER_PROMPT = `You are an expert advisor to a less-capable "student" agent that got stuck.
|
|
@@ -223,7 +223,7 @@ async function runTeacherCore(runner, studentSpec, teacher) {
|
|
|
223
223
|
throw e;
|
|
224
224
|
}
|
|
225
225
|
},
|
|
226
|
-
});
|
|
226
|
+
}, (enrich, _mounted) => wrap(rebindDefineToolCtx(product, enrich)));
|
|
227
227
|
}
|
|
228
228
|
return {
|
|
229
229
|
...t,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `delegation.ask_unresolvable` — the one mint of the notice the gate raises when an `ask` reached its
|
|
3
|
+
* final deny with NOBODY having judged it: the approver consulted for the call (the run's own seat, or an
|
|
4
|
+
* ancestor's frozen seat on a delegated child) answered the ROUTING question `unavailable` — no operator
|
|
5
|
+
* is reachable for this ask — and no durable approval park took the call afterwards. The deny itself is
|
|
6
|
+
* older than this notice and stands unchanged (the model reads the sentence as its tool result, the wire
|
|
7
|
+
* carries `tool_end.gate.settlement.kind:"approver_unavailable"`); what was missing was any face a PERSON
|
|
8
|
+
* watching notices could see. An operator reading notices alone saw nothing, and the person whose
|
|
9
|
+
* delegated work was refused learned it only by reading the child's transcript.
|
|
10
|
+
*
|
|
11
|
+
* Two deny arms compose this settlement — the marked-call intercept (an ancestor's seat answered
|
|
12
|
+
* unavailable and the float found no park) and the resolver's own unavailable arm — and both reach ONE
|
|
13
|
+
* exit in the gate lanes, after the park re-route has had its one attempt. That exit is the only caller of
|
|
14
|
+
* this factory: one call, one notice, once per tool call.
|
|
15
|
+
*
|
|
16
|
+
* Audience `"user"`: the refused work is the user's own (their run, or a delegation their run made), and
|
|
17
|
+
* the recovery — a checkpoint store with a durable approval opt-in so such asks can wait for a person, or
|
|
18
|
+
* a live approver reachable for delegated children — is a deployment posture the operator hears through
|
|
19
|
+
* the sink like every other code; the row only adds the session-stream projection so the person whose
|
|
20
|
+
* call was refused sees WHY it did not run. `sessionId` is therefore REQUIRED at the mint (the routing
|
|
21
|
+
* half of a user row). Dedup unit: per tool call — the gate's obligation (it owns the call); this module
|
|
22
|
+
* is pure.
|
|
23
|
+
*/
|
|
24
|
+
import type { EngineNotice } from "./engine-notice.js";
|
|
25
|
+
import type { SettlementKind } from "./gate-outcome.js";
|
|
26
|
+
/** What the gate knows at the deny exit — facts only, no words of the engine's vocabulary composed. */
|
|
27
|
+
export interface AskUnresolvableFacts {
|
|
28
|
+
/** The session whose call was refused — the notice's routing key, required (see module note). */
|
|
29
|
+
readonly sessionId: string;
|
|
30
|
+
/** The tool the ask was for (the message quotes it verbatim). */
|
|
31
|
+
readonly toolName: string;
|
|
32
|
+
/** The refused call's id — the correlation key to the `tool_end` frame carrying the same settlement. */
|
|
33
|
+
readonly toolCallId: string;
|
|
34
|
+
/** The settlement the deny composed — always `approver_unavailable` today; carried as a fact, not re-derived. */
|
|
35
|
+
readonly settlementKind: Extract<SettlementKind, "approver_unavailable">;
|
|
36
|
+
/** Whether a durable approval park lane was ARMED for this run (`true`: the park had its one attempt and
|
|
37
|
+
* did not take the call — it declined or failed; `false`: no park lane at all, the usual shape). */
|
|
38
|
+
readonly parkLaneExisted: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Compose the `delegation.ask_unresolvable` notice from the gate's deny-exit facts.
|
|
42
|
+
*
|
|
43
|
+
* @contract delegation.ask_unresolvable.single_mint — the `delegation.ask_unresolvable` notice is composed
|
|
44
|
+
* by CORE from the gate's deny-exit FACTS (session, tool, call id, the composed settlement, whether a park
|
|
45
|
+
* lane existed) at the ONE exit both unavailable deny arms reach; no other site spells the code or the
|
|
46
|
+
* sentence, and an empty session or tool name is refused at the mint with a `TypeError` rather than
|
|
47
|
+
* shaped into a notice that would route nowhere.
|
|
48
|
+
*
|
|
49
|
+
* `detail: { sessionId, toolName, toolCallId, settlementKind, parkLaneExisted }` — `sessionId` is lifted
|
|
50
|
+
* onto the typed key by {@link import("./engine-notice.js").deliverEngineNotice}, the one throat.
|
|
51
|
+
*/
|
|
52
|
+
export declare function askUnresolvableNotice(facts: AskUnresolvableFacts): EngineNotice;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function askUnresolvableNotice(facts) {
|
|
2
|
+
const { sessionId, toolName, toolCallId, settlementKind, parkLaneExisted } = facts;
|
|
3
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
4
|
+
throw new TypeError("delegation.ask_unresolvable: sessionId is required — a user-audience notice with no session routes nowhere");
|
|
5
|
+
}
|
|
6
|
+
if (typeof toolName !== "string" || toolName.length === 0) {
|
|
7
|
+
throw new TypeError("delegation.ask_unresolvable: toolName is required");
|
|
8
|
+
}
|
|
9
|
+
if (typeof toolCallId !== "string" || toolCallId.length === 0) {
|
|
10
|
+
throw new TypeError("delegation.ask_unresolvable: toolCallId is required");
|
|
11
|
+
}
|
|
12
|
+
if (settlementKind !== "approver_unavailable") {
|
|
13
|
+
throw new TypeError(`delegation.ask_unresolvable: settlementKind ${JSON.stringify(settlementKind)} is not the unavailable settlement`);
|
|
14
|
+
}
|
|
15
|
+
const park = parkLaneExisted
|
|
16
|
+
? "the durable approval park did not take it"
|
|
17
|
+
: "no durable approval park is armed for this run";
|
|
18
|
+
return {
|
|
19
|
+
code: "delegation.ask_unresolvable",
|
|
20
|
+
message: `approval for "${toolName}" could not be resolved: the approver reported that nobody is reachable for this ask and ${park} — ` +
|
|
21
|
+
`the call was denied fail-closed and did not run. To let such approvals wait for a person, run with a checkpoint store and a ` +
|
|
22
|
+
`durable approval opt-in (or a live approver reachable for delegated children).`,
|
|
23
|
+
detail: { sessionId, toolName, toolCallId, settlementKind, parkLaneExisted: parkLaneExisted === true },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -8,9 +8,10 @@ export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
|
|
|
8
8
|
export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2000;
|
|
9
9
|
/**
|
|
10
10
|
* The classifier call's output cap (`maxTokens`), sized from the engine's OWN output contract rather
|
|
11
|
-
* than copied from upstream: the
|
|
12
|
-
* `<block>
|
|
13
|
-
*
|
|
11
|
+
* than copied from upstream: the prompt asks for exactly two shapes, `<block>no</block>` and
|
|
12
|
+
* `<block>yes</block><category>NAME</category><reason>[NAME] one short sentence</reason>` (the parser,
|
|
13
|
+
* `parseAutoModeResponse`, reads the verdict wherever it sits, so a short preamble costs tokens, not
|
|
14
|
+
* the verdict). The longest rule name in the shipped rule sets is 46 characters and the reason
|
|
14
15
|
* is one short sentence naming that rule (≈200 characters at the outside, more when several rules
|
|
15
16
|
* match and the others are named in the reason), so the longest LEGAL reply is ≈350 characters —
|
|
16
17
|
* ≈120 tokens at the conservative 3 chars/token that tag-heavy text tokenizes at, ≈150 with a
|
|
@@ -22,6 +23,17 @@ export declare const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2000;
|
|
|
22
23
|
* wire can say off (`thinkingOffExpressible`); a model that thinks regardless keeps its own budget.
|
|
23
24
|
*/
|
|
24
25
|
export declare const AUTO_MODE_CLASSIFIER_MAX_TOKENS = 256;
|
|
26
|
+
/**
|
|
27
|
+
* CC 2.1.250 `E4`: for a model in the `rejects_disabled_thinking` family (`BHe`) the classifier request
|
|
28
|
+
* adds 2048 output tokens on top of the verdict cap — the thinking ALLOWANCE for a model that reasons
|
|
29
|
+
* whether or not it is told to. The engine has no per-model declaration of that fact (a gateway model
|
|
30
|
+
* with no `reasoning`/`compat` in the catalog reads as "off is expressible"), so it learns it from the
|
|
31
|
+
* reply instead: a capped classify that comes back EMPTY at the cap (the output went to reasoning) is
|
|
32
|
+
* re-asked ONCE with the cap raised by this allowance, thinking still off (CC `cEt` re-asks such a
|
|
33
|
+
* reply up to `maxRetries` = 4 times at the same cap; one re-ask at the allowance-widened cap is the
|
|
34
|
+
* subset the engine spends). A second empty reply is the contract failure it always was.
|
|
35
|
+
*/
|
|
36
|
+
export declare const AUTO_MODE_CLASSIFIER_THINKING_ALLOWANCE_TOKENS = 2048;
|
|
25
37
|
/** The sentinel a deployment puts INSIDE a paired rule list to splice the CC default rules back in at
|
|
26
38
|
* that position (CC `XYt = "$defaults"`). It lives here, beside the other defaults, because the #503
|
|
27
39
|
* recipe canonicalizer needs its VALUE and must not load the assembly face (and its SHA-locked assets)
|
|
@@ -3,6 +3,7 @@ export const AUTO_MODE_DEFAULT_FAILURE_THRESHOLD = 3;
|
|
|
3
3
|
export const AUTO_MODE_DEFAULT_WINDOW_MAX_ENTRIES = 40;
|
|
4
4
|
export const AUTO_MODE_DEFAULT_WINDOW_MAX_CHARS = 2_000;
|
|
5
5
|
export const AUTO_MODE_CLASSIFIER_MAX_TOKENS = 256;
|
|
6
|
+
export const AUTO_MODE_CLASSIFIER_THINKING_ALLOWANCE_TOKENS = 2048;
|
|
6
7
|
export const AUTO_MODE_DEFAULTS_SENTINEL = "$defaults";
|
|
7
8
|
export const AUTO_MODE_DENIAL_LIMIT_DEFAULTS = Object.freeze({ maxConsecutive: 3, maxTotal: 20 });
|
|
8
9
|
export const AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS = 120_000;
|