@deepstrike/wasm 0.2.34 → 0.2.36

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/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://github.com/kongusen/deepstrike">
3
+ <img src="https://raw.githubusercontent.com/kongusen/deepstrike/main/docs/public/banner.png" alt="DeepStrike" width="420" />
4
+ </a>
5
+ </p>
6
+
1
7
  # DeepStrike WASM SDK
2
8
 
3
9
  Runtime framework built on a Rust kernel compiled to WebAssembly. Runs in browsers, Cloudflare Workers, Deno Deploy, and Vercel Edge — anywhere that supports `fetch` and WASM.
@@ -250,6 +250,8 @@ export class AnthropicProvider {
250
250
  const inputTokens = uncachedInput + cacheReadTokens + cacheCreationTokens;
251
251
  if (inputTokens > 0 || outputTokens > 0) {
252
252
  const bySlot = estimateCacheReadBySlot(cacheReadTokens, slotBp);
253
+ // stop_reason rides on message_delta; "max_tokens" drives output-cap recovery.
254
+ const stopReason = evt.delta?.stop_reason;
253
255
  yield {
254
256
  type: "usage",
255
257
  totalTokens: inputTokens + outputTokens,
@@ -258,6 +260,7 @@ export class AnthropicProvider {
258
260
  cacheReadInputTokens: cacheReadTokens,
259
261
  cacheCreationInputTokens: cacheCreationTokens,
260
262
  ...(bySlot ? { cacheReadInputTokensBySlot: bySlot } : {}),
263
+ ...(stopReason ? { stopReason } : {}),
261
264
  };
262
265
  }
263
266
  }
@@ -37,6 +37,9 @@ export class OpenAIProvider {
37
37
  const reader = resp.body.getReader();
38
38
  const decoder = new TextDecoder();
39
39
  let buf = "";
40
+ // Phase 4: OpenAI signals an output-cap truncation via finish_reason="length"; the kernel
41
+ // treats it as a truncation (== Anthropic "max_tokens") and drives output-cap recovery.
42
+ let finishReason;
40
43
  while (true) {
41
44
  const { done, value } = await reader.read();
42
45
  if (done)
@@ -48,10 +51,17 @@ export class OpenAIProvider {
48
51
  if (!line.startsWith("data: "))
49
52
  continue;
50
53
  const data = line.slice(6).trim();
51
- if (data === "[DONE]")
54
+ if (data === "[DONE]") {
55
+ // Surface an output-cap truncation (finish_reason="length") to the runner/kernel. Emitted
56
+ // as a usage frame (the channel the runner reads stopReason from) before the early return.
57
+ if (finishReason)
58
+ yield { type: "usage", totalTokens: 0, stopReason: finishReason };
52
59
  return;
60
+ }
53
61
  try {
54
62
  const chunk = JSON.parse(data);
63
+ if (chunk.choices?.[0]?.finish_reason)
64
+ finishReason = chunk.choices[0].finish_reason ?? undefined;
55
65
  const delta = chunk.choices?.[0]?.delta;
56
66
  if (!delta)
57
67
  continue;
@@ -45,5 +45,12 @@ export declare class LargeResultSpool {
45
45
  processToolResult(result: ToolResult): Promise<SpooledToolResult>;
46
46
  persistOutput(callId: string, content: string): Promise<string>;
47
47
  readSpooledResult(spoolRef: string): Promise<string>;
48
+ /**
49
+ * O7: locate a spooled output by the tool call's id (the `read_result` meta-tool only knows
50
+ * `call_id`, not the content-hashed key `persistOutput` chose). Scans the driver's key list for
51
+ * the `.spool/${callId}-*.txt` naming convention; returns `undefined` if nothing was ever
52
+ * spooled for that call.
53
+ */
54
+ findByCallId(callId: string): Promise<string | undefined>;
48
55
  cleanup(maxAgeMs?: number): Promise<number>;
49
56
  }
@@ -113,6 +113,31 @@ omitted: ${omitted} chars
113
113
  async readSpooledResult(spoolRef) {
114
114
  return this.driver.read(spoolRef);
115
115
  }
116
+ /**
117
+ * O7: locate a spooled output by the tool call's id (the `read_result` meta-tool only knows
118
+ * `call_id`, not the content-hashed key `persistOutput` chose). Scans the driver's key list for
119
+ * the `.spool/${callId}-*.txt` naming convention; returns `undefined` if nothing was ever
120
+ * spooled for that call.
121
+ */
122
+ async findByCallId(callId) {
123
+ let keys;
124
+ try {
125
+ keys = await this.driver.list();
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ const prefix = `.spool/${callId}-`;
131
+ const match = keys.find(k => k.startsWith(prefix) && k.endsWith('.txt'));
132
+ if (!match)
133
+ return undefined;
134
+ try {
135
+ return await this.driver.read(match);
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
116
141
  async cleanup(maxAgeMs) {
117
142
  const limit = maxAgeMs ?? this.config.maxAgeMs ?? 7 * 24 * 60 * 60 * 1000;
118
143
  try {
@@ -2,7 +2,7 @@ import type { LLMProvider, Message, StreamEvent, PermissionRequestEvent, Permiss
2
2
  import type { ToolSuspendEvent } from "./execution-plane.js";
3
3
  import type { DreamStore, DreamResult } from "../memory/index.js";
4
4
  import type { KnowledgeSource } from "../knowledge/index.js";
5
- import type { SignalSource } from "../signals/index.js";
5
+ import type { SignalSource, RuntimeSignal } from "../signals/index.js";
6
6
  import type { SessionLog, SessionEvent } from "./session-log.js";
7
7
  import type { ExecutionPlane } from "./execution-plane.js";
8
8
  import { type GovernancePolicy } from "../governance.js";
@@ -59,6 +59,16 @@ export interface TurnMetrics {
59
59
  };
60
60
  cacheCreationTokens: number;
61
61
  }
62
+ /** O5: decision returned by `onToolCall` — `block: true` denies this call before it executes. */
63
+ export interface ToolCallHookDecision {
64
+ block?: boolean;
65
+ reason?: string;
66
+ }
67
+ /** O5: decision returned by `onToolResult` — replace the output and/or inject a signal note. */
68
+ export interface ToolResultHookDecision {
69
+ replaceOutput?: string;
70
+ note?: string;
71
+ }
62
72
  export interface RuntimeOptions {
63
73
  provider: LLMProvider;
64
74
  /** M1/G3 intelligence routing: resolve a per-node provider from a workflow node's `modelHint`.
@@ -78,6 +88,8 @@ export interface RuntimeOptions {
78
88
  * the knowledge partition before turn 1. Requires dreamStore + agentId. */
79
89
  preQueryMemory?: (ctx: {
80
90
  goal: string;
91
+ /** K4: `"initial"` = pre-turn-1 fetch; `"renewal"` = re-fired after a sprint renewal. */
92
+ phase?: "initial" | "renewal";
81
93
  }) => Promise<string[] | undefined> | string[] | undefined;
82
94
  systemPrompt?: string;
83
95
  initialMemory?: string[];
@@ -95,6 +107,39 @@ export interface RuntimeOptions {
95
107
  };
96
108
  schedulerBudget?: SchedulerBudget;
97
109
  resourceQuota?: ResourceQuota;
110
+ /** O6: the in-kernel repeat fuse — identical tool call (same name AND args) `denyAfter` turns in a
111
+ * row ⇒ deny + directive note; `terminateAfter` ⇒ run ends `no_progress`. Defaults 5/8; `false`
112
+ * disables. Same-tool/different-args loops never trip it. */
113
+ repeatFuse?: {
114
+ denyAfter?: number;
115
+ terminateAfter?: number;
116
+ } | false;
117
+ /** O4: turn-end criteria gate — one kernel-injected self-check turn before accepting completion
118
+ * while `criteria` stand. Default enabled; `false` accepts the first finish unconditionally. */
119
+ criteriaGate?: boolean;
120
+ /** K2: max share of `maxTokens` the durable knowledge partition may occupy. Over budget ⇒
121
+ * warn-once observation + oldest unpinned non-skill entries evicted at the next boundary.
122
+ * Pinned/skill entries are exempt. `0` disables. Default: kernel's 0.25. */
123
+ knowledgeBudgetRatio?: number;
124
+ /** K3: default lease (in turns) for every skill activation — auto-deactivates after N turns
125
+ * (toolset re-widens, knowledge pin boundary-swept). Absent ⇒ permanent (default). */
126
+ skillLeaseTurns?: number;
127
+ /** O5 (PreToolUse-hook analog): stateful host veto over each kernel-approved call; return
128
+ * `{ block: true, reason }` to deny — the reason reaches the model as a denied result. Errs-open. */
129
+ onToolCall?: (call: {
130
+ callId: string;
131
+ name: string;
132
+ arguments: string;
133
+ }) => Promise<ToolCallHookDecision | undefined | void> | ToolCallHookDecision | undefined | void;
134
+ /** O5 (PostToolUse-hook analog): inspect each executed result; `{ replaceOutput }` swaps what the
135
+ * model sees, `{ note }` injects a signal note (the `injectNote` channel). Errs-open. */
136
+ onToolResult?: (result: {
137
+ callId: string;
138
+ name: string;
139
+ arguments: string;
140
+ output: string;
141
+ isError: boolean;
142
+ }) => Promise<ToolResultHookDecision | undefined | void> | ToolResultHookDecision | undefined | void;
98
143
  memoryPolicy?: MemoryPolicy;
99
144
  tokenizer?: string;
100
145
  enablePlanTool?: boolean;
@@ -140,8 +185,15 @@ export declare class RuntimeRunner {
140
185
  private pendingObservations;
141
186
  private activeKernel;
142
187
  private currentSessionId;
188
+ /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
189
+ private injectedSignals;
190
+ /** Skill names whose content has already been pushed into the durable `knowledge` slot this
191
+ * run — guards against re-pushing a duplicate entry if the model calls `skill(name)` again for
192
+ * an already-active skill (loading is idempotent; the knowledge push should be too). */
193
+ private knowledgePushedSkills;
194
+ /** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
195
+ private currentGoal;
143
196
  private nextArchiveStart;
144
- private localPageOutCache;
145
197
  /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
146
198
  * at the next safe point (after the tool turn resolves, kernel back in Reason). */
147
199
  private pendingAuthoredWorkflows;
@@ -149,6 +201,15 @@ export declare class RuntimeRunner {
149
201
  constructor(opts: RuntimeOptions);
150
202
  get hostOptions(): RuntimeOptions;
151
203
  interrupt(): void;
204
+ /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
205
+ * the next turn boundary, routes through the kernel attention policy, and — once acted on — renders
206
+ * as a `[SIGNAL] <text>` line in the volatile state turn plus a durable directive. `urgency` maps to
207
+ * the kernel disposition ladder: `"normal"` queues (default), `"high"` soft-interrupts, `"critical"`
208
+ * preempts. */
209
+ injectNote(text: string, urgency?: RuntimeSignal["urgency"]): void;
210
+ /** Injected-note drain shared with the main loop's per-turn poll: injected notes first (FIFO), then
211
+ * the configured `signalSource` — one code path so the two inbound channels never drift. */
212
+ private nextInboundSignal;
152
213
  run(req: {
153
214
  sessionId: string;
154
215
  goal: string;
@@ -160,11 +221,29 @@ export declare class RuntimeRunner {
160
221
  }>;
161
222
  }): AsyncIterable<StreamEvent>;
162
223
  wake(sessionId: string, extensions?: Record<string, unknown>): AsyncIterable<StreamEvent>;
163
- /** Push content into Slot 2 (system_knowledge) via add_knowledge_message. */
164
- pushKnowledge(message: Message, tokens?: number): void;
165
- /** Phase 4: satisfy kernel page-in requests before meta-tool execution. */
166
- private applyKernelPageIn;
224
+ /** Push content into Slot 2 (system_knowledge) via add_knowledge_message.
225
+ * K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
226
+ * compaction/renewal boundary) instead of appending a duplicate. `opts.pinned` exempts the
227
+ * entry from the knowledge-budget sweep. */
228
+ pushKnowledge(message: Message, tokens?: number, opts?: {
229
+ key?: string;
230
+ pinned?: boolean;
231
+ }): void;
232
+ /** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
233
+ * Errs-open: an unknown key is a kernel-side no-op. */
234
+ removeKnowledge(key: string): void;
235
+ /** K3: host-driven skill deactivation — toolset re-widens at the next provider call, the
236
+ * skill's knowledge pin drops at the next boundary. Errs-open: not-active is a no-op. */
237
+ deactivateSkill(name: string): void;
167
238
  private resolveKernelSuspend;
239
+ /**
240
+ * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
241
+ * output. Resolution order: (a) this turn's in-memory `pendingSpoolOutputs` map, (b) the result
242
+ * spool (persisted once the kernel observation `large_result_spooled` was processed), (c) a
243
+ * session-log scan for the original `tool_completed` event carrying that `call_id`. Slices the
244
+ * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
245
+ */
246
+ private resolveReadResult;
168
247
  dream(agentId: string, nowMs?: number): Promise<DreamResult>;
169
248
  private execute;
170
249
  spawnSubAgent(spec: AgentRunSpec): Promise<SubAgentResult>;
@@ -256,6 +335,12 @@ export declare class RuntimeRunner {
256
335
  failed: string[];
257
336
  }>;
258
337
  private appendObservations;
338
+ /** I4 + K4: fetch long-term memory hits for the current goal and land them in `history` as an
339
+ * ordinary user turn — single-use retrieval content that decays with the compression pyramid,
340
+ * never pinned into `knowledge`. `phase: "initial"` = once before turn 1; `phase: "renewal"` =
341
+ * re-fired after each sprint renewal (renewal drops the old history INCLUDING earlier memory
342
+ * hits). Errs-open throughout. */
343
+ private prefetchMemoryIntoHistory;
259
344
  private archiveSemanticPageOut;
260
345
  }
261
346
  export declare function collectText(stream: AsyncIterable<StreamEvent>): Promise<string>;
@@ -5,13 +5,13 @@ import { peekProviderReplay, seedProviderReplayFromEvents } from "./provider-rep
5
5
  import { sanitizeReplayText } from "./replay-sanitize.js";
6
6
  import { formatToolError } from "../tools/errors.js";
7
7
  import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, recoverCompletedWorkflowNodes, recoverSubmittedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
8
- import { forceCompact, kernelAction, kernelApply, kernelMaybeAction, messageToKernelMessage, skillMetadataToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
8
+ import { kernelAction, kernelApply, kernelMaybeAction, messageToKernelMessage, skillMetadataToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
9
9
  import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass, milestoneCheckResultToKernel, spawnObservationToManifest, subAgentResultToKernel, submitWorkflowNodesToKernel, submitWorkflowToKernel, workflowBudgetNote, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "./types/agent.js";
10
10
  import { defaultSubAgentOrchestrator } from "./sub-agent-orchestrator.js";
11
11
  import { extractJsonValue, schemaInstruction, schemaRetryInstruction, validateAgainstSchema, } from "./output-schema.js";
12
12
  import { resolveReducer } from "./reducers.js";
13
13
  import { loopInstruction, classifyInstruction, judgeGoal, extractLoopContinue, extractClassifyBranch, extractJudgeWinner, } from "./workflow-control-flow.js";
14
- import { kernelObservationToSessionEvent, withCategory } from "./kernel-event-log.js";
14
+ import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
15
15
  import { assertNativeProfile } from "./os-profile.js";
16
16
  import { LargeResultSpool } from "./large-result-spool.js";
17
17
  export class RuntimeRunner {
@@ -22,8 +22,15 @@ export class RuntimeRunner {
22
22
  pendingObservations = [];
23
23
  activeKernel = null;
24
24
  currentSessionId = null;
25
+ /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
26
+ injectedSignals = [];
27
+ /** Skill names whose content has already been pushed into the durable `knowledge` slot this
28
+ * run — guards against re-pushing a duplicate entry if the model calls `skill(name)` again for
29
+ * an already-active skill (loading is idempotent; the knowledge push should be too). */
30
+ knowledgePushedSkills = new Set();
31
+ /** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
32
+ currentGoal = "";
25
33
  nextArchiveStart = 0;
26
- localPageOutCache = [];
27
34
  /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
28
35
  * at the next safe point (after the tool turn resolves, kernel back in Reason). */
29
36
  pendingAuthoredWorkflows = [];
@@ -33,6 +40,24 @@ export class RuntimeRunner {
33
40
  }
34
41
  get hostOptions() { return this.opts; }
35
42
  interrupt() { this.interrupted = true; this.abortController?.abort(); }
43
+ /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
44
+ * the next turn boundary, routes through the kernel attention policy, and — once acted on — renders
45
+ * as a `[SIGNAL] <text>` line in the volatile state turn plus a durable directive. `urgency` maps to
46
+ * the kernel disposition ladder: `"normal"` queues (default), `"high"` soft-interrupts, `"critical"`
47
+ * preempts. */
48
+ injectNote(text, urgency = "normal") {
49
+ this.injectedSignals.push({ source: "custom", signalType: "event", urgency, payload: { goal: text } });
50
+ }
51
+ /** Injected-note drain shared with the main loop's per-turn poll: injected notes first (FIFO), then
52
+ * the configured `signalSource` — one code path so the two inbound channels never drift. */
53
+ async nextInboundSignal() {
54
+ const injected = this.injectedSignals.shift();
55
+ if (injected)
56
+ return injected;
57
+ if (!this.opts.signalSource)
58
+ return null;
59
+ return this.opts.signalSource.nextSignal();
60
+ }
36
61
  async *run(req) {
37
62
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
38
63
  const midRun = isMidRun(prior);
@@ -58,59 +83,35 @@ export class RuntimeRunner {
58
83
  const start = startEntry.event;
59
84
  yield* this.execute(sessionId, start.goal, start.criteria, extensions, events, true);
60
85
  }
61
- /** Push content into Slot 2 (system_knowledge) via add_knowledge_message. */
62
- pushKnowledge(message, tokens) {
86
+ /** Push content into Slot 2 (system_knowledge) via add_knowledge_message.
87
+ * K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
88
+ * compaction/renewal boundary) instead of appending a duplicate. `opts.pinned` exempts the
89
+ * entry from the knowledge-budget sweep. */
90
+ pushKnowledge(message, tokens, opts) {
63
91
  if (!this.activeKernel)
64
92
  return;
65
93
  kernelApply(this.activeKernel, this.pendingObservations, {
66
94
  kind: "add_knowledge_message",
67
95
  content: message.content ?? "",
68
96
  tokens: tokens ?? Math.max(1, Math.ceil((message.content?.length ?? 0) / 4)),
97
+ ...(opts?.key !== undefined ? { key: opts.key } : {}),
98
+ ...(opts?.pinned ? { pinned: true } : {}),
69
99
  });
70
100
  }
71
- /** Phase 4: satisfy kernel page-in requests before meta-tool execution. */
72
- async applyKernelPageIn(runtime, sessionId) {
73
- const requests = this.pendingObservations.filter((o) => o.kind === "page_in_requested" && typeof o.tool === "string");
74
- if (requests.length === 0)
101
+ /** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
102
+ * Errs-open: an unknown key is a kernel-side no-op. */
103
+ removeKnowledge(key) {
104
+ if (!this.activeKernel)
75
105
  return;
76
- const entries = [];
77
- for (const req of requests) {
78
- const query = typeof req.query === "string" ? req.query : "";
79
- const topK = typeof req.top_k === "number" ? req.top_k : 5;
80
- if (req.tool === "memory") {
81
- const localHits = this.localPageOutCache.filter(m => typeof m.content === "string" && m.content.toLowerCase().includes(query.toLowerCase())).slice(0, topK);
82
- for (const hit of localHits) {
83
- entries.push({
84
- content: `[local semantic cache] ${hit.role}: ${hit.content}`,
85
- source: "semantic_cache",
86
- });
87
- }
88
- const remainingK = topK - entries.length;
89
- if (remainingK > 0 && this.opts.dreamStore && this.opts.agentId) {
90
- const hits = await this.opts.dreamStore.search(this.opts.agentId, query, remainingK);
91
- for (const hit of hits) {
92
- entries.push({
93
- content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`,
94
- source: "memory",
95
- });
96
- }
97
- }
98
- }
99
- else if (req.tool === "knowledge" && this.opts.knowledgeSource) {
100
- const snippets = await this.opts.knowledgeSource.retrieve(query, topK);
101
- for (const snippet of snippets) {
102
- entries.push({ content: snippet, source: "knowledge" });
103
- }
104
- }
105
- }
106
- if (entries.length === 0)
106
+ kernelApply(this.activeKernel, this.pendingObservations, { kind: "remove_knowledge", key });
107
+ }
108
+ /** K3: host-driven skill deactivation toolset re-widens at the next provider call, the
109
+ * skill's knowledge pin drops at the next boundary. Errs-open: not-active is a no-op. */
110
+ deactivateSkill(name) {
111
+ if (!this.activeKernel)
107
112
  return;
108
- kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
109
- await this.opts.sessionLog.append(sessionId, withCategory({
110
- kind: "page_in",
111
- turn: runtime.turn(),
112
- entry_count: entries.length,
113
- }));
113
+ kernelApply(this.activeKernel, this.pendingObservations, { kind: "skill_deactivated", name });
114
+ this.knowledgePushedSkills.delete(name);
114
115
  }
115
116
  async resolveKernelSuspend(runtime, sessionId) {
116
117
  const gated = this.pendingObservations.filter((o) => o.kind === "tool_gated" && typeof o.call_id === "string" && typeof o.tool === "string");
@@ -190,6 +191,63 @@ export class RuntimeRunner {
190
191
  }
191
192
  return { approved, denied, events };
192
193
  }
194
+ /**
195
+ * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
196
+ * output. Resolution order: (a) this turn's in-memory `pendingSpoolOutputs` map, (b) the result
197
+ * spool (persisted once the kernel observation `large_result_spooled` was processed), (c) a
198
+ * session-log scan for the original `tool_completed` event carrying that `call_id`. Slices the
199
+ * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
200
+ */
201
+ async resolveReadResult(sessionId, argsJson) {
202
+ let callId = "";
203
+ let offset = 0;
204
+ let maxBytes = 4000;
205
+ try {
206
+ const args = JSON.parse(argsJson || "{}");
207
+ callId = typeof args.call_id === "string" ? args.call_id : "";
208
+ if (typeof args.offset === "number" && Number.isFinite(args.offset))
209
+ offset = args.offset;
210
+ if (typeof args.max_bytes === "number" && Number.isFinite(args.max_bytes))
211
+ maxBytes = args.max_bytes;
212
+ }
213
+ catch {
214
+ // malformed arguments — callId stays empty, falls through to "not found" below
215
+ }
216
+ let full = this.pendingSpoolOutputs.get(callId)?.output;
217
+ if (full === undefined && this.opts.resultSpool) {
218
+ try {
219
+ full = await this.opts.resultSpool.findByCallId(callId);
220
+ }
221
+ catch {
222
+ full = undefined;
223
+ }
224
+ }
225
+ if (full === undefined) {
226
+ try {
227
+ const events = await this.opts.sessionLog.read(sessionId);
228
+ for (const { event } of events) {
229
+ if (event.kind !== "tool_completed")
230
+ continue;
231
+ const match = event.results.find(r => r.call_id === callId);
232
+ if (match)
233
+ full = match.output;
234
+ }
235
+ }
236
+ catch {
237
+ full = undefined;
238
+ }
239
+ }
240
+ if (full === undefined) {
241
+ return { text: `no stored output for call_id "${callId}"`, isError: true };
242
+ }
243
+ const start = Math.max(0, offset);
244
+ const end = Math.min(full.length, start + Math.max(0, maxBytes));
245
+ const slice = full.slice(start, end);
246
+ return {
247
+ text: `[read_result ${callId}: chars ${start}–${end} of ${full.length}]\n${slice}`,
248
+ isError: false,
249
+ };
250
+ }
193
251
  async dream(agentId, nowMs = Date.now()) {
194
252
  if (!this.opts.dreamStore)
195
253
  throw new Error("dreamStore not configured");
@@ -351,14 +409,35 @@ export class RuntimeRunner {
351
409
  messages: replayed.map(messageToKernelMessage),
352
410
  });
353
411
  // P1-B B3: rebuild active-skill gating after a wake (active_skills is not snapshotted).
412
+ // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
413
+ // the skill's content from its replayed tool_result so the durable copy survives a wake too.
414
+ const toolResultByCallId = new Map();
415
+ for (const m of replayed) {
416
+ for (const part of m.contentParts ?? []) {
417
+ if (part.type === "tool_result" && part.callId && part.output !== undefined) {
418
+ toolResultByCallId.set(part.callId, part.output);
419
+ }
420
+ }
421
+ }
354
422
  for (const m of replayed) {
355
423
  for (const tc of m.toolCalls ?? []) {
356
424
  if (tc.name !== "skill")
357
425
  continue;
358
426
  try {
359
427
  const name = JSON.parse(tc.arguments || "{}").name;
360
- if (name)
361
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
428
+ if (!name)
429
+ continue;
430
+ kernelApply(runtime, this.pendingObservations, {
431
+ kind: "skill_activated",
432
+ name,
433
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
434
+ });
435
+ const output = toolResultByCallId.get(tc.id);
436
+ if (output && !this.knowledgePushedSkills.has(name)) {
437
+ this.knowledgePushedSkills.add(name);
438
+ // K1: keyed — the kernel-side upsert is the authoritative dedup across wake replays.
439
+ this.pushKnowledge({ role: "system", content: output }, undefined, { key: `skill:${name}` });
440
+ }
362
441
  }
363
442
  catch { /* skip */ }
364
443
  }
@@ -386,44 +465,29 @@ export class RuntimeRunner {
386
465
  startPayload.run_spec = agentRunSpecToKernel(spec);
387
466
  }
388
467
  this.applyKernelPolicies(runtime);
389
- // I4: pre-fetch memory into the knowledge partition before the first LLM turn (mirrors Node).
390
- if (!resumeMidRun && this.opts.preQueryMemory && this.opts.dreamStore && this.opts.agentId) {
391
- try {
392
- const queries = await this.opts.preQueryMemory({ goal });
393
- const entries = [];
394
- for (const q of queries ?? []) {
395
- if (typeof q !== "string" || !q.trim())
396
- continue;
397
- const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
398
- for (const hit of hits) {
399
- entries.push({ content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`, source: "memory" });
400
- }
401
- }
402
- if (entries.length > 0) {
403
- kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
404
- }
405
- }
406
- catch { /* errs-open */ }
468
+ // I4: pre-fetch memory before the first LLM turn (mirrors Node). Strict dynamic context
469
+ // control: single-use retrieval content, not a stable skill — lands in `history` like an
470
+ // ordinary `memory` tool result, so it decays with the compression pyramid instead of
471
+ // pinning itself in `knowledge` forever.
472
+ this.currentGoal = goal;
473
+ if (!resumeMidRun) {
474
+ await this.prefetchMemoryIntoHistory(runtime, "initial");
407
475
  }
408
476
  let action = resumeMidRun
409
477
  ? kernelAction(runtime, this.pendingObservations, { kind: "resume" })
410
478
  : kernelAction(runtime, this.pendingObservations, startPayload);
411
- let hasAttemptedReactiveCompact = false;
412
479
  // P0-C: the skill loaded and in effect going into the current turn → per-turn `activeSkill` metric.
413
480
  let activeSkill;
414
481
  // I0b: kernel-throw safety net — see Node runner for full rationale.
415
482
  try {
416
483
  while (!runtime.isTerminal()) {
417
- if (action.kind === "execute_tool") {
418
- await this.applyKernelPageIn(runtime, sessionId);
419
- }
420
484
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
421
485
  if (this.interrupted) {
422
486
  action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
423
487
  break;
424
488
  }
425
- if (this.opts.signalSource) {
426
- const sig = await this.opts.signalSource.nextSignal();
489
+ if (this.opts.signalSource || this.injectedSignals.length > 0) {
490
+ const sig = await this.nextInboundSignal();
427
491
  if (sig) {
428
492
  const sigAction = kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig));
429
493
  if (sigAction)
@@ -466,7 +530,7 @@ export class RuntimeRunner {
466
530
  let turnCacheReadTokens = 0;
467
531
  let turnCacheCreationTokens = 0;
468
532
  let turnCacheReadBySlot;
469
- let shouldRetry = false;
533
+ let turnStopReason;
470
534
  const abortSignal = this.abortController?.signal;
471
535
  try {
472
536
  for await (const evt of this.opts.provider.stream(context, tools, Object.keys(ext).length ? ext : undefined, providerState, abortSignal)) {
@@ -483,6 +547,9 @@ export class RuntimeRunner {
483
547
  turnCacheCreationTokens = usageEvt.cacheCreationInputTokens ?? 0;
484
548
  // I1: per-slot attribution forwarded to TurnMetrics; undefined on non-Anthropic providers.
485
549
  turnCacheReadBySlot = usageEvt.cacheReadInputTokensBySlot;
550
+ // Phase 4: stop_reason drives the kernel's max-output-tokens recovery.
551
+ if (usageEvt.stopReason)
552
+ turnStopReason = usageEvt.stopReason;
486
553
  continue;
487
554
  }
488
555
  yield evt;
@@ -495,23 +562,31 @@ export class RuntimeRunner {
495
562
  }
496
563
  }
497
564
  catch (err) {
498
- // #2-B-ii: an aborted in-flight request surfaces as an AbortError — treat as an interrupt.
499
565
  if (abortSignal?.aborted) {
566
+ // #2-B-ii: an aborted in-flight request surfaces as an AbortError — treat it as an
567
+ // interrupt (the post-stream `aborted` check below converts it to a clean
568
+ // timeout/UserAbort), not a crash or a provider error.
500
569
  this.interrupted = true;
501
570
  }
502
- const errMsg = formatToolError(err).toLowerCase();
503
- if ((errMsg.includes("413") || errMsg.includes("too long") || errMsg.includes("context length exceeded") || errMsg.includes("context_length_exceeded")) &&
504
- !hasAttemptedReactiveCompact) {
505
- hasAttemptedReactiveCompact = true;
506
- if (forceCompact(runtime, this.pendingObservations)) {
507
- nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
508
- shouldRetry = true;
571
+ else {
572
+ // Reactive recovery is now a kernel decision. Forward the raw provider error and
573
+ // dispatch whatever the kernel returns: `call_provider` to retry with a freshly
574
+ // compacted context, or `done` to terminate with an honest `ContextOverflow`. The
575
+ // classify + compact + retry + give-up policy lives in the kernel (one place), not
576
+ // duplicated across the four SDK runners.
577
+ action = kernelAction(runtime, this.pendingObservations, {
578
+ kind: "provider_error",
579
+ message: formatToolError(err),
580
+ });
581
+ // Withholding (query.ts parity): surface the raw provider error only when the kernel
582
+ // could NOT recover (it returned a terminal). On a recovered retry (`call_provider`)
583
+ // the error stays hidden. `continue` re-enters the loop: a recovered turn persists its
584
+ // compaction archive via the loop-top appendObservations, and a terminal `done` exits
585
+ // through `isTerminal()`.
586
+ if (action.kind === "done") {
587
+ yield { type: "error", message: formatToolError(err) };
509
588
  }
510
- }
511
- if (!shouldRetry) {
512
- yield { type: "error", message: formatToolError(err) };
513
- action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
514
- break;
589
+ continue;
515
590
  }
516
591
  }
517
592
  // #2-B-ii: stream aborted (preempt/interrupt) via the break path — end the turn now.
@@ -519,14 +594,6 @@ export class RuntimeRunner {
519
594
  action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
520
595
  break;
521
596
  }
522
- if (shouldRetry) {
523
- action = {
524
- kind: "call_provider",
525
- context: runtime.render(),
526
- tools,
527
- };
528
- continue;
529
- }
530
597
  const assistantMessage = {
531
598
  role: "assistant",
532
599
  content: finalText,
@@ -539,6 +606,7 @@ export class RuntimeRunner {
539
606
  ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
540
607
  ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
541
608
  now_ms: Date.now(),
609
+ ...(turnStopReason ? { stop_reason: turnStopReason } : {}),
542
610
  };
543
611
  let nextAction = kernelMaybeAction(runtime, this.pendingObservations, providerEvent);
544
612
  const hasSuspended = this.pendingObservations.some(o => o.kind === "suspended");
@@ -605,7 +673,16 @@ export class RuntimeRunner {
605
673
  // the orchestrator collects them and `runWorkflow` sends them to the parent kernel.
606
674
  // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path.
607
675
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
608
- const normalCalls = allCalls.filter(c => c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
676
+ // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
677
+ // host-resolved: (a) this turn's in-memory pending spool map, (b) the on-disk result spool,
678
+ // (c) a session-log scan for the original `tool_completed` event.
679
+ const readResultCalls = allCalls.filter(c => c.name === "read_result");
680
+ const normalCalls = allCalls.filter(c => c.name !== "submit_workflow_nodes" && c.name !== "start_workflow" && c.name !== "read_result");
681
+ for (const call of readResultCalls) {
682
+ const out = await this.resolveReadResult(sessionId, call.arguments);
683
+ toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
684
+ yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
685
+ }
609
686
  for (const call of submitCalls) {
610
687
  // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
611
688
  // spec and AUTO-PIVOT once this tool turn resolves. A workflow-NODE's `start_workflow` (and
@@ -627,7 +704,35 @@ export class RuntimeRunner {
627
704
  toolResults.push({ callId: call.id, output: "submitted", isError: false });
628
705
  yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
629
706
  }
630
- for await (const evt of this.opts.executionPlane.executeAll(normalCalls, runCtx)) {
707
+ // O5 (PreToolUse-hook analog): stateful host veto over each kernel-approved call.
708
+ // A blocked call never executes; its reason reaches the model as a denied result.
709
+ let executableCalls = normalCalls;
710
+ if (this.opts.onToolCall) {
711
+ const allowed = [];
712
+ for (const call of normalCalls) {
713
+ let decision;
714
+ try {
715
+ decision = await this.opts.onToolCall({ callId: call.id, name: call.name, arguments: call.arguments });
716
+ }
717
+ catch {
718
+ decision = undefined;
719
+ }
720
+ if (decision?.block) {
721
+ const reason = decision.reason ?? "blocked by host onToolCall hook";
722
+ yield { type: "tool_denied", callId: call.id, toolName: call.name, reason };
723
+ await this.opts.sessionLog.append(sessionId, {
724
+ kind: "tool_denied", turn: runtime.turn(), call_id: call.id, tool_name: call.name, reason,
725
+ });
726
+ const out = `blocked by host hook: ${reason}`;
727
+ toolResults.push({ callId: call.id, output: out, isError: true, errorKind: "governance_denied" });
728
+ yield { type: "tool_result", callId: call.id, name: call.name, content: out, isError: true };
729
+ continue;
730
+ }
731
+ allowed.push(call);
732
+ }
733
+ executableCalls = allowed;
734
+ }
735
+ for await (const evt of this.opts.executionPlane.executeAll(executableCalls, runCtx)) {
631
736
  yield evt;
632
737
  if (evt.type === "tool_result") {
633
738
  const tre = evt;
@@ -681,6 +786,31 @@ export class RuntimeRunner {
681
786
  });
682
787
  }
683
788
  }
789
+ // O5 (PostToolUse-hook analog): host inspection of each executed result before it reaches
790
+ // the kernel/session-log — replace the output and/or inject a signal note. Errs-open.
791
+ if (this.opts.onToolResult) {
792
+ for (const r of toolResults) {
793
+ const call = executableCalls.find(c => c.id === r.callId);
794
+ if (!call)
795
+ continue;
796
+ let decision;
797
+ try {
798
+ decision = await this.opts.onToolResult({
799
+ callId: r.callId, name: call.name, arguments: call.arguments,
800
+ output: r.output, isError: r.isError,
801
+ });
802
+ }
803
+ catch {
804
+ decision = undefined;
805
+ }
806
+ if (!decision)
807
+ continue;
808
+ if (typeof decision.replaceOutput === "string")
809
+ r.output = decision.replaceOutput;
810
+ if (decision.note)
811
+ this.injectNote(decision.note);
812
+ }
813
+ }
684
814
  await this.opts.sessionLog.append(sessionId, {
685
815
  kind: "tool_completed",
686
816
  turn: runtime.turn(),
@@ -698,6 +828,12 @@ export class RuntimeRunner {
698
828
  }
699
829
  }
700
830
  // P1-B B3: a successfully-resolved `skill` call activates that skill for the next turn.
831
+ //
832
+ // Strict dynamic context control: a skill is METHOD content — how to do something — reused
833
+ // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
834
+ // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
835
+ // (in addition to the ordinary tool_result already headed for `history`, where it will decay
836
+ // with the compression pyramid like any other tool output). First activation only.
701
837
  for (const call of allCalls) {
702
838
  if (call.name !== "skill")
703
839
  continue;
@@ -706,8 +842,20 @@ export class RuntimeRunner {
706
842
  continue;
707
843
  try {
708
844
  const name = JSON.parse(call.arguments || "{}").name;
709
- if (name)
710
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
845
+ if (!name)
846
+ continue;
847
+ kernelApply(runtime, this.pendingObservations, {
848
+ kind: "skill_activated",
849
+ name,
850
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
851
+ });
852
+ // With a lease configured, skip the Set optimization: an expired-then-reloaded skill
853
+ // must re-pin — only the kernel knows the lease state; its upsert dedupes anyway.
854
+ if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
855
+ this.knowledgePushedSkills.add(name);
856
+ // K1: keyed `skill:<name>` — the kernel-side upsert dedupes across runner instances.
857
+ this.pushKnowledge({ role: "system", content: res.output }, undefined, { key: `skill:${name}` });
858
+ }
711
859
  }
712
860
  catch { /* skip */ }
713
861
  }
@@ -988,6 +1136,21 @@ export class RuntimeRunner {
988
1136
  : {}),
989
1137
  };
990
1138
  }
1139
+ // O6: tune/disable the in-kernel repeat fuse (absent ⇒ kernel defaults: enabled, 5/8).
1140
+ if (this.opts.repeatFuse !== undefined) {
1141
+ const rf = this.opts.repeatFuse;
1142
+ config.repeat_fuse = rf === false
1143
+ ? { enabled: false, deny_after: 0, terminate_after: 0 }
1144
+ : { enabled: true, deny_after: rf.denyAfter ?? 5, terminate_after: rf.terminateAfter ?? 8 };
1145
+ }
1146
+ // O4: turn-end criteria gate toggle (absent ⇒ kernel default: enabled).
1147
+ if (this.opts.criteriaGate !== undefined) {
1148
+ config.criteria_gate = this.opts.criteriaGate;
1149
+ }
1150
+ // K2: knowledge budget ratio (absent ⇒ kernel default 0.25; 0 disables).
1151
+ if (this.opts.knowledgeBudgetRatio !== undefined) {
1152
+ config.knowledge_budget_ratio = this.opts.knowledgeBudgetRatio;
1153
+ }
991
1154
  kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
992
1155
  if (this.opts.memoryPolicy) {
993
1156
  const m = this.opts.memoryPolicy;
@@ -1110,7 +1273,8 @@ export class RuntimeRunner {
1110
1273
  if (!source)
1111
1274
  return null;
1112
1275
  while (!batchState.settled) {
1113
- const sig = await source.nextSignal();
1276
+ // O2: injected notes participate in the monitor too (drain order matches nextInboundSignal).
1277
+ const sig = this.injectedSignals.shift() ?? await source.nextSignal();
1114
1278
  if (batchState.settled)
1115
1279
  break;
1116
1280
  if (!sig) {
@@ -1255,9 +1419,6 @@ export class RuntimeRunner {
1255
1419
  });
1256
1420
  if (!event)
1257
1421
  continue;
1258
- if (obs.kind === "page_out" && obs.archived) {
1259
- this.localPageOutCache.push(...obs.archived);
1260
- }
1261
1422
  const compressedSeq = await this.opts.sessionLog.append(sessionId, event);
1262
1423
  if (event.kind === "compressed") {
1263
1424
  nextArchiveStart = compressedSeq + 1;
@@ -1268,9 +1429,42 @@ export class RuntimeRunner {
1268
1429
  && obs.archived.length > 0) {
1269
1430
  void this.archiveSemanticPageOut(obs.archived, compressionAction(obs.action));
1270
1431
  }
1432
+ // K4: a sprint renewal dropped the old history — including any earlier memory hits — so
1433
+ // re-run the preQueryMemory prefetch for the new sprint (live observations only).
1434
+ if (obs.kind === "renewed") {
1435
+ await this.prefetchMemoryIntoHistory(runtime, "renewal");
1436
+ }
1271
1437
  }
1272
1438
  return nextArchiveStart;
1273
1439
  }
1440
+ /** I4 + K4: fetch long-term memory hits for the current goal and land them in `history` as an
1441
+ * ordinary user turn — single-use retrieval content that decays with the compression pyramid,
1442
+ * never pinned into `knowledge`. `phase: "initial"` = once before turn 1; `phase: "renewal"` =
1443
+ * re-fired after each sprint renewal (renewal drops the old history INCLUDING earlier memory
1444
+ * hits). Errs-open throughout. */
1445
+ async prefetchMemoryIntoHistory(runtime, phase) {
1446
+ if (!this.opts.preQueryMemory || !this.opts.dreamStore || !this.opts.agentId)
1447
+ return;
1448
+ try {
1449
+ const queries = await this.opts.preQueryMemory({ goal: this.currentGoal, phase });
1450
+ const lines = [];
1451
+ for (const q of queries ?? []) {
1452
+ if (typeof q !== "string" || !q.trim())
1453
+ continue;
1454
+ const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
1455
+ for (const hit of hits) {
1456
+ lines.push(`[memory score=${hit.score.toFixed(3)}] ${hit.text}`);
1457
+ }
1458
+ }
1459
+ if (lines.length > 0) {
1460
+ kernelApply(runtime, this.pendingObservations, {
1461
+ kind: "add_history_message",
1462
+ message: { role: "user", content: lines.join("\n") },
1463
+ });
1464
+ }
1465
+ }
1466
+ catch { /* errs-open */ }
1467
+ }
1274
1468
  async archiveSemanticPageOut(archived, action) {
1275
1469
  if (!this.opts.dreamStore || !this.opts.agentId)
1276
1470
  return;
@@ -19,7 +19,9 @@ function terminationFromStatus(status) {
19
19
  normalized === "timeout" ||
20
20
  normalized === "user_abort" ||
21
21
  normalized === "error" ||
22
- normalized === "milestone_exceeded") {
22
+ normalized === "milestone_exceeded" ||
23
+ normalized === "context_overflow" ||
24
+ normalized === "no_progress") {
23
25
  return normalized;
24
26
  }
25
27
  return status;
@@ -72,6 +74,9 @@ export class SubAgentOrchestrator {
72
74
  provider: resolveProvider(ctx.parentOpts, ctx.spec.modelHint),
73
75
  // M4/G5: cap the child run at the node's token budget (falls back to the inherited cap).
74
76
  maxTotalTokens: ctx.spec.tokenBudget ?? ctx.parentOpts.maxTotalTokens,
77
+ // O3: per-child turn / wall-clock caps (fall back to the inherited limits).
78
+ maxTurns: ctx.spec.maxTurns ?? ctx.parentOpts.maxTurns,
79
+ timeoutMs: ctx.spec.maxWallMs ?? ctx.parentOpts.timeoutMs,
75
80
  executionPlane: filteredPlane,
76
81
  agentId: ctx.spec.identity.agentId,
77
82
  systemPrompt,
@@ -2,7 +2,12 @@ import type { Message, ToolSchema } from "../../types.js";
2
2
  export type KernelAgentRole = "explore" | "plan" | "implement" | "verify" | "custom";
3
3
  export type AgentIsolation = "shared" | "read_only" | "worktree" | "remote";
4
4
  export type ContextInheritance = "none" | "system_only" | "full";
5
- export type TerminationReason = "completed" | "max_turns" | "token_budget" | "timeout" | "user_abort" | "error" | "milestone_exceeded";
5
+ export type TerminationReason = "completed" | "max_turns" | "token_budget" | "timeout" | "user_abort" | "error" | "milestone_exceeded"
6
+ /** v0.2.35 recovery ladder: compaction exhausted and the prompt still exceeds the provider window. */
7
+ | "context_overflow"
8
+ /** Repeat-fuse escalation: the same tool call (name AND args) re-issued past `terminateAfter` —
9
+ * a stall, distinct from `max_turns` which productive runs can also hit. */
10
+ | "no_progress";
6
11
  export type MilestonePolicy = "require_verifier" | "terminate" | "auto_pass";
7
12
  export interface AgentIdentity {
8
13
  agentId: string;
@@ -28,6 +33,10 @@ export interface AgentRunSpec {
28
33
  modelHint?: string;
29
34
  /** M4/G5: cumulative token cap for this sub-agent's run (sets the child kernel's `maxTotalTokens`). */
30
35
  tokenBudget?: number;
36
+ /** O3: per-child turn cap (sets the child runner's `maxTurns`; falls back to the parent's). */
37
+ maxTurns?: number;
38
+ /** O3: per-child wall-clock cap in ms (sets the child runner's `timeoutMs`; falls back to the parent's). */
39
+ maxWallMs?: number;
31
40
  }
32
41
  /** Kernel process-table observation (Phase 3 canonical spawn signal). */
33
42
  export interface AgentProcessChangedObservation {
package/dist/types.d.ts CHANGED
@@ -73,7 +73,8 @@ export interface UsageEvent extends StreamEvent {
73
73
  system?: number;
74
74
  tools?: number;
75
75
  messages?: number;
76
- };
76
+ }; /** Provider stop reason — `max_tokens` (Anthropic) / `length` (OpenAI) flag an output-cap truncation driving the kernel's max-output-tokens recovery. */
77
+ stopReason?: string;
77
78
  }
78
79
  export interface ThinkingDelta extends StreamEvent {
79
80
  type: "thinking_delta";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepstrike/wasm",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
4
4
  "description": "DeepStrike WASM SDK — browser, Cloudflare Workers, Deno Deploy",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "test": "node --experimental-vm-modules node_modules/.bin/jest"
16
16
  },
17
17
  "dependencies": {
18
- "@deepstrike/wasm-kernel": "0.2.34"
18
+ "@deepstrike/wasm-kernel": "0.2.36"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/jest": "^30.0.0",