@sema-agent/core 5.14.0 → 5.16.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 +174 -0
- package/dist/agents/subagent.js +3 -2
- package/dist/brain/errors.js +21 -1
- package/dist/brain/retry.d.ts +5 -0
- package/dist/brain/retry.js +16 -4
- package/dist/brain/stream-engine.js +7 -3
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/checkpoint-store.js +48 -26
- package/dist/core/memory-engine/engine.d.ts +3 -1
- package/dist/core/memory-engine/engine.js +4 -3
- package/dist/core/memory-recall.d.ts +1 -1
- package/dist/core/memory-recall.js +3 -2
- package/dist/core/runner/prepare-memory.d.ts +1 -0
- package/dist/core/runner/prepare-memory.js +3 -3
- package/dist/core/runner/prepare-task.d.ts +3 -0
- package/dist/core/runner/prepare-task.js +43 -12
- package/dist/core/runner/runtask.js +124 -36
- package/dist/core/runner/tool-disclosure.d.ts +5 -0
- package/dist/core/runner/tool-disclosure.js +65 -16
- package/dist/core/runner/turn-attachments.d.ts +4 -0
- package/dist/core/runner/turn-attachments.js +15 -2
- package/dist/core/task-registry-agent.d.ts +2 -2
- package/dist/core/task-registry-agent.js +119 -5
- package/dist/core/task-registry.d.ts +2 -2
- package/dist/core/task-tool-shape.js +4 -3
- package/dist/core/trace.d.ts +6 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/prompts/default.d.ts +3 -3
- package/dist/prompts/default.js +2 -2
- package/dist/prompts/supervisor.d.ts +2 -2
- package/dist/prompts/supervisor.js +5 -4
- package/dist/tools/fs/fs-bash.d.ts +1 -0
- package/dist/tools/fs/fs-bash.js +1 -1
- package/dist/tools/fs/gh-rate-limit.d.ts +1 -1
- package/dist/tools/fs/gh-rate-limit.js +4 -3
- package/dist/tools/fs/index.d.ts +1 -0
- package/dist/tools/fs/index.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,181 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.16.0 — 2026-08-07
|
|
4
|
+
|
|
5
|
+
### BREAKING
|
|
6
|
+
|
|
7
|
+
- **`TaskRegistry.reviveBackgroundAgent` is now async and CLAIMS the durable row before reviving.**
|
|
8
|
+
A retained revive and a foreign tier-3 claim now arbitrate in one domain: the winner takes clean
|
|
9
|
+
write authority (`writerEpoch` bump via guarded CAS, in-memory flip only after the claim), the
|
|
10
|
+
loser gets the existing `still_running`/`not_found` refusals. A revive the store cannot confirm is
|
|
11
|
+
refused instead of running with a silently lost persistent identity; a pre-poisoned write lane
|
|
12
|
+
re-establishes ownership through the store and hands off to a fresh lane. Closes a window where a
|
|
13
|
+
session's narrowed org-admission verdict was silently dropped and a later cross-process revival
|
|
14
|
+
seeded from the stale wider one (dropped write-backs are now also disclosed via
|
|
15
|
+
`process.emitWarning`).
|
|
16
|
+
- **The memory engine's `# Memory` write instruction (and its index read-seed) require the `Write`
|
|
17
|
+
tool on the assembled roster.** `handsReadOnly: true`, hands-less runs, and
|
|
18
|
+
`excludeTools: ["Write"]` no longer receive the write instruction or seed files; the fenced index
|
|
19
|
+
still injects. Engine-direct hosts that manage their own mounts keep the historical behavior by
|
|
20
|
+
omitting the new `inject()` option. Downstream tests pinning the old always-on instruction must
|
|
21
|
+
re-pin under the new predicate.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- An `env_failed` replay binds the recorded deny/review note: `ResolvedOutcome` gains an optional
|
|
26
|
+
persisted `reason`, recorded on the approval and review lanes and compared on replay. Additive
|
|
27
|
+
compatibility: a row with no recorded note leaves that dimension unbound, so pre-existing rows
|
|
28
|
+
replay unchanged.
|
|
29
|
+
- A resume presenting an already-consumed checkpoint token is answered honestly:
|
|
30
|
+
`checkpoint.already_resolved` (confirmed against the live row) or
|
|
31
|
+
`checkpoint.reopened_concurrently` (a resolve/reopen cycle raced the resume) — never a refusal
|
|
32
|
+
claiming the row is still pending. Terminal-state refusals name the observed status without
|
|
33
|
+
coercing store-supplied values.
|
|
34
|
+
- The approval lane's `reason` is validated as plain text at capture; malformed decisions and
|
|
35
|
+
hostile text shapes get typed refusals instead of raising inside the refusal path.
|
|
36
|
+
- Orchestration guidance, the TaskOutput tool card, and the selective-recall affordance stop
|
|
37
|
+
teaching retired tool names: the workflow tool is named by its wire name, and
|
|
38
|
+
`composeSelectiveBody` accepts an optional caller-supplied `recallToolName` (additive).
|
|
39
|
+
- The gh rate-limit hint's Monitor clause follows the real Monitor mount (dropped when the Monitor
|
|
40
|
+
tool is not on the roster).
|
|
41
|
+
- The exported `MEMORY_SAFETY`/`MEMORY_HYGIENE` prompt assets are reworded name-free (they taught
|
|
42
|
+
two retired tool names; semantics unchanged).
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- `test/tool-name-literal-gate.test.ts`: every src string literal is checked against the retired
|
|
47
|
+
tool-name table on a per-directory ratchet (per-file ceilings under `src/prompts`).
|
|
48
|
+
- Standing live release legs: a deferred tool with a required structured argument must converge to a
|
|
49
|
+
real schema-valid call after activation, and the search surface must answer a live hunt.
|
|
50
|
+
|
|
51
|
+
## 5.15.0 — 2026-08-06
|
|
52
|
+
|
|
53
|
+
> Post-release addendum (2026-08-06): the new `toolDisclosure` diagnostics keys ride
|
|
54
|
+
> `prompt.assembled`. A service layer that projects that event through an allow-list must add the
|
|
55
|
+
> new keys explicitly, or they are silently dropped from its stored/forwarded copy.
|
|
56
|
+
|
|
57
|
+
### BREAKING
|
|
58
|
+
|
|
59
|
+
- **`TaskSpec.toolMaterializeStrategy` defaults to `"swap"` again** (it defaulted to `"static"` for one
|
|
60
|
+
release window). Under `"static"` a deferred tool's placeholder never swaps: the tools block keeps
|
|
61
|
+
advertising `{"type":"object","properties":{}}` and the real schema reaches the model only as text in
|
|
62
|
+
the `ToolSearch` result. That is fine for a model whose tool arguments are free-form JSON, and
|
|
63
|
+
structurally unusable for one served with **constrained decoding**, where the advertised wire schema is
|
|
64
|
+
enforced at the sampler. On the mainstream implementations — grammar compiled from the declared
|
|
65
|
+
`properties` — `{}` becomes the only argument object the model can emit, so the engine's corrective
|
|
66
|
+
invalid-arguments rejection cannot be acted on and the run burns turns without converging. (A strictly
|
|
67
|
+
draft-faithful decoder would read the absent `additionalProperties` as permissive; the model still has
|
|
68
|
+
no schema to form other keys from, so that path degrades to guessing rather than converging.)
|
|
69
|
+
The condition for `"static"` is also a property of the WHOLE serving route rather than of one model —
|
|
70
|
+
the degrading brain can fall back across providers mid-run and `limits.degrade` can hand the run to a
|
|
71
|
+
different model outright, and neither rebuilds the disclosure strategy — so it is now a strict explicit
|
|
72
|
+
opt-in: set it only if every model the run can reach, fallback targets included, has been verified to
|
|
73
|
+
read schemas from result text. **Who is affected:** a deployment that relied on the default to keep its
|
|
74
|
+
provider prompt cache intact across activations now sees the pre-`"static"` behavior (activation calls
|
|
75
|
+
`setTools`, invalidating the cache suffix) unless it sets `toolMaterializeStrategy: "static"` itself.
|
|
76
|
+
**Pinned faces that move with the default:** the `tools_delta` attachment says "now available … Their
|
|
77
|
+
full schemas are loaded" (not "now active"), the `ToolSearch` description says activation "loads its
|
|
78
|
+
full parameter schema" (not "returns … in the result"), the `ToolSearch` result no longer inlines
|
|
79
|
+
`parameters:` per matched tool, and the post-activation tools block carries the real schema again — a
|
|
80
|
+
consumer pinning any of these under the default must re-pin. `SEMA_TOOL_MATERIALIZE_STRATEGY` still
|
|
81
|
+
fills an absent spec value, and an env-sourced `"static"` on a `deferSelfResolve: false` task still
|
|
82
|
+
degrades to `"swap"` (the explicit spec pairing stays refused with `config.tool_materialize_unreachable`).
|
|
83
|
+
|
|
84
|
+
### Added
|
|
85
|
+
|
|
86
|
+
- **`prompt.assembled` reports the resolved deferred-tool disclosure strategy** —
|
|
87
|
+
`toolDisclosure: {deferredTools, strategy, source}` (new exported type `ToolDisclosureManifest`;
|
|
88
|
+
absent when the leg deferred nothing). `strategy` is the EFFECTIVE value after the direct-call-lane
|
|
89
|
+
interlock, and `source` names the seat that chose it: `"spec"` / `"env"` / `"default"` /
|
|
90
|
+
`"degraded_no_direct_lane"` (the interlock narrowed a `"static"` request to `"swap"`; that value
|
|
91
|
+
implies an env-sourced request, since the spec pairing is refused at prepare and the default is not
|
|
92
|
+
`"static"`). "Which strategy did this leg run under, and who chose it" was previously answerable only
|
|
93
|
+
by re-deriving spec > env > release default by hand.
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
|
|
97
|
+
- **Model-visible text stops teaching retired tool names** (#181 census F batch). Three faces taught
|
|
98
|
+
names a model cannot call (RB-476-A retired alias resolution — a retired name is a loud roster
|
|
99
|
+
miss): the selective-recall manifest affordance said "call Recall" (a name core registers as
|
|
100
|
+
removed; the affordance's tool name is now caller-supplied via `composeSelectiveBody`'s new
|
|
101
|
+
optional `recallToolName` parameter, name-free when omitted); both `ORCHESTRATION_GUIDANCE`
|
|
102
|
+
blocks said `run_workflow` — on the deferred short form a complete dead link, since `ToolSearch`'s
|
|
103
|
+
`select:` arm matches exact wire names — and now interpolate `RUN_WORKFLOW_TOOL_NAME`
|
|
104
|
+
(`"Workflow"`); TaskOutput's lanes description and `task_id` parameter said `RunWorkflow` and now
|
|
105
|
+
say `Workflow`. A consumer pinning any of these strings must re-pin. A new gate
|
|
106
|
+
(`test/tool-name-literal-gate.test.ts`) scans every src string literal against the whole
|
|
107
|
+
`RETIRED_TOOL_NAMES` table with a frozen per-bucket ratchet, so this class cannot regrow silently.
|
|
108
|
+
- **The `# Memory` write instruction requires the Write tool on the roster** (behavior face). The
|
|
109
|
+
memory engine's injection gated the CC-verbatim write instruction on a writable scope alone;
|
|
110
|
+
a `handsReadOnly` (or hands-less) run with a memory `writeScope` was instructed to write with a
|
|
111
|
+
tool its roster does not carry. `MemoryEngine.inject` takes an optional `writeToolMounted`
|
|
112
|
+
(omitted ⇒ historic behavior for engine-direct hosts; the Runner passes the real hands-write-half
|
|
113
|
+
predicate), and the RB-276 index seed follows the same gate. **Who is affected:** a deployment
|
|
114
|
+
running memory-enabled tasks with `handsReadOnly: true` (or no execution env) no longer receives
|
|
115
|
+
the `# Memory` instruction or the index read-seed on those runs — the fenced index still injects.
|
|
116
|
+
- **The gh rate-limit hint's Monitor clause follows the real Monitor mount.** The SR-2 reminder
|
|
117
|
+
unconditionally closed with "use the Monitor tool"; Monitor mounts only when background task tools
|
|
118
|
+
do. `ghRateLimitHint` (and `HandsToolkitOptions`/`createBashTool` `monitorToolActive`) drop exactly
|
|
119
|
+
that clause when the caller declares Monitor unmounted; omitted keeps the byte-identical historic
|
|
120
|
+
wording.
|
|
121
|
+
- **`ToolSearch` no longer activates a name the roster has withdrawn.** The deferred registry is frozen
|
|
122
|
+
at prepare; the roster is not — an MCP refresh can take a deferred name off the mount. An exact
|
|
123
|
+
`select:` still resolved through the stale registry, added the name to the active set, "succeeded" at
|
|
124
|
+
a rematerialize that mounted nothing, and announced a tool that could not be called. Withdrawn names
|
|
125
|
+
are now reported as `No longer available: <names> — withdrawn by its provider and cannot be activated
|
|
126
|
+
or called`, ride the result's `details.missing`, and never enter the active set.
|
|
127
|
+
- **The deferred placeholder's corrective round is now bounded.** A shape-invalid direct call is answered
|
|
128
|
+
with the real declared schema so the corrected call is one turn away — which assumes the caller can act
|
|
129
|
+
on a schema once it has one. A caller whose arguments are constrained to the ADVERTISED schema cannot,
|
|
130
|
+
and re-sent the identical rejected arguments for as many turns as the budget allowed. After
|
|
131
|
+
`DEFERRED_NO_PROGRESS_LIMIT` (3) consecutive rejections of the IDENTICAL failure shape on one
|
|
132
|
+
placeholder, the lane stops re-teaching: it returns a terminal rejection that names the condition with
|
|
133
|
+
the stable token `tool.deferred_schema_incompatible` (in the model-facing text and on the tool result's
|
|
134
|
+
`details.noProgress`) and votes the tool batch terminated. Any other outcome — a different validation
|
|
135
|
+
error, or a successful call — resets the count, so the bound tracks being STUCK, not being wrong.
|
|
136
|
+
`TaskResult.errorCode` is unchanged: this terminal is reported on the tool result, not as a task
|
|
137
|
+
failure code.
|
|
138
|
+
- **The invalid-arguments rejection no longer overstates where its schema lives.** It used to say "use it
|
|
139
|
+
for this and later calls" under both strategies; under `"static"` the schema is carried by that one
|
|
140
|
+
result and is gone with the next compaction. The text now says either "the next request's tools list
|
|
141
|
+
will advertise it too" (swap) or "carried by THIS result only" plus the `ToolSearch` re-select spelling
|
|
142
|
+
(static). A declaration too large to inline is called an "ABRIDGED copy" instead of "full" — past the
|
|
143
|
+
model-facing bound the serialization comes back with its middle spliced out and is not valid JSON.
|
|
144
|
+
- **A declaration the static face cannot carry in text is exempted from the static face per tool.** Under
|
|
145
|
+
`"static"` the only in-context schema carrier is result text; a declaration that does not serialize, or
|
|
146
|
+
that the model-facing bound truncates, has no carrier there, and the tool was announced as activated
|
|
147
|
+
with a schema the model never received in usable form. Such a tool now materializes into the tools block
|
|
148
|
+
on activation (the rest of the run stays static), the `ToolSearch` line says where its declaration is,
|
|
149
|
+
and the result head stops promising schemas it did not print. The `tools_delta` boundary frame carries
|
|
150
|
+
the same exception (one `Exception: <names> — too large to inline…` line) instead of asserting compact
|
|
151
|
+
placeholders for a name whose full schema is visibly on the wire; with no exemptions in play the frame
|
|
152
|
+
is byte-identical to before. The exemption is read from the LIVE roster on every call, so an MCP
|
|
153
|
+
refresh that changes a declaration's size cannot leave the lane describing the old carrier — and the
|
|
154
|
+
exemption is MONOTONE within a leg: a declaration that shrinks back under the bound does not put the
|
|
155
|
+
compact placeholder back on a tool whose schema was only ever delivered through the tools block.
|
|
156
|
+
|
|
157
|
+
### Fixed (receive-time additions)
|
|
158
|
+
|
|
159
|
+
- Ruled at receive: the deferred no-progress terminal stays a TOOL-RESULT face (`tool.deferred_schema_incompatible`
|
|
160
|
+
marker + `details.noProgress`) and does NOT mint a `TaskResult.errorCode` — the run can continue on other
|
|
161
|
+
tools, so a run-level code would misclassify a per-tool condition.
|
|
162
|
+
- Ruled at receive: a malformed `SEMA_TOOL_MATERIALIZE_STRATEGY` still refuses the task loudly even when a
|
|
163
|
+
valid spec value is present — a deployment running with a broken env var must hear about it before the
|
|
164
|
+
first task that omits the spec value inherits the typo.
|
|
165
|
+
|
|
3
166
|
## 5.14.0 — 2026-08-06
|
|
4
167
|
|
|
168
|
+
> **Post-release addendum (2026-08-06, disclosure gap reported by a consumer):** the fail-open survey
|
|
169
|
+
> (shipped in 5.13.0's window, but its consequence surfaced against 5.14.0 pickups) hardened
|
|
170
|
+
> `canonicalizeTarget`'s existence probe — an ERRORED `exists` no longer reads as "does not exist yet"
|
|
171
|
+
> and instead reports `{ok:false, unresolvedSymlink:true}`. Combined with `createSensitivePathPolicy`'s
|
|
172
|
+
> deny on `unresolvedSymlink`, an execution env whose `exists` always throws (a stub/lexical env with
|
|
173
|
+
> no real filesystem) now sees every write target denied — the gate degrades from "guarded" to
|
|
174
|
+
> "closed". That is the intended fail-closed direction for a PROBE FAILURE, but such an env should
|
|
175
|
+
> answer honestly instead of throwing: return `ok(false)` ("this env cannot see a filesystem") and the
|
|
176
|
+
> gate resolves normally. Also: `DelegationTaskType` was named in the ship note but missed the package
|
|
177
|
+
> root; the export lands in the next release — type the `taskType` key as an open `string` until then.
|
|
178
|
+
|
|
5
179
|
### BREAKING
|
|
6
180
|
|
|
7
181
|
- **`CheckpointStore.setPendingSteer` APPENDS to a bounded ordered queue instead of overwriting a single seat**
|
package/dist/agents/subagent.js
CHANGED
|
@@ -447,12 +447,13 @@ export function createSubagentResume(deps) {
|
|
|
447
447
|
signal: abort.signal,
|
|
448
448
|
};
|
|
449
449
|
if (deps.registry !== undefined && deps.taskId !== undefined && deps.taskAccess !== undefined) {
|
|
450
|
-
const revived = deps.registry.reviveBackgroundAgent(deps.taskId, deps.taskAccess, abort);
|
|
450
|
+
const revived = await deps.registry.reviveBackgroundAgent(deps.taskId, deps.taskAccess, abort);
|
|
451
451
|
if (!revived.ok) {
|
|
452
452
|
entry.resumeCount -= 1;
|
|
453
453
|
entry.cycleSeq -= 1;
|
|
454
454
|
throw configError(revived.reason === "still_running"
|
|
455
|
-
?
|
|
455
|
+
?
|
|
456
|
+
"resume unavailable: the agent's registry row is not available for a resume right now (it is running, or another revival claimed it) — wait for its completion notification and send again."
|
|
456
457
|
: "resume unavailable: the agent's registry row no longer exists (terminal GC) — relaunch a new agent instead.", revived.reason === "still_running" ? "steering.still_running" : "resume.row_gone");
|
|
457
458
|
}
|
|
458
459
|
reviveCycle = revived.cycle;
|
package/dist/brain/errors.js
CHANGED
|
@@ -42,6 +42,25 @@ export function extractErrorCode(errorMessage) {
|
|
|
42
42
|
export function stripErrorCodePrefix(errorMessage) {
|
|
43
43
|
return errorMessage.replace(CODE_PREFIX_RE, "");
|
|
44
44
|
}
|
|
45
|
+
const STALE_CONNECTION_CODES = new Set([
|
|
46
|
+
"ECONNRESET",
|
|
47
|
+
"EPIPE",
|
|
48
|
+
"ConnectionClosed",
|
|
49
|
+
"ETIMEDOUT",
|
|
50
|
+
"ECONNABORTED",
|
|
51
|
+
"ERR_SOCKET_CLOSED",
|
|
52
|
+
"StreamSuspended",
|
|
53
|
+
]);
|
|
54
|
+
function hasStaleConnectionCode(e) {
|
|
55
|
+
let cur = e;
|
|
56
|
+
for (let depth = 0; depth < 5 && cur !== undefined && cur !== null; depth++) {
|
|
57
|
+
const code = typeof cur.code === "string" ? cur.code : undefined;
|
|
58
|
+
if (code !== undefined && STALE_CONNECTION_CODES.has(code))
|
|
59
|
+
return true;
|
|
60
|
+
cur = cur instanceof Error ? cur.cause : undefined;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
45
64
|
export function describeNetworkError(e) {
|
|
46
65
|
const top = e instanceof Error ? e.message : String(e);
|
|
47
66
|
const parts = [];
|
|
@@ -54,5 +73,6 @@ export function describeNetworkError(e) {
|
|
|
54
73
|
parts.push(piece);
|
|
55
74
|
cur = cur instanceof Error ? cur.cause : undefined;
|
|
56
75
|
}
|
|
57
|
-
|
|
76
|
+
const body = parts.length > 0 ? `${top} (${parts.join(" ← ")})` : top;
|
|
77
|
+
return hasStaleConnectionCode(e) ? `${body} — possibly a stale connection; a fresh one may succeed` : body;
|
|
58
78
|
}
|
package/dist/brain/retry.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export declare function parseRetryAfter(res: Response | undefined): number | undefined;
|
|
2
2
|
export declare function parseRateLimitReset(res: Response | undefined): number | undefined;
|
|
3
|
+
export interface ProviderWaitHint {
|
|
4
|
+
readonly ms: number;
|
|
5
|
+
readonly rawMs?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function providerWaitHint(res: Response | undefined): ProviderWaitHint | undefined;
|
|
3
8
|
export declare function retryBackoffMs(baseDelayMs: number, attempt: number, res?: Response, rand?: () => number): number;
|
package/dist/brain/retry.js
CHANGED
|
@@ -24,17 +24,29 @@ export function parseRateLimitReset(res) {
|
|
|
24
24
|
const ms = Math.round(epochSecs * 1000 - Date.now());
|
|
25
25
|
return ms > 0 ? ms : undefined;
|
|
26
26
|
}
|
|
27
|
-
function
|
|
27
|
+
function clampHint(rawMs) {
|
|
28
|
+
return rawMs > MAX_HEADER_WAIT_MS ? { ms: MAX_HEADER_WAIT_MS, rawMs } : { ms: rawMs };
|
|
29
|
+
}
|
|
30
|
+
export function providerWaitHint(res) {
|
|
28
31
|
const hints = [];
|
|
29
32
|
const retryAfter = parseRetryAfter(res);
|
|
30
33
|
if (retryAfter !== undefined)
|
|
31
|
-
hints.push(
|
|
34
|
+
hints.push(clampHint(retryAfter));
|
|
32
35
|
if (res?.status === RATE_LIMIT_STATUS) {
|
|
33
36
|
const reset = parseRateLimitReset(res);
|
|
34
37
|
if (reset !== undefined)
|
|
35
|
-
hints.push(
|
|
38
|
+
hints.push(clampHint(reset));
|
|
36
39
|
}
|
|
37
|
-
|
|
40
|
+
if (hints.length === 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
return hints.reduce((largest, next) => {
|
|
43
|
+
if (next.ms !== largest.ms)
|
|
44
|
+
return next.ms > largest.ms ? next : largest;
|
|
45
|
+
return (next.rawMs ?? next.ms) > (largest.rawMs ?? largest.ms) ? next : largest;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function providerWaitHintMs(res) {
|
|
49
|
+
return providerWaitHint(res)?.ms;
|
|
38
50
|
}
|
|
39
51
|
export function retryBackoffMs(baseDelayMs, attempt, res, rand = Math.random) {
|
|
40
52
|
const exp = Math.min(MAX_BACKOFF_MS, baseDelayMs * 2 ** attempt);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createAssistantMessageEventStream, } from "../internal/llm.js";
|
|
2
2
|
import { FLOOR_OUTPUT_TOKENS, parseContextOverflow, planOutputCapAdjustment } from "./context-overflow.js";
|
|
3
3
|
import { BrainError, classifyHttp, describeNetworkError } from "./errors.js";
|
|
4
|
-
import { retryBackoffMs } from "./retry.js";
|
|
4
|
+
import { providerWaitHint, retryBackoffMs } from "./retry.js";
|
|
5
5
|
import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js";
|
|
6
6
|
import { createConnectController, resolveStallTimeoutMs } from "./timeout.js";
|
|
7
7
|
const DEFAULT_MAX_RETRIES = 10;
|
|
@@ -372,6 +372,10 @@ export function runStreamingBrain(args) {
|
|
|
372
372
|
const retryable = shouldRetryHeaderVerdict(r) ?? retryableByStatus;
|
|
373
373
|
if (retryable && attempt < maxRetries) {
|
|
374
374
|
const delayMs = retryBackoffMs(baseDelay, attempt, r);
|
|
375
|
+
const waitHint = providerWaitHint(r);
|
|
376
|
+
const truncationNote = waitHint?.rawMs !== undefined
|
|
377
|
+
? ` (the requested wait was longer than the cap; honoring ${Math.ceil(waitHint.ms / 1000)}s instead of ${Math.ceil(waitHint.rawMs / 1000)}s)`
|
|
378
|
+
: "";
|
|
375
379
|
const statusPhase = r?.status === 429 ? "rate_limited" : netErr !== undefined ? "reconnecting" : "retrying";
|
|
376
380
|
emitBrainTelemetry({ kind: "retry", attempt: attempt + 1, phase: "connect" });
|
|
377
381
|
announcedRetry = true;
|
|
@@ -381,10 +385,10 @@ export function runStreamingBrain(args) {
|
|
|
381
385
|
await sleepAnnouncingRetry(delayMs, signal, (remainingMs) => ({
|
|
382
386
|
phase: statusPhase,
|
|
383
387
|
detail: statusPhase === "rate_limited"
|
|
384
|
-
?
|
|
388
|
+
? `rate limited, backing off${truncationNote}`
|
|
385
389
|
: statusPhase === "reconnecting"
|
|
386
390
|
? "connection lost, reconnecting"
|
|
387
|
-
:
|
|
391
|
+
: `transient error, retrying${truncationNote}`,
|
|
388
392
|
retryInSec: Math.ceil(remainingMs / 1000),
|
|
389
393
|
retryInMs: remainingMs,
|
|
390
394
|
attempt: attempt + 1,
|
|
@@ -214,6 +214,7 @@ export interface ResolvedOutcome {
|
|
|
214
214
|
decision: "allow" | "deny" | "approve" | "reject" | "edit";
|
|
215
215
|
updatedInput?: unknown;
|
|
216
216
|
answer?: QuestionAnswer;
|
|
217
|
+
reason?: string;
|
|
217
218
|
}
|
|
218
219
|
export type ReopenReason = "env_failed" | "tool_unavailable";
|
|
219
220
|
export interface ResolveExpectation {
|
|
@@ -230,6 +230,7 @@ export function winnerFromOutcome(outcome) {
|
|
|
230
230
|
boundCallId: `gate:${outcome.gate}`,
|
|
231
231
|
decision: outcome.decision,
|
|
232
232
|
...(editedPlan !== undefined ? { updatedInput: editedPlan } : {}),
|
|
233
|
+
...(outcome.reason !== undefined ? { reason: outcome.reason } : {}),
|
|
233
234
|
};
|
|
234
235
|
}
|
|
235
236
|
if (outcome.gate !== "policy_ask")
|
|
@@ -239,10 +240,14 @@ export function winnerFromOutcome(outcome) {
|
|
|
239
240
|
decision: outcome.decision,
|
|
240
241
|
...(outcome.updatedInput === undefined ? {} : { updatedInput: outcome.updatedInput }),
|
|
241
242
|
...(outcome.answer === undefined ? {} : { answer: outcome.answer }),
|
|
243
|
+
...(outcome.reason === undefined ? {} : { reason: outcome.reason }),
|
|
242
244
|
};
|
|
243
245
|
}
|
|
244
246
|
const CHECKPOINT_CONTROL_CHARS_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
|
245
247
|
export function validatePendingSteer(steer) {
|
|
248
|
+
if (steer === null || typeof steer !== "object") {
|
|
249
|
+
throw new CheckpointError("steering.invalid_content", `steering entry must be an object carrying text/trusted (got ${steer === null ? "null" : typeof steer})`);
|
|
250
|
+
}
|
|
246
251
|
const allowed = new Set(PENDING_STEER_FROZEN_FIELDS.filter((f) => f !== "seq"));
|
|
247
252
|
for (const key of Object.keys(steer)) {
|
|
248
253
|
if (!allowed.has(key)) {
|
|
@@ -250,53 +255,76 @@ export function validatePendingSteer(steer) {
|
|
|
250
255
|
`a field this worker does not know would be dropped silently on the parked leg`);
|
|
251
256
|
}
|
|
252
257
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
258
|
+
const text = steer.text;
|
|
259
|
+
const trusted = steer.trusted;
|
|
260
|
+
const actor = steer.actor;
|
|
261
|
+
const inputId = steer.inputId;
|
|
262
|
+
const priority = steer.priority;
|
|
263
|
+
if (typeof text !== "string") {
|
|
264
|
+
throw new CheckpointError("steering.invalid_content", "steering text must be a string");
|
|
265
|
+
}
|
|
266
|
+
if (typeof trusted !== "boolean") {
|
|
267
|
+
throw new CheckpointError("steering.invalid_content", "steering trusted must be a boolean stated by the caller");
|
|
268
|
+
}
|
|
269
|
+
if (inputId !== undefined && typeof inputId !== "string") {
|
|
270
|
+
throw new CheckpointError("steering.invalid_content", "steering inputId must be a string when supplied");
|
|
271
|
+
}
|
|
272
|
+
if (priority !== undefined && priority !== "now" && priority !== "next" && priority !== "later") {
|
|
273
|
+
throw new CheckpointError("steering.invalid_content", `steering priority "${typeof priority === "string" ? priority : priority === null ? "null" : typeof priority}" is outside the injection-priority domain ("now", "next", "later")`);
|
|
274
|
+
}
|
|
275
|
+
const actorTwin = actor === undefined ? undefined : captureActorAssertion(actor);
|
|
276
|
+
if (inputId !== undefined && (inputId === "" || inputId.length > MAX_STEER_INPUT_ID_CHARS)) {
|
|
256
277
|
throw new CheckpointError("steering.invalid_content", `steering inputId must be a non-empty string of at most ${MAX_STEER_INPUT_ID_CHARS} characters`);
|
|
257
278
|
}
|
|
258
|
-
if (
|
|
279
|
+
if (inputId === LEGACY_PENDING_STEER_INPUT_ID) {
|
|
259
280
|
throw new CheckpointError("steering.invalid_content", `steering inputId "${LEGACY_PENDING_STEER_INPUT_ID}" is reserved for a pre-queue parked steer and cannot be supplied by a caller`);
|
|
260
281
|
}
|
|
261
|
-
if (sanitizeUntrustedText(
|
|
282
|
+
if (sanitizeUntrustedText(text) !== text) {
|
|
262
283
|
throw new CheckpointError("steering.invalid_content", "steering text must not contain a system-reminder break-out tag");
|
|
263
284
|
}
|
|
264
|
-
if (CHECKPOINT_CONTROL_CHARS_RE.test(
|
|
285
|
+
if (CHECKPOINT_CONTROL_CHARS_RE.test(text)) {
|
|
265
286
|
throw new CheckpointError("steering.invalid_content", "steering text must not contain control characters");
|
|
266
287
|
}
|
|
267
|
-
if (
|
|
268
|
-
throw new CheckpointError("steering.invalid_content", `steering text must be at most ${MAX_PENDING_STEER_CHARS} characters (got ${
|
|
288
|
+
if (text.length > MAX_PENDING_STEER_CHARS) {
|
|
289
|
+
throw new CheckpointError("steering.invalid_content", `steering text must be at most ${MAX_PENDING_STEER_CHARS} characters (got ${text.length})`);
|
|
269
290
|
}
|
|
270
291
|
return {
|
|
271
|
-
text
|
|
272
|
-
trusted
|
|
273
|
-
...(
|
|
274
|
-
inputId:
|
|
275
|
-
...(
|
|
292
|
+
text,
|
|
293
|
+
trusted,
|
|
294
|
+
...(actorTwin !== undefined ? { actor: actorTwin } : {}),
|
|
295
|
+
inputId: inputId ?? uuidv7(),
|
|
296
|
+
...(priority !== undefined ? { priority: priority } : {}),
|
|
276
297
|
};
|
|
277
298
|
}
|
|
278
299
|
export const MAX_ACTOR_FIELD_CHARS = 256;
|
|
279
300
|
export const MAX_STEER_INPUT_ID_CHARS = 128;
|
|
280
301
|
export const ACTOR_ASSERTION_FROZEN_FIELDS = ["id", "hostAsserted", "issuer"];
|
|
281
|
-
function
|
|
302
|
+
function captureActorAssertion(actor) {
|
|
303
|
+
if (actor === null || typeof actor !== "object") {
|
|
304
|
+
throw new CheckpointError("steering.invalid_content", `actor must be an object carrying the attribution fields (got ${actor === null ? "null" : typeof actor})`);
|
|
305
|
+
}
|
|
306
|
+
const supplied = actor;
|
|
282
307
|
const allowed = new Set(ACTOR_ASSERTION_FROZEN_FIELDS);
|
|
283
308
|
for (const key of Object.keys(actor)) {
|
|
284
309
|
if (!allowed.has(key)) {
|
|
285
310
|
throw new CheckpointError("steering.invalid_content", `unknown actor field "${key}" — the persisted actor shape is frozen (${[...allowed].join(", ")})`);
|
|
286
311
|
}
|
|
287
312
|
}
|
|
288
|
-
|
|
313
|
+
const id = supplied.id;
|
|
314
|
+
const hostAsserted = supplied.hostAsserted;
|
|
315
|
+
const issuer = supplied.issuer;
|
|
316
|
+
if (typeof id !== "string" || id === "" || id.length > MAX_ACTOR_FIELD_CHARS) {
|
|
289
317
|
throw new CheckpointError("steering.invalid_content", `actor.id must be a non-empty namespaced string of at most ${MAX_ACTOR_FIELD_CHARS} characters (e.g. "slack:U123")`);
|
|
290
318
|
}
|
|
291
|
-
if (typeof
|
|
319
|
+
if (typeof hostAsserted !== "boolean") {
|
|
292
320
|
throw new CheckpointError("steering.invalid_content", "actor.hostAsserted must be a boolean stated by the host");
|
|
293
321
|
}
|
|
294
|
-
if (
|
|
322
|
+
if (issuer !== undefined && (typeof issuer !== "string" || issuer === "" || issuer.length > MAX_ACTOR_FIELD_CHARS)) {
|
|
295
323
|
throw new CheckpointError("steering.invalid_content", `actor.issuer must be a non-empty string of at most ${MAX_ACTOR_FIELD_CHARS} characters`);
|
|
296
324
|
}
|
|
297
325
|
for (const [field, value] of [
|
|
298
|
-
["id",
|
|
299
|
-
["issuer",
|
|
326
|
+
["id", id],
|
|
327
|
+
["issuer", issuer],
|
|
300
328
|
]) {
|
|
301
329
|
if (value === undefined)
|
|
302
330
|
continue;
|
|
@@ -304,13 +332,7 @@ function validateActorAssertion(actor) {
|
|
|
304
332
|
throw new CheckpointError("steering.invalid_content", `actor.${field} must not contain control characters or a system-reminder break-out tag`);
|
|
305
333
|
}
|
|
306
334
|
}
|
|
307
|
-
}
|
|
308
|
-
function freezeActorAssertion(actor) {
|
|
309
|
-
return {
|
|
310
|
-
id: actor.id,
|
|
311
|
-
hostAsserted: actor.hostAsserted,
|
|
312
|
-
...(actor.issuer !== undefined ? { issuer: actor.issuer } : {}),
|
|
313
|
-
};
|
|
335
|
+
return { id, hostAsserted, ...(issuer !== undefined ? { issuer } : {}) };
|
|
314
336
|
}
|
|
315
337
|
export function readPendingSteerQueue(state) {
|
|
316
338
|
const legacy = state.pendingSteer === undefined
|
|
@@ -49,7 +49,9 @@ export declare class MemoryEngine {
|
|
|
49
49
|
constructor(opts: MemoryEngineOptions);
|
|
50
50
|
private discloseAnnounceFailure;
|
|
51
51
|
materialize(scopes: readonly string[], writeScope: string | null): Promise<MemorySessionHandle>;
|
|
52
|
-
inject(handle: MemorySessionHandle
|
|
52
|
+
inject(handle: MemorySessionHandle, opts?: {
|
|
53
|
+
writeToolMounted?: boolean;
|
|
54
|
+
}): MemoryInjection;
|
|
53
55
|
gateWrite(handle: MemorySessionHandle, canonicalPath: string, content: string): {
|
|
54
56
|
ok: true;
|
|
55
57
|
} | {
|
|
@@ -188,14 +188,15 @@ export class MemoryEngine {
|
|
|
188
188
|
}
|
|
189
189
|
return handle;
|
|
190
190
|
}
|
|
191
|
-
inject(handle) {
|
|
192
|
-
const
|
|
191
|
+
inject(handle, opts) {
|
|
192
|
+
const writeChannel = handle.writeScope !== null && opts?.writeToolMounted !== false;
|
|
193
|
+
const instruction = writeChannel ? buildMemoryInstruction(handle.writableRoot) : "";
|
|
193
194
|
const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
|
|
194
195
|
const onDisk = readSafe(indexPath);
|
|
195
196
|
const indexText = onDisk !== undefined && onDisk.trim() !== "" ? onDisk : handle.indexText;
|
|
196
197
|
const truncated = truncateIndex(indexText);
|
|
197
198
|
const index = composeMemoryBlock(truncated, handle.writeScope ?? handle.scopes[0] ?? "memory");
|
|
198
|
-
const indexSeed =
|
|
199
|
+
const indexSeed = writeChannel && onDisk !== undefined && (onDisk.trim() === "" || truncated === onDisk)
|
|
199
200
|
? { path: indexPath, content: onDisk }
|
|
200
201
|
: undefined;
|
|
201
202
|
let announcements;
|
|
@@ -36,7 +36,7 @@ export type SelectiveRecallResult = {
|
|
|
36
36
|
export declare function resolveLinkedIds(headers: MemoryNoteHeader[], selected: MemoryNoteRecord[], max: number): string[];
|
|
37
37
|
export declare function buildManifestText(headers: MemoryNoteHeader[]): string;
|
|
38
38
|
export declare function validateSelectedIds(headers: MemoryNoteHeader[], ids: string[], max: number): string[];
|
|
39
|
-
export declare function composeSelectiveBody(manifestText: string, selected: MemoryNoteRecord[], nowMs: number, linked?: MemoryNoteRecord[], recallable?: boolean): string;
|
|
39
|
+
export declare function composeSelectiveBody(manifestText: string, selected: MemoryNoteRecord[], nowMs: number, linked?: MemoryNoteRecord[], recallable?: boolean, recallToolName?: string): string;
|
|
40
40
|
export declare function encodeSurfacedKey(scope: string, id: string): string;
|
|
41
41
|
export interface ScopedNoteHeader extends MemoryNoteHeader {
|
|
42
42
|
scope: string;
|
|
@@ -102,7 +102,7 @@ export function validateSelectedIds(headers, ids, max) {
|
|
|
102
102
|
}
|
|
103
103
|
return out;
|
|
104
104
|
}
|
|
105
|
-
export function composeSelectiveBody(manifestText, selected, nowMs, linked = [], recallable = true) {
|
|
105
|
+
export function composeSelectiveBody(manifestText, selected, nowMs, linked = [], recallable = true, recallToolName) {
|
|
106
106
|
const renderNote = (r, label) => {
|
|
107
107
|
const ageMs = nowMs - r.mtimeMs;
|
|
108
108
|
const verify = r.timestampMissing || ageMs > ONE_DAY_MS ? " — verify it's still current" : "";
|
|
@@ -124,8 +124,9 @@ export function composeSelectiveBody(manifestText, selected, nowMs, linked = [],
|
|
|
124
124
|
body = `${buf.subarray(0, cut).toString("utf8")}\n<selected notes truncated to ${SELECTED_BODY_MAX_BYTES} bytes>`;
|
|
125
125
|
}
|
|
126
126
|
const selectedSection = selected.length > 0 ? `\n\nRelevant notes:\n${body}` : "\n\n(no notes selected as relevant to this task)";
|
|
127
|
+
const recallCall = recallToolName !== undefined ? `call ${recallToolName}` : `search the memory-recall tool bound to this scope`;
|
|
127
128
|
const manifestSection = recallable
|
|
128
|
-
? `Memory index — to load a note in full,
|
|
129
|
+
? `Memory index — to load a note in full, ${recallCall} with keywords from its description:\n${sanitizeUntrustedText(manifestText, ["user_memory"])}`
|
|
129
130
|
: "";
|
|
130
131
|
return `${manifestSection}${selectedSection}`;
|
|
131
132
|
}
|
|
@@ -135,7 +135,7 @@ export async function prepareMemory(input) {
|
|
|
135
135
|
const writeIsPersonal = p.writePlane === "personal";
|
|
136
136
|
writeEngine = writeIsPersonal ? personalEngine : projectEngine;
|
|
137
137
|
writeHandle = writeIsPersonal ? personalHandle : projectHandle;
|
|
138
|
-
injectFn = () => mergeInjections(projectEngine.inject(projectHandle), personalEngine.inject(personalHandle));
|
|
138
|
+
injectFn = () => mergeInjections(projectEngine.inject(projectHandle, { writeToolMounted: input.writeToolsMounted }), personalEngine.inject(personalHandle, { writeToolMounted: input.writeToolsMounted }));
|
|
139
139
|
harvestBoth = async () => {
|
|
140
140
|
const writeFirst = writeIsPersonal ? [personalEngine, personalHandle] : [projectEngine, projectHandle];
|
|
141
141
|
const readOther = writeIsPersonal ? [projectEngine, projectHandle] : [personalEngine, personalHandle];
|
|
@@ -159,7 +159,7 @@ export async function prepareMemory(input) {
|
|
|
159
159
|
const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope);
|
|
160
160
|
writeEngine = personalEngine;
|
|
161
161
|
writeHandle = handle;
|
|
162
|
-
injectFn = () => personalEngine.inject(handle);
|
|
162
|
+
injectFn = () => personalEngine.inject(handle, { writeToolMounted: input.writeToolsMounted });
|
|
163
163
|
harvestBoth = () => personalEngine.harvest(handle);
|
|
164
164
|
}
|
|
165
165
|
else {
|
|
@@ -171,7 +171,7 @@ export async function prepareMemory(input) {
|
|
|
171
171
|
const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope);
|
|
172
172
|
writeEngine = engine;
|
|
173
173
|
writeHandle = handle;
|
|
174
|
-
injectFn = () => engine.inject(handle);
|
|
174
|
+
injectFn = () => engine.inject(handle, { writeToolMounted: input.writeToolsMounted });
|
|
175
175
|
harvestBoth = () => engine.harvest(handle);
|
|
176
176
|
}
|
|
177
177
|
memoryWriteGateRef.current = (w) => writeEngine.gateWrite(writeHandle, w.key, w.content);
|
|
@@ -16,6 +16,7 @@ import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
|
|
|
16
16
|
import { type OutputRef, type BlockedRef, type SkillListingEntry } from "./synthetic-tools.js";
|
|
17
17
|
import type { MemoryEngine } from "../memory-engine/engine.js";
|
|
18
18
|
import { type ToolManifestRow } from "../../prompt-assembly/tool-catalog.js";
|
|
19
|
+
import type { ToolDisclosureManifest } from "../trace.js";
|
|
19
20
|
import type { TaskNotificationPayload } from "../task-notification.js";
|
|
20
21
|
import { type CwdRef } from "../../tools/fs/index.js";
|
|
21
22
|
import { type WorkflowSizeGuideline } from "../../orchestration/workflow-size-guideline.js";
|
|
@@ -111,6 +112,7 @@ export interface Prepared {
|
|
|
111
112
|
elements: import("../../prompt-assembly/turn-snapshot.js").CacheIdentityElements;
|
|
112
113
|
};
|
|
113
114
|
lowering?: import("../../prompt-assembly/turn-snapshot.js").LoweringRecord;
|
|
115
|
+
toolDisclosure?: ToolDisclosureManifest;
|
|
114
116
|
};
|
|
115
117
|
epochDeclaredSections: import("../../prompt-assembly/epoch.js").EpochDeclaredSections;
|
|
116
118
|
turnSnapshot?: import("../../prompt-assembly/turn-snapshot.js").TurnPromptSnapshot;
|
|
@@ -125,6 +127,7 @@ export interface Prepared {
|
|
|
125
127
|
activeTools: Set<string>;
|
|
126
128
|
deferredToolNames?: ReadonlySet<string>;
|
|
127
129
|
toolMaterializeStatic: boolean;
|
|
130
|
+
staticFaceFor?: (name: string) => boolean;
|
|
128
131
|
memoryEngineSession?: {
|
|
129
132
|
engine: MemoryEngine;
|
|
130
133
|
handle: MemorySessionHandle;
|