@sema-agent/core 7.9.2 → 7.10.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 +38 -0
- package/dist/agents/child-model-seat.d.ts +31 -1
- package/dist/agents/child-model-seat.js +12 -0
- package/dist/agents/subagent.d.ts +2 -2
- package/dist/agents/subagent.js +6 -4
- package/dist/core/ask-unresolvable-notice.d.ts +52 -0
- package/dist/core/ask-unresolvable-notice.js +25 -0
- package/dist/core/auto-mode.d.ts +62 -3
- package/dist/core/auto-mode.js +31 -0
- 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-lanes.js +15 -0
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +4 -0
- package/dist/core/hooks.d.ts +24 -1
- package/dist/core/hooks.js +2 -0
- package/dist/core/permission-rule-model.d.ts +51 -16
- package/dist/core/permission-rule-model.js +55 -21
- package/dist/core/permission-rules.d.ts +6 -4
- package/dist/core/permission-rules.js +14 -14
- 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/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 +13 -3
- package/dist/core/runner/prepare-gate-stations.d.ts +3 -2
- package/dist/core/runner/prepare-gate-stations.js +3 -0
- package/dist/core/runner/prepare-policy-chain.js +7 -6
- 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/swappable-deps.d.ts +90 -0
- package/dist/core/swappable-deps.js +55 -0
- package/dist/core/tool-policy.d.ts +26 -0
- package/dist/core/tool-policy.js +5 -1
- 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/types.d.ts +11 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/orchestration/run-workflow-tool.d.ts +17 -0
- package/dist/orchestration/run-workflow-tool.js +12 -0
- package/dist/orchestration/workflow-observe.d.ts +1 -1
- package/dist/orchestration/workflow-observe.js +2 -0
- package/dist/orchestration/workflow-types.d.ts +37 -2
- package/dist/orchestration/workflow-types.js +16 -0
- package/dist/orchestration/workflow.d.ts +41 -2
- package/dist/orchestration/workflow.js +295 -44
- package/dist/stores/file/workflow-journal-store.js +10 -3
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +51 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.10.0 — 2026-09-09
|
|
4
|
+
|
|
5
|
+
### BREAKING — `Runner.swapModels` is retired into ONE hot-swap door, `Runner.swapDeps` (#628 + #616 seat door; @server S-174 @cli @client-core)
|
|
6
|
+
- **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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **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).
|
|
9
|
+
- Exports +6: `SWAPPABLE_DEP_SEATS`, `SWAP_SEAT_NOTICE`, `isSwappableDepSeat`, `screenSwappableDeps`, `SwappableDepSeat`, `SwappableDeps`.
|
|
10
|
+
|
|
11
|
+
### 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)
|
|
12
|
+
- **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).
|
|
13
|
+
- **① 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.
|
|
14
|
+
- **② 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.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
### Narrowing + BREAKING export — a `~/` path rule resolves against the EXECUTION ENVIRONMENT's home, never the engine process's (#644; @server S-184 @cli)
|
|
18
|
+
- **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.
|
|
19
|
+
- **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).
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
### Fixes — a child run's thinking level follows the same seat rule as its model (#651)
|
|
24
|
+
- `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).
|
|
25
|
+
|
|
26
|
+
### Wire (additive) — the workflow lane joins the park family (#642 · B-049; @server S-185 @cli @client-core @web-admin)
|
|
27
|
+
- **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).
|
|
28
|
+
- **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).
|
|
29
|
+
- **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".
|
|
30
|
+
- **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).
|
|
31
|
+
- **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.
|
|
32
|
+
- **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).
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
|
|
36
|
+
### Wire (additive) — `delegation.ask_unresolvable`: the fail-closed unavailable deny gets its person-facing half (#648; @server @cli @client-core)
|
|
37
|
+
- **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.
|
|
38
|
+
- **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.
|
|
39
|
+
- **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).
|
|
40
|
+
|
|
3
41
|
## 7.9.2 — 2026-09-09
|
|
4
42
|
|
|
5
43
|
### 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,36 @@ 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
|
+
* The child run's THINKING seat — {@link childModelSeat}'s sister, the same rung order on the other axis
|
|
23
|
+
* (#651): an EXPLICIT level for this spawn wins (the agent definition's `thinking`; a fork's explicit is the
|
|
24
|
+
* caller's own level, as its model is the caller's); else the deployment's STATED subagent level
|
|
25
|
+
* ({@link deploymentSubagentThinking}); else the PARENT run's effective level (the harness's current level —
|
|
26
|
+
* a mid-run degrade/boost carries into new children); else nothing (the role chain decides, as before).
|
|
27
|
+
*
|
|
28
|
+
* The stated level is WRITTEN onto the spec rather than left to the role chain, unlike the model axis,
|
|
29
|
+
* because the two axes resolve differently inside `resolveTaskModel`: a stated MODEL is reached through the
|
|
30
|
+
* seat's role identity (`modelRole: "subagent"`), which an explicit-model seat does not carry — leaving the
|
|
31
|
+
* level unwritten there would silently hand an explicit-model child the `default` role's level instead of
|
|
32
|
+
* the stated subagent one. Writing the literal is the same value the role chain would have produced, on
|
|
33
|
+
* every seat shape.
|
|
34
|
+
*
|
|
35
|
+
* Why the parent's level used to be wrong here: the parent's RESOLVED run level (`"off"` when the parent
|
|
36
|
+
* declared nothing — the harness contract) was inherited as if it were an explicit declaration, and
|
|
37
|
+
* `spec.thinking` outranks the role preset, so a deployment that stated `roles.subagent.thinking` watched
|
|
38
|
+
* every child run with thinking off. The parent's level is the LAST rung now, where the model axis has it.
|
|
39
|
+
*/
|
|
40
|
+
export declare function childThinkingSeat(explicit: ThinkingLevel | undefined, deploymentStated: ThinkingLevel | undefined, parent: ThinkingLevel | undefined): {
|
|
41
|
+
thinking?: ThinkingLevel;
|
|
42
|
+
};
|
|
13
43
|
/**
|
|
14
44
|
* The deployment's stated subagent tier as a resolved `Model` — for a spawn lane that needs the concrete
|
|
15
45
|
* model up front (the workflow lane keys call identity and the display label on it), so both lanes read the
|
|
@@ -20,6 +20,18 @@ export function deploymentStatesSubagentModel(roles) {
|
|
|
20
20
|
return cfg.model !== "default";
|
|
21
21
|
return cfg.select !== undefined;
|
|
22
22
|
}
|
|
23
|
+
export function deploymentSubagentThinking(roles) {
|
|
24
|
+
const s = roles?.subagent;
|
|
25
|
+
if (s == null || typeof s === "string")
|
|
26
|
+
return undefined;
|
|
27
|
+
if ("id" in s && "api" in s)
|
|
28
|
+
return undefined;
|
|
29
|
+
return s.thinking;
|
|
30
|
+
}
|
|
31
|
+
export function childThinkingSeat(explicit, deploymentStated, parent) {
|
|
32
|
+
const level = explicit ?? deploymentStated ?? parent;
|
|
33
|
+
return level !== undefined ? { thinking: level } : {};
|
|
34
|
+
}
|
|
23
35
|
export function deploymentSubagentTierModel(deps) {
|
|
24
36
|
return deps !== undefined && deploymentStatesSubagentModel(deps.roles) ? resolveTaskModel({ modelRole: "subagent" }, deps).model : undefined;
|
|
25
37
|
}
|
|
@@ -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, 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";
|
|
@@ -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(undefined, 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,8 @@ 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
|
|
2136
|
+
const explicitThinking = wantsFork ? ctx.thinkingLevel : def?.thinking;
|
|
2137
|
+
const childThinking = childThinkingSeat(explicitThinking, deploymentSubagentThinking(runnerRoleMap(opts.runner)), ctx.thinkingLevel);
|
|
2136
2138
|
const provenanceAgentName = agentName ?? def?.name;
|
|
2137
2139
|
const childMemoryPersistenceCapable = ctx.memoryPersistenceCapable === false ? false : (def?.memoryPersistenceCapable ?? ctx.memoryPersistenceCapable);
|
|
2138
2140
|
const childOnAsk = ctx.onAsk !== undefined
|
|
@@ -2145,7 +2147,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
|
|
|
2145
2147
|
const buildChildSpec = (signal) => ({
|
|
2146
2148
|
objective: prompt,
|
|
2147
2149
|
...childSeat,
|
|
2148
|
-
...
|
|
2150
|
+
...childThinking,
|
|
2149
2151
|
tools: childTools,
|
|
2150
2152
|
systemPrompt: childSystemPrompt,
|
|
2151
2153
|
...(def?.memory ? { memory: def.memory } : {}),
|
|
@@ -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
|
+
}
|
package/dist/core/auto-mode.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { ToolCallRequest } from "./tool-policy.js";
|
|
2
|
+
/**
|
|
3
|
+
* WHY an `unavailable` verdict could not run — the closed set behind the verdict arm, the ask's
|
|
4
|
+
* `classifierUnavailable` fact (the card's "asked because the classifier could not run (timeout)" sentence)
|
|
5
|
+
* and the `auto_mode.classified` trace frame's `cause`, so the three spell one word. `error` = the model leg
|
|
6
|
+
* threw or rejected (a route failure at classify time reads here too — the derived-route pre-flight fell
|
|
7
|
+
* back BEFORE any decide, so there is no separate word for it); `timeout` = the round-trip cap;
|
|
8
|
+
* `breaker_open` = the session latch already tripped and the round was short-circuited.
|
|
9
|
+
*/
|
|
10
|
+
export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly ["error", "timeout", "breaker_open"];
|
|
11
|
+
export type AutoModeUnavailableCause = (typeof AUTO_MODE_UNAVAILABLE_CAUSES)[number];
|
|
12
|
+
/** Whether a value is a member of the unavailable-cause set — the screen a row reader applies to a stored word. */
|
|
13
|
+
export declare function isAutoModeUnavailableCause(v: unknown): v is AutoModeUnavailableCause;
|
|
2
14
|
/** The classifier's verdict over one pending `ask`. */
|
|
3
15
|
export type AutoModeVerdict = {
|
|
4
16
|
kind: "allow";
|
|
@@ -12,13 +24,23 @@ export type AutoModeVerdict = {
|
|
|
12
24
|
/** The classifier could not run: model error/timeout, or the session breaker is open. NOT a decision. */
|
|
13
25
|
| {
|
|
14
26
|
kind: "unavailable";
|
|
15
|
-
cause:
|
|
27
|
+
cause: AutoModeUnavailableCause;
|
|
16
28
|
}
|
|
17
29
|
/** The model responded but not in the `<block>…` contract shape. NOT a decision. */
|
|
18
30
|
| {
|
|
19
31
|
kind: "parse_error";
|
|
20
32
|
raw: string;
|
|
21
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* The failure kinds that COUNT toward the one-way breaker — and the word a trip records as its `lastCause`:
|
|
36
|
+
* the two health failures of the unavailable arm plus the contract failure. `breaker_open` is deliberately
|
|
37
|
+
* not one: it is what the latch ANSWERS once tripped, never what trips it; a cancelled round (the run's own
|
|
38
|
+
* abort) counts nothing (see the decider's catch arm).
|
|
39
|
+
*/
|
|
40
|
+
export declare const AUTO_MODE_BREAKER_CAUSES: readonly ["error", "timeout", "parse_error"];
|
|
41
|
+
export type AutoModeBreakerCause = (typeof AUTO_MODE_BREAKER_CAUSES)[number];
|
|
42
|
+
/** Whether a value is a member of the breaker-cause set (the manifest's value screen over a recorded trip). */
|
|
43
|
+
export declare function isAutoModeBreakerCause(v: unknown): v is AutoModeBreakerCause;
|
|
22
44
|
/**
|
|
23
45
|
* Parse the 207 classifier output contract:
|
|
24
46
|
* blocked: `<block>yes</block><category>Rule Name</category><reason>[Rule Name] …</reason>`
|
|
@@ -65,10 +87,11 @@ export interface AutoModeDeciderOptions {
|
|
|
65
87
|
/** Consecutive-failure threshold that opens the session breaker (default 3 "连续 N 失败").
|
|
66
88
|
* Failures = unavailable(error|timeout) + parse_error. A successful round (allow/block) resets it. */
|
|
67
89
|
failureThreshold?: number;
|
|
68
|
-
/** Fired ONCE when the breaker opens ("本 session 退回非 auto + 一次性告警").
|
|
90
|
+
/** Fired ONCE when the breaker opens ("本 session 退回非 auto + 一次性告警"). `lastCause` is the failure that tripped it
|
|
91
|
+
* ({@link AutoModeBreakerCause}). */
|
|
69
92
|
onBreakerOpen?: (info: {
|
|
70
93
|
consecutiveFailures: number;
|
|
71
|
-
lastCause:
|
|
94
|
+
lastCause: AutoModeBreakerCause;
|
|
72
95
|
}) => void;
|
|
73
96
|
/**
|
|
74
97
|
* Fired once per `decide` call with the verdict the gate is about to act on and the wall time it
|
|
@@ -117,6 +140,42 @@ export interface AutoModeDecider {
|
|
|
117
140
|
* One instance per run/session — the breaker state is the session's "退回非 auto" latch.
|
|
118
141
|
*/
|
|
119
142
|
export declare function createAutoModeDecider(opts: AutoModeDeciderOptions): AutoModeDecider;
|
|
143
|
+
/**
|
|
144
|
+
* ONE breaker trip as the session-level read face records it (`WiringManifest.autoMode.breaker`): when the
|
|
145
|
+
* latch opened, what tripped it, how many consecutive failures it took, and which run's decider it was.
|
|
146
|
+
* A decider is minted per run (its latch is a RUN fact), so a trip names one leg; the ledger below carries
|
|
147
|
+
* the most recent one forward per session, which is how the session's NEXT leg can say "auto mode fell
|
|
148
|
+
* back to asking on this session, at T, because the classifier timed out three times" — the sentence a
|
|
149
|
+
* shell's doctor line and a capabilities read face owe a person who wonders why auto mode is asking.
|
|
150
|
+
*/
|
|
151
|
+
export interface AutoModeBreakerTrip {
|
|
152
|
+
/** Epoch ms at which the latch opened. */
|
|
153
|
+
readonly openedAtMs: number;
|
|
154
|
+
/** The failure that tripped it — the streak's last failure ({@link AutoModeBreakerCause}). */
|
|
155
|
+
readonly lastCause: AutoModeBreakerCause;
|
|
156
|
+
/** The consecutive-failure count at the trip (the threshold, or more under concurrent rounds). */
|
|
157
|
+
readonly failures: number;
|
|
158
|
+
/** The run whose decider tripped. */
|
|
159
|
+
readonly runId: string;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The Runner-lived, per-session record of the most recent breaker trip — the ONE writer is the engine's
|
|
163
|
+
* own `onBreakerOpen` wrap at the arming site (the deployment's alarm hook, when wired, is called after
|
|
164
|
+
* the record lands), the ONE reader the wiring-manifest phase of a later leg. Never persisted (a trip is
|
|
165
|
+
* process-local, like the decider it describes); bounded FIFO so a long-lived Runner cannot grow it
|
|
166
|
+
* without limit — an evicted session simply reads as "no trip recorded", the same as a session that
|
|
167
|
+
* never tripped. Threaded through the trusted `RunInternals` channel exactly as the per-session read-file
|
|
168
|
+
* seats are (the Runner sets it on every prepare it drives; a standalone prepare has none).
|
|
169
|
+
*/
|
|
170
|
+
export declare class AutoModeBreakerLedger {
|
|
171
|
+
private readonly cap;
|
|
172
|
+
private readonly trips;
|
|
173
|
+
constructor(cap?: number);
|
|
174
|
+
/** Record THIS session's most recent trip (replacing an earlier one). */
|
|
175
|
+
record(sessionId: string, trip: AutoModeBreakerTrip): void;
|
|
176
|
+
/** The most recent trip recorded for this session, or undefined (never tripped, or evicted). */
|
|
177
|
+
lastTrip(sessionId: string): AutoModeBreakerTrip | undefined;
|
|
178
|
+
}
|
|
120
179
|
/** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
|
|
121
180
|
* optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
|
|
122
181
|
* loudly at construction (never clamped, never read as the default). */
|
package/dist/core/auto-mode.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { AUTO_MODE_DEFAULT_FAILURE_THRESHOLD, AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS } from "./auto-mode-defaults.js";
|
|
2
|
+
export const AUTO_MODE_UNAVAILABLE_CAUSES = ["error", "timeout", "breaker_open"];
|
|
3
|
+
const AUTO_MODE_UNAVAILABLE_CAUSE_SET = new Set(AUTO_MODE_UNAVAILABLE_CAUSES);
|
|
4
|
+
export function isAutoModeUnavailableCause(v) {
|
|
5
|
+
return AUTO_MODE_UNAVAILABLE_CAUSE_SET.has(v);
|
|
6
|
+
}
|
|
7
|
+
export const AUTO_MODE_BREAKER_CAUSES = ["error", "timeout", "parse_error"];
|
|
8
|
+
const AUTO_MODE_BREAKER_CAUSE_SET = new Set(AUTO_MODE_BREAKER_CAUSES);
|
|
9
|
+
export function isAutoModeBreakerCause(v) {
|
|
10
|
+
return AUTO_MODE_BREAKER_CAUSE_SET.has(v);
|
|
11
|
+
}
|
|
2
12
|
export function parseAutoModeResponse(text) {
|
|
3
13
|
const t = text
|
|
4
14
|
.replace(/<thinking>[\s\S]*?<\/thinking>/g, "")
|
|
@@ -117,6 +127,27 @@ class AutoModeTimeout extends Error {
|
|
|
117
127
|
super("auto-mode classify timeout");
|
|
118
128
|
}
|
|
119
129
|
}
|
|
130
|
+
export class AutoModeBreakerLedger {
|
|
131
|
+
cap;
|
|
132
|
+
trips = new Map();
|
|
133
|
+
constructor(cap = 1024) {
|
|
134
|
+
this.cap = cap;
|
|
135
|
+
}
|
|
136
|
+
record(sessionId, trip) {
|
|
137
|
+
if (this.trips.has(sessionId))
|
|
138
|
+
this.trips.delete(sessionId);
|
|
139
|
+
this.trips.set(sessionId, trip);
|
|
140
|
+
while (this.trips.size > this.cap) {
|
|
141
|
+
const oldest = this.trips.keys().next().value;
|
|
142
|
+
if (oldest === undefined)
|
|
143
|
+
break;
|
|
144
|
+
this.trips.delete(oldest);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
lastTrip(sessionId) {
|
|
148
|
+
return this.trips.get(sessionId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
120
151
|
export function readDenialLimitFallback(v) {
|
|
121
152
|
if (typeof v !== "object" || v === null)
|
|
122
153
|
return undefined;
|
|
@@ -813,6 +813,14 @@ export type PendingAction = {
|
|
|
813
813
|
* Written only when it is a member of the set; a pre-existing row reads absent (unreported).
|
|
814
814
|
* Display metadata — the resume belts keep reading the gate's own bit. */
|
|
815
815
|
origin?: import("./ask-origin.js").AskOrigin;
|
|
816
|
+
/** #616 (additive, same precedent) — the PARK twin of `AskRequest.classifierUnavailable`: present ⇔ the
|
|
817
|
+
* auto-mode classifier was consulted on the parked ask and could not run, with the verdict's own cause
|
|
818
|
+
* word ({@link import("./auto-mode.js").AutoModeUnavailableCause}). Written only when the cause is a
|
|
819
|
+
* member of the set; a pre-existing row reads absent. Display metadata (a durable card's "asked because
|
|
820
|
+
* the classifier was unavailable" sentence) — the resume belts never read it. */
|
|
821
|
+
classifierUnavailable?: {
|
|
822
|
+
readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
|
|
823
|
+
};
|
|
816
824
|
/**
|
|
817
825
|
* design/80 D-1 §2 (slice 1a.2): the server-minted **opaque** boundInputHash of {@link args} — a
|
|
818
826
|
* SHA-256 (hex) via {@link import("./canonical-json.js").boundInputHashOf}, computed ONCE here at
|
|
@@ -1920,6 +1928,12 @@ export interface CheckpointSummary {
|
|
|
1920
1928
|
* value is a member of the closed set ({@link import("./ask-origin.js").isAskOrigin}) — a bad value
|
|
1921
1929
|
* reads as absent, never as a word. OMITTED when the row carries none (a pre-existing row). */
|
|
1922
1930
|
origin?: import("./ask-origin.js").AskOrigin;
|
|
1931
|
+
/** The row's `PendingAction.tool_approval.classifierUnavailable` (#616), projected for the same one-call
|
|
1932
|
+
* reason. Echoed ONLY when the stored cause is a member of the closed set
|
|
1933
|
+
* ({@link import("./auto-mode.js").isAutoModeUnavailableCause}); OMITTED when the row carries none. */
|
|
1934
|
+
classifierUnavailable?: {
|
|
1935
|
+
readonly cause: import("./auto-mode.js").AutoModeUnavailableCause;
|
|
1936
|
+
};
|
|
1923
1937
|
/**
|
|
1924
1938
|
* #438: PRESENCE ONLY — this pending `tool_approval` row's payload carries at least one DIRECTIONAL
|
|
1925
1939
|
* format control (the {@link import("./tool-policy.js").AskRequest.hasBidiControls} character class;
|
|
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { uuidv7 } from "../internal/harness.js";
|
|
3
3
|
import { PROBE_CAUSE_PATH_MAX, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
|
|
4
4
|
import { carriesBidiControls } from "./tool-policy.js";
|
|
5
|
-
import { readDenialLimitFallback } from "./auto-mode.js";
|
|
5
|
+
import { isAutoModeUnavailableCause, readDenialLimitFallback } from "./auto-mode.js";
|
|
6
6
|
import { isAskOrigin } from "./ask-origin.js";
|
|
7
7
|
import { checkpointExecutionRecorded, executionRecordDisposition, ExecutionOutcomeConflictError, } from "./checkpoint-execution-record.js";
|
|
8
8
|
import { renderUntrustedCommandText, stripFormatCharacters } from "./permission-rule-model.js";
|
|
@@ -278,6 +278,7 @@ export function summarizeCheckpoint(cp) {
|
|
|
278
278
|
return fb !== undefined ? { denialLimitFallback: fb } : {};
|
|
279
279
|
})(),
|
|
280
280
|
...(tool !== undefined && isAskOrigin(tool.origin) ? { origin: tool.origin } : {}),
|
|
281
|
+
...(tool !== undefined && isAutoModeUnavailableCause(tool.classifierUnavailable?.cause) ? { classifierUnavailable: { cause: tool.classifierUnavailable.cause } } : {}),
|
|
281
282
|
...(hasBidi ? { hasBidiControls: true } : {}),
|
|
282
283
|
};
|
|
283
284
|
}
|
|
@@ -69,15 +69,25 @@ export interface EngineNotice {
|
|
|
69
69
|
* (`MCP_INJECTION_DROP_REASONS`, CLOSED-SETS), `field` names the bad key for `malformed_entry`
|
|
70
70
|
* when known. `"user"` audience; `sessionId` REQUIRED at the mint (a user row with no session
|
|
71
71
|
* routes nowhere — refused, never fabricated). Dedup unit: per leg per server name, the host's.
|
|
72
|
-
* - `"config.models_swapped"` — `Runner.
|
|
73
|
-
* (zero-restart model switching
|
|
74
|
-
* never the
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
72
|
+
* - `"config.models_swapped"` — `Runner.swapDeps` replaced the model catalog generation
|
|
73
|
+
* (zero-restart model switching; the `models` / `tiers` seats of the swappable set). `detail:
|
|
74
|
+
* { models, tiers, occurrenceId }` — key COUNTS only, never the catalog itself. In-flight tasks
|
|
75
|
+
* finish on the models they resolved at prepare (natural snapshot); every later prepare resolves
|
|
76
|
+
* against the new generation. A failed swap (illegal tier binding, a refused candidate) throws
|
|
77
|
+
* atomically and mints nothing.
|
|
78
78
|
* `occurrenceId` (#495, uuidv7 minted at the announcement): the verb carries no run and no
|
|
79
79
|
* session, and the payload is a pair of counts, so two swaps of the same shape were byte-
|
|
80
80
|
* identical lines. Dedup key: (code, occurrenceId).
|
|
81
|
+
* - `"config.read_face_swapped"` — `Runner.swapDeps` replaced the deployment's read-face
|
|
82
|
+
* declaration (`RunnerDeps.readFace`, the `readFace` seat of the swappable set) without a
|
|
83
|
+
* restart. `detail: { from, to, occurrenceId }` — the seat's previous and new value, each
|
|
84
|
+
* `"open"` | `"roots"` | `null` (`null` = no declaration; the engine default `roots` applies).
|
|
85
|
+
* The SEAT is announced, not a leg's resolved face: resolution (task seat, governance, the
|
|
86
|
+
* read-only mount) still runs per prepare. In-flight legs keep the face they prepared under;
|
|
87
|
+
* every later prepare resolves against the new declaration. A candidate outside the value gate
|
|
88
|
+
* (`assertReadFaceValue`) throws atomically and mints nothing. Own `occurrenceId` for the same
|
|
89
|
+
* reason as the catalog line (no run, no session, a two-word payload). Dedup key:
|
|
90
|
+
* (code, occurrenceId).
|
|
81
91
|
* - `"route.fallback_to_primary"` (key↔URL pairing, `src/brain/route-adjudicator.ts`) — a
|
|
82
92
|
* DERIVED-leg model (role/tier/system-default resolution, never a caller-explicit one) failed
|
|
83
93
|
* the pairing pre-flight and the seat fell back to the primary model instead of sinking the
|
|
@@ -90,7 +100,7 @@ export interface EngineNotice {
|
|
|
90
100
|
* session falling back on the same seat used to mint byte-identical lines; `runId` is what
|
|
91
101
|
* tells one task falling back twice apart from two tasks falling back once each.
|
|
92
102
|
* Explicitly-named models never mint this: they refuse at the brain's request gate instead.
|
|
93
|
-
* - `"route.base_url_changed_key_unchanged"` (key↔URL pairing) — `Runner.
|
|
103
|
+
* - `"route.base_url_changed_key_unchanged"` (key↔URL pairing) — `Runner.swapDeps` moved a
|
|
94
104
|
* same-name entry's `baseUrl` while its Model-visible credential half (auth-bearing headers)
|
|
95
105
|
* did not change: legal (a provider changing domains), but worth one loud line — if the
|
|
96
106
|
* PROVIDER changed, the credential reference needs the same update. Advisory only, never a
|
|
@@ -484,6 +494,17 @@ export interface EngineNotice {
|
|
|
484
494
|
* access scope of the read that found the gap — from the continuation read faces (SendMessage preflight /
|
|
485
495
|
* AgentTranscript's durable leg); the per-call honest refusals are unchanged, and the declared
|
|
486
496
|
* tier is NOT auto-downgraded (declaration-制 — observation reports, it never re-adjudicates).
|
|
497
|
+
* - `"delegation.ask_unresolvable"` (#648) — an `ask` reached its FINAL deny with nobody having judged
|
|
498
|
+
* it: the approver consulted for the call (the run's own seat, or an ancestor's frozen seat on a
|
|
499
|
+
* delegated child — the sync / workflow lanes, or a background child outside the park family)
|
|
500
|
+
* answered `unavailable` (no operator reachable for this ask) and no durable approval park took
|
|
501
|
+
* the call afterwards. The deny itself is unchanged (`tool_end.gate.settlement.kind:
|
|
502
|
+
* "approver_unavailable"`, the fail-closed sentence as the tool result); this is its person-facing
|
|
503
|
+
* half. Minted at the one gate exit both unavailable deny arms reach, once per tool call, by
|
|
504
|
+
* `ask-unresolvable-notice.ts`. `detail: { sessionId, toolName, toolCallId, settlementKind,
|
|
505
|
+
* parkLaneExisted }` — `parkLaneExisted` says whether a park lane was armed and declined/failed
|
|
506
|
+
* (`true`) or none existed (`false`, the usual shape). Audience `"user"`: the refused work is the
|
|
507
|
+
* session user's own; the operator hears it through the sink like every code.
|
|
487
508
|
*
|
|
488
509
|
* - `"memory.consolidation_recommended"` (design/339 §2.2/§6.2) — the engine-minted per-scope
|
|
489
510
|
* session count crossed the consolidation thresholds (time gate open ∧ enough distinct
|
package/dist/core/gate-lanes.js
CHANGED
|
@@ -385,6 +385,9 @@ export async function runGateLanes(pass) {
|
|
|
385
385
|
pass.deniedBy = "classifier";
|
|
386
386
|
}
|
|
387
387
|
}
|
|
388
|
+
else if (verdict.kind === "unavailable") {
|
|
389
|
+
pass.decision = { ...pass.decision, classifierUnavailable: { cause: verdict.cause } };
|
|
390
|
+
}
|
|
388
391
|
}
|
|
389
392
|
if (input.sandboxAdmission !== undefined &&
|
|
390
393
|
pass.decision.action === "ask" &&
|
|
@@ -425,6 +428,7 @@ export async function runGateLanes(pass) {
|
|
|
425
428
|
...(callSignal !== undefined ? { signal: callSignal } : {}),
|
|
426
429
|
...(d.action === "ask" && d.denialLimitFallback !== undefined ? { denialLimitFallback: unarmedWindow(d.denialLimitFallback) } : {}),
|
|
427
430
|
...(d.action === "ask" && d.origin !== undefined ? { origin: d.origin } : {}),
|
|
431
|
+
...(d.action === "ask" && d.classifierUnavailable !== undefined ? { classifierUnavailable: { cause: d.classifierUnavailable.cause } } : {}),
|
|
428
432
|
});
|
|
429
433
|
if (suspendAsk && pass.decision.action === "ask") {
|
|
430
434
|
const parkArgs = [pass.req, pass.currentInput, safety, undefined, realApprovalOf(pass.decision), pass.decision.action === "ask" ? pass.decision.persistedRuleShadowed : undefined, pass.decision.action === "ask" ? pass.decision.decisionReason : undefined, pass.decision.action === "ask" ? pass.decision.probeReason : undefined, pass.decision.action === "ask" ? pass.decision.probeCause : undefined, pass.decision.action === "ask" ? pass.decision.segmentCoverage : undefined, pass.decision.action === "ask" ? pass.decision.matchedAskRule : undefined, pass.decision.action === "ask" ? pass.decision.probeMandated : undefined];
|
|
@@ -492,6 +496,16 @@ export async function runGateLanes(pass) {
|
|
|
492
496
|
return { suspend: suspended, preToolContext: pass.preToolContext };
|
|
493
497
|
}
|
|
494
498
|
}
|
|
499
|
+
const noteUnresolvable = (r) => {
|
|
500
|
+
if (r.action !== "deny" || r.approverUnavailable !== true)
|
|
501
|
+
return;
|
|
502
|
+
try {
|
|
503
|
+
input.onAskUnresolvable?.({ toolName, toolCallId, settlementKind: "approver_unavailable", parkLaneExisted: suspendAsk !== undefined });
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
noteUnresolvable(resolved);
|
|
495
509
|
if (askBeforeResolve.action === "ask" && askBeforeResolve.denialLimitFallback !== undefined) {
|
|
496
510
|
if (pass.decision.action === "allow") {
|
|
497
511
|
input.autoMode?.denialTracking?.recordAllow();
|
|
@@ -624,6 +638,7 @@ export async function runGateLanes(pass) {
|
|
|
624
638
|
const editAskReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
|
|
625
639
|
const rr = await (callSignal !== undefined ? resolveAsk(editAskDecision, editAskReq, callSignal) : resolveAsk(editAskDecision, editAskReq));
|
|
626
640
|
ledger.fromResolved(rr, editAskDecision, toolName);
|
|
641
|
+
noteUnresolvable(rr);
|
|
627
642
|
if (rr.action !== "allow") {
|
|
628
643
|
editDenied = rr;
|
|
629
644
|
pass.deniedBy = "ask_resolution";
|
|
@@ -103,7 +103,7 @@ export type NoticeAudience = "user" | "operator";
|
|
|
103
103
|
* src/ for notice mint shapes and names any code that is minted but unregistered, or registered but
|
|
104
104
|
* no longer minted.
|
|
105
105
|
*/
|
|
106
|
-
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
|
|
106
|
+
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "route.fallback_to_primary", "route.base_url_changed_key_unchanged", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "tool_result.offload_put_failed"];
|
|
107
107
|
/** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
|
|
108
108
|
* `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
|
|
109
109
|
* sink is a supported shape, and narrowing that field would break it. */
|
|
@@ -97,6 +97,7 @@ export const ENGINE_NOTICE_CODES = [
|
|
|
97
97
|
"config.env_timeout_discarded",
|
|
98
98
|
"config.materialize_env_discarded",
|
|
99
99
|
"config.models_swapped",
|
|
100
|
+
"config.read_face_swapped",
|
|
100
101
|
"config.read_face_deployment_clamped",
|
|
101
102
|
"config.tool_model_gate_removed",
|
|
102
103
|
"config.tool_model_gate_unknown_class",
|
|
@@ -113,6 +114,7 @@ export const ENGINE_NOTICE_CODES = [
|
|
|
113
114
|
"classifier.denial_limit",
|
|
114
115
|
"checkpoint.execution_outcome_unrecorded",
|
|
115
116
|
"delegation.transcript_integrity",
|
|
117
|
+
"delegation.ask_unresolvable",
|
|
116
118
|
"mcp.injection_dropped",
|
|
117
119
|
"mcp.revocation_probe_failed",
|
|
118
120
|
"workflow.governance_key_stripped",
|
|
@@ -160,10 +162,12 @@ const NOTICE_AUDIENCE_TABLE = {
|
|
|
160
162
|
"memory.capture_opted_out": "user",
|
|
161
163
|
"memory.capture_optout_unpersisted": "user",
|
|
162
164
|
"memory.consolidation_withheld": "user",
|
|
165
|
+
"delegation.ask_unresolvable": "user",
|
|
163
166
|
"config.autocompact_window_clamped": "operator",
|
|
164
167
|
"config.env_timeout_discarded": "operator",
|
|
165
168
|
"config.materialize_env_discarded": "operator",
|
|
166
169
|
"config.models_swapped": "operator",
|
|
170
|
+
"config.read_face_swapped": "operator",
|
|
167
171
|
"config.read_face_deployment_clamped": "operator",
|
|
168
172
|
"config.tool_model_gate_removed": "operator",
|
|
169
173
|
"config.tool_model_gate_unknown_class": "operator",
|