@deepstrike/sdk 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 Node.js SDK
2
8
 
3
9
  Runtime framework built on a Rust kernel. The kernel owns loop control, context compression, governance, signal routing, and memory paging — the SDK owns all I/O (LLM calls, tool execution, disk, long-term memory).
@@ -181,7 +187,7 @@ The mechanisms above are not internal refactors — they change what you can bui
181
187
  Tool calls, spawns, compression, and signals pass through one kernel gate with an explicit lifecycle (Ready / Running / Blocked / Suspended). You implement I/O; the kernel decides *when* and *whether*. Node, Python, and Rust share the same decision path, so `wake(sessionId)` and cross-language tooling see consistent behavior.
182
188
 
183
189
  **Longer, sturdier sessions (Layer-1 spool + semantic page-out)**
184
- Oversized tool results (> 50 KB) stay in context as a preview plus a `.spool/` reference — the model reads the full payload on demand via ordinary file tools. When pressure triggers semantic eviction, the SDK summarizes archived content into `DreamStore` and satisfies `page_in_requested` on the way back in. Long tasks survive token pressure instead of failing mid-run.
190
+ Oversized tool results (> 50 KB) stay in context as a preview plus a `.spool/` reference — the model reads the full payload on demand via ordinary file tools. When pressure triggers semantic eviction, the SDK summarizes archived content into `DreamStore`. Long tasks survive token pressure instead of failing mid-run.
185
191
 
186
192
  **Safety and governance by default (OS native profile)**
187
193
  Every run loads declarative `governancePolicy` (deny / ask_user / rate-limit / param rules) and in-kernel signal routing (`attentionPolicy`, default queue 64). Dangerous tools, external interrupts, and approval flows are policy — not ad-hoc `if` checks in your handlers.
@@ -305,7 +311,7 @@ The kernel renders context as four LLM API slots — only **history** is compres
305
311
  | Slot | Source | Role |
306
312
  |------|--------|------|
307
313
  | `systemStable` | system partition | Identity, rules — never changes within a run |
308
- | `systemKnowledge` | knowledge partition | Preloaded memory, skill defslow frequency |
314
+ | `systemKnowledge` | knowledge partition | Skill bodies, `initialMemory`, host-pinned durable refs keyed, boundary-evicted, budgeted |
309
315
  | `turns[0]` | `task_state` + signals | Goal, plan, progress, compression log, runtime signals |
310
316
  | `turns[1..N]` | history | Conversation transcript |
311
317
 
@@ -489,15 +495,15 @@ effort: 1
489
495
  2. Express each as a concise bullet
490
496
  ```
491
497
 
498
+ A loaded skill's body is pinned into the durable `knowledge` slot (keyed `skill:<name>`) and its `allowed_tools` narrow the exposed toolset. Activation is **not** permanent: `runner.deactivateSkill(name)` re-widens the toolset at the next provider call and unpins the body at the next boundary, and `skillLeaseTurns` auto-deactivates every skill N turns after it loads — so a long multi-phase run doesn't monotonically accumulate early-phase skills. There is deliberately no model-facing unload (deactivation is host-driven only).
499
+
492
500
  ---
493
501
 
494
502
  ## Knowledge
495
503
 
496
- Implement `KnowledgeSource` to connect any RAG system. The kernel injects a `knowledge` meta-tool that the LLM calls on demand. Runtime retrieval results land in **history** as tool results.
497
-
498
- To inject durable knowledge at startup (Slot 2, cacheable on Anthropic), use `initialMemory` or `runner.pushKnowledge()`.
504
+ Implement `KnowledgeSource` to connect any RAG system. The kernel injects a `knowledge` meta-tool that the LLM calls on demand. Runtime retrieval results land in **history** as tool results (single-use fact content that decays with the compression pyramid) — not in the durable `knowledge` partition.
499
505
 
500
- Before tool execution the kernel may emit `page_in_requested`; the SDK satisfies it from `DreamStore`, `KnowledgeSource`, and a local semantic page-out cache, then feeds `page_in` back to the kernel.
506
+ To inject durable knowledge at startup (Slot 2, cacheable on Anthropic), use `initialMemory` or `runner.pushKnowledge(content, tokens?, { key, pinned })`. A **keyed** entry upserts on a repeated key and can be removed with `runner.removeKnowledge(key)`; both take effect at the next compaction/renewal boundary (where the `system[1]` cache prefix is rewritten anyway). Set `knowledgeBudgetRatio` (default 0.25 of `maxTokens`, 0 disables) to cap the partition — over budget, the oldest unpinned, non-skill entries are evicted at boundaries while `pinned: true` entries survive.
501
507
 
502
508
  ```typescript
503
509
  const runner = new RuntimeRunner({
@@ -182,6 +182,9 @@ export class AnthropicProvider {
182
182
  // cache-heavy turn look tiny and suppress compaction until a 413.
183
183
  const inputTokens = uncachedInput + cacheReadTokens + cacheCreationTokens;
184
184
  const bySlot = estimateCacheReadBySlot(cacheReadTokens, slotBp);
185
+ // stop_reason is only present on message_delta (the closing frame). `max_tokens` drives
186
+ // the kernel's output-cap recovery; other reasons (end_turn/tool_use) are informational.
187
+ const stopReason = evt.delta?.stop_reason;
185
188
  yield {
186
189
  type: "usage",
187
190
  totalTokens: inputTokens + outputTokens,
@@ -190,6 +193,7 @@ export class AnthropicProvider {
190
193
  cacheReadInputTokens: cacheReadTokens,
191
194
  cacheCreationInputTokens: cacheCreationTokens,
192
195
  ...(bySlot ? { cacheReadInputTokensBySlot: bySlot } : {}),
196
+ ...(stopReason ? { stopReason } : {}),
193
197
  };
194
198
  }
195
199
  }
@@ -250,6 +250,11 @@ export class OpenAIChatProvider {
250
250
  let inputTokens = 0;
251
251
  let outputTokens = 0;
252
252
  let cacheReadTokens = 0;
253
+ // Phase 4: OpenAI signals an output-cap truncation via finish_reason="length", which arrives on
254
+ // a `choices` frame separate from the trailing `usage` frame — so capture it and attach it to the
255
+ // usage event the runner reads. The kernel treats "length" as a truncation (== Anthropic
256
+ // "max_tokens"); other reasons ("stop"/"tool_calls") pass through harmlessly.
257
+ let finishReason;
253
258
  for await (const chunk of stream) {
254
259
  if (chunk.usage) {
255
260
  totalTokens = chunk.usage.total_tokens;
@@ -261,6 +266,8 @@ export class OpenAIChatProvider {
261
266
  const choice = chunk.choices[0];
262
267
  if (!choice)
263
268
  continue;
269
+ if (choice.finish_reason)
270
+ finishReason = choice.finish_reason;
264
271
  const delta = choice.delta;
265
272
  if (!delta)
266
273
  continue;
@@ -317,7 +324,7 @@ export class OpenAIChatProvider {
317
324
  rememberStream();
318
325
  yield* emitPendingToolCalls();
319
326
  if (totalTokens > 0)
320
- yield { type: "usage", totalTokens, inputTokens, outputTokens, ...(cacheReadTokens > 0 ? { cacheReadInputTokens: cacheReadTokens } : {}) };
327
+ yield { type: "usage", totalTokens, inputTokens, outputTokens, ...(cacheReadTokens > 0 ? { cacheReadInputTokens: cacheReadTokens } : {}), ...(finishReason ? { stopReason: finishReason } : {}) };
321
328
  }
322
329
  /**
323
330
  * Default `prompt_cache_key` derived from the cacheable prefix (system prompt +
@@ -65,6 +65,8 @@ export interface KernelObservation {
65
65
  phase_id?: string;
66
66
  capabilities_unlocked?: string[];
67
67
  evidence?: string[];
68
+ removed_keys?: string[];
69
+ tokens_freed?: number;
68
70
  reason?: RollbackReason | string;
69
71
  agent_id?: string;
70
72
  parent_session_id?: string;
@@ -77,6 +77,13 @@ export declare class LargeResultSpool {
77
77
  * Read a spooled result back from disk.
78
78
  */
79
79
  readSpooledResult(spoolRef: string): Promise<string>;
80
+ /**
81
+ * O7: locate a spooled output by the tool call's id (the `read_result` meta-tool only knows
82
+ * `call_id`, not the content-hashed file name `persistOutput` chose). Scans the spool directory
83
+ * for the `${callId}-*.txt` naming convention; returns `undefined` if nothing was ever spooled
84
+ * for that call (e.g. it never actually exceeded the threshold, or the spool dir was cleaned up).
85
+ */
86
+ findByCallId(callId: string): Promise<string | undefined>;
80
87
  /**
81
88
  * Clean up old spool files (optional maintenance).
82
89
  */
@@ -140,6 +140,31 @@ omitted: ${omitted} chars
140
140
  throw new Error(`Failed to read spooled result: ${error}`);
141
141
  }
142
142
  }
143
+ /**
144
+ * O7: locate a spooled output by the tool call's id (the `read_result` meta-tool only knows
145
+ * `call_id`, not the content-hashed file name `persistOutput` chose). Scans the spool directory
146
+ * for the `${callId}-*.txt` naming convention; returns `undefined` if nothing was ever spooled
147
+ * for that call (e.g. it never actually exceeded the threshold, or the spool dir was cleaned up).
148
+ */
149
+ async findByCallId(callId) {
150
+ let files;
151
+ try {
152
+ files = await fs.readdir(this.spoolDir);
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ const prefix = `${callId}-`;
158
+ const match = files.find(f => f.startsWith(prefix) && f.endsWith('.txt'));
159
+ if (!match)
160
+ return undefined;
161
+ try {
162
+ return await fs.readFile(path.join(this.spoolDir, match), 'utf-8');
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ }
143
168
  /**
144
169
  * Clean up old spool files (optional maintenance).
145
170
  */
@@ -1,7 +1,7 @@
1
1
  import type { LLMProvider, Message, ContentPart, ToolSchema, StreamEvent, ToolSuspendEvent, PermissionRequestEvent, PermissionResponse, AsyncSummarizer, DreamSummarizer } from "../types.js";
2
2
  import type { DreamStore, MemoryEntry, MemoryQuery, MemoryWriteRequest } from "../memory/protocols.js";
3
3
  import type { KnowledgeSource } from "../knowledge/source.js";
4
- import type { SignalSource } from "../signals/types.js";
4
+ import type { SignalSource, RuntimeSignalUrgency } from "../signals/types.js";
5
5
  import type { SessionLog, SessionEvent } from "./session-log.js";
6
6
  import type { ArchiveStore } from "./archive.js";
7
7
  import type { ExecutionPlane } from "./execution-plane.js";
@@ -50,6 +50,18 @@ export interface TurnMetrics {
50
50
  /** Tokens written to the prompt cache this turn (Anthropic `cache_creation_input_tokens`). */
51
51
  cacheCreationTokens: number;
52
52
  }
53
+ /** O5: decision returned by `onToolCall` — `block: true` denies this call before it executes; the
54
+ * `reason` is fed back to the model as a governance-denied tool result (so it can redirect). */
55
+ export interface ToolCallHookDecision {
56
+ block?: boolean;
57
+ reason?: string;
58
+ }
59
+ /** O5: decision returned by `onToolResult` — `replaceOutput` swaps the result the model (and the
60
+ * session log) sees; `note` is injected into the signal stream (see `injectNote`). */
61
+ export interface ToolResultHookDecision {
62
+ replaceOutput?: string;
63
+ note?: string;
64
+ }
53
65
  export interface RuntimeOptions {
54
66
  provider: LLMProvider;
55
67
  /** M4/G5: cumulative token cap for this run (the kernel's `max_total_tokens`). A workflow node's
@@ -81,6 +93,10 @@ export interface RuntimeOptions {
81
93
  preQueryMemory?: (ctx: {
82
94
  goal: string;
83
95
  runSpec?: AgentRunSpec;
96
+ /** K4: `"initial"` = the once-per-run pre-turn-1 fetch; `"renewal"` = re-fired after a sprint
97
+ * renewal (renewal drops the old history INCLUDING earlier memory hits, so the new sprint
98
+ * gets a fresh recall pass). Hooks that ignore it keep the pre-K4 behavior. */
99
+ phase?: "initial" | "renewal";
84
100
  }) => Promise<string[] | undefined> | string[] | undefined;
85
101
  systemPrompt?: string;
86
102
  initialMemory?: string[];
@@ -118,6 +134,63 @@ export interface RuntimeOptions {
118
134
  * and memory-write syscalls are admitted unconditionally (pre-M2 behavior).
119
135
  */
120
136
  resourceQuota?: ResourceQuota;
137
+ /**
138
+ * O6: the in-kernel repeat fuse — the hard rungs above the soft no-progress STOP. When the model
139
+ * re-issues the IDENTICAL tool call (same name AND args) `denyAfter` turns in a row, the kernel
140
+ * denies it and feeds a directive note back; at `terminateAfter` the run ends `no_progress`.
141
+ * Same-tool/different-args loops never trip it. Defaults: enabled, denyAfter 5, terminateAfter 8.
142
+ * Pass `false` to disable (e.g. legit fixed-argument polling loops).
143
+ */
144
+ repeatFuse?: {
145
+ denyAfter?: number;
146
+ terminateAfter?: number;
147
+ } | false;
148
+ /**
149
+ * O4: the turn-end criteria gate (the Stop-hook analog). When the model tries to finish while the
150
+ * run's `criteria` stand, the kernel injects ONE self-check turn ("verify each criterion; continue
151
+ * if any is unmet") before accepting completion. Fires at most once per run; runs without criteria
152
+ * are untouched. Default enabled — set `false` to accept the first finish unconditionally.
153
+ */
154
+ criteriaGate?: boolean;
155
+ /**
156
+ * K2: max share of `maxTokens` the durable knowledge partition may occupy. Exceeding it emits a
157
+ * `knowledge_budget_exceeded` observation (once per cache generation) and evicts the OLDEST
158
+ * unpinned, non-skill entries at the next compaction/renewal boundary until usage fits. Pinned
159
+ * entries and skill pins are never budget-evicted. `0` disables. Default: kernel's 0.25.
160
+ */
161
+ knowledgeBudgetRatio?: number;
162
+ /**
163
+ * K3: default lease (in turns) for every skill activation. After that many turns the kernel
164
+ * auto-deactivates the skill — toolset re-widens, knowledge pin boundary-swept — exactly like
165
+ * an explicit `deactivateSkill()`. Absent ⇒ activations are permanent (default). A repeat
166
+ * `skill(name)` call refreshes the lease.
167
+ */
168
+ skillLeaseTurns?: number;
169
+ /**
170
+ * O5 (the PreToolUse-hook analog): called for each kernel-APPROVED tool call just before it
171
+ * executes. Return `{ block: true, reason }` to veto — the call never runs and the reason is fed
172
+ * back to the model as a denied tool result. This is the seam for STATEFUL host policy (count
173
+ * repeats, budget writes per resource, project-specific rules); keep static allow/deny in
174
+ * `governancePolicy`. Errs-open: a throwing hook never blocks the run.
175
+ */
176
+ onToolCall?: (call: {
177
+ callId: string;
178
+ name: string;
179
+ arguments: string;
180
+ }) => Promise<ToolCallHookDecision | undefined | void> | ToolCallHookDecision | undefined | void;
181
+ /**
182
+ * O5 (the PostToolUse-hook analog): called for each executed tool result before it reaches the
183
+ * kernel. Return `{ replaceOutput }` to swap the result the model sees (redact / annotate), and/or
184
+ * `{ note }` to push a contextual note into the signal stream (same channel as `injectNote` —
185
+ * e.g. "that write was a no-op, stop repeating it"). Errs-open: a throwing hook changes nothing.
186
+ */
187
+ onToolResult?: (result: {
188
+ callId: string;
189
+ name: string;
190
+ arguments: string;
191
+ output: string;
192
+ isError: boolean;
193
+ }) => Promise<ToolResultHookDecision | undefined | void> | ToolResultHookDecision | undefined | void;
121
194
  /**
122
195
  * L1 (RunGroup): bind this runner to a governance domain shared by N peer sessions of one logical
123
196
  * run. Members pass the same `id` + `budgetStore`; the kernel's run-level token cap is then enforced
@@ -217,11 +290,17 @@ export declare class RuntimeRunner {
217
290
  private activeKernel;
218
291
  private pendingObservations;
219
292
  private currentSessionId;
293
+ /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
294
+ private injectedSignals;
295
+ /** Skill names whose content has already been pushed into the durable `knowledge` slot this
296
+ * run — guards against re-pushing a duplicate entry if the model calls `skill(name)` again for
297
+ * an already-active skill (loading is idempotent; the knowledge push should be too). */
298
+ private knowledgePushedSkills;
220
299
  private nextArchiveStart;
300
+ /** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
301
+ private currentGoal;
221
302
  /** Full tool outputs keyed by call_id until Layer-1 spool observations are logged. */
222
303
  private pendingSpoolOutputs;
223
- /** Local cache of paged-out/archived messages for priority memory retrieval. */
224
- private localPageOutCache;
225
304
  /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
226
305
  * at the next safe point (after the tool turn resolves, kernel back in Reason — not suspended). */
227
306
  private pendingAuthoredWorkflows;
@@ -256,10 +335,22 @@ export declare class RuntimeRunner {
256
335
  mountMarker(kind: string, id: string, description: string): void;
257
336
  /** Unmount a capability by kind + id from the active run. No-op if not running. */
258
337
  unmountCapability(kind: string, id: string): void;
259
- /** Phase 4: satisfy kernel page-in requests before meta-tool execution. */
260
- private applyKernelPageIn;
261
- /** Push content into the Knowledge slot (memory retrievals, skill definitions, artifacts). */
262
- pushKnowledge(message: Message, tokens?: number): void;
338
+ /** Push content into the Knowledge slot (memory retrievals, skill definitions, artifacts).
339
+ * K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
340
+ * compaction/renewal boundary, where the cached system[1] block is rewritten anyway) instead
341
+ * of appending a duplicate. `opts.pinned` exempts the entry from the knowledge-budget sweep. */
342
+ pushKnowledge(message: Message, tokens?: number, opts?: {
343
+ key?: string;
344
+ pinned?: boolean;
345
+ }): void;
346
+ /** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
347
+ * Errs-open: an unknown key is a kernel-side no-op. */
348
+ removeKnowledge(key: string): void;
349
+ /** K3: host-driven skill deactivation (there is deliberately no model-facing unload — it
350
+ * invites thrash). The toolset re-widens at the next provider call; the skill's knowledge pin
351
+ * drops at the next compaction/renewal boundary. A later `skill(name)` call re-activates and
352
+ * re-pins fresh content. Errs-open: not-active is a kernel-side no-op. */
353
+ deactivateSkill(name: string): void;
263
354
  /**
264
355
  * Spawn an isolated sub-agent via the kernel, run it on the host, and feed the result back.
265
356
  * Requires an active parent run (`run()` / `wake()` in progress or paused at milestone).
@@ -359,6 +450,16 @@ export declare class RuntimeRunner {
359
450
  failed: string[];
360
451
  }>;
361
452
  interrupt(): void;
453
+ /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
454
+ * the next turn boundary, routes through the kernel attention policy, and — once acted on — renders
455
+ * as a `[SIGNAL] <text>` line in the volatile state turn plus a durable directive. Use it to feed
456
+ * host-detected events back to the model mid-run (e.g. "that write was a no-op — stop repeating it")
457
+ * without wiring a full `SignalSource`. `urgency` maps to the kernel disposition ladder: `"normal"`
458
+ * queues for the next boundary (default), `"high"` soft-interrupts, `"critical"` preempts. */
459
+ injectNote(text: string, urgency?: RuntimeSignalUrgency): void;
460
+ /** Injected-note drain shared by the main loop's per-turn poll: injected notes first (FIFO), then
461
+ * the configured `signalSource`. Keeps the two inbound channels on one code path so they never drift. */
462
+ private nextInboundSignal;
362
463
  run(req: {
363
464
  sessionId: string;
364
465
  goal: string;
@@ -376,7 +477,22 @@ export declare class RuntimeRunner {
376
477
  dream(agentId: string, nowMs?: number): AsyncIterable<StreamEvent>;
377
478
  /** Resolve in-kernel AskUser suspend; returns resume lists and stream events to yield. */
378
479
  private resolveKernelSuspend;
480
+ /**
481
+ * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
482
+ * output. Resolution order: (a) this turn's in-memory `pendingSpoolOutputs` map (a call spooled
483
+ * earlier in the SAME tool-turn, before the session-log write lands), (b) the on-disk result
484
+ * spool (persisted once the kernel observation `large_result_spooled` was processed), (c) a
485
+ * session-log scan for the original `tool_completed` event carrying that `call_id`. Slices the
486
+ * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
487
+ */
488
+ private resolveReadResult;
379
489
  private execute;
490
+ /** I4 + K4: fetch long-term memory hits for the current goal and land them in `history` as an
491
+ * ordinary user turn — single-use retrieval content that decays with the compression pyramid,
492
+ * never pinned into `knowledge`. Called once before turn 1 (`phase: "initial"`) and re-fired
493
+ * after each sprint renewal (`phase: "renewal"`): renewal drops the old history INCLUDING the
494
+ * earlier memory hits, so the new sprint gets a fresh recall pass. Errs-open throughout. */
495
+ private prefetchMemoryIntoHistory;
380
496
  private appendObservations;
381
497
  private archiveSemanticPageOut;
382
498
  private upgradeCompressedSummary;
@@ -5,14 +5,14 @@ import { peekProviderReplay, seedProviderReplayFromEvents } from "./provider-rep
5
5
  import { sanitizeReplayText } from "./replay-sanitize.js";
6
6
  import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, recoverCompletedWorkflowNodes, recoverSubmittedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
7
7
  import { KernelPrimitivesDashboard } from "./kernel-primitives-dashboard.js";
8
- import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, kernelAction, kernelApply, kernelMaybeAction, forceCompact, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
8
+ import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, kernelAction, kernelApply, kernelMaybeAction, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, 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
14
  import { governancePolicyToKernelEvent, governanceFilterSchema } from "../governance.js";
15
- import { kernelObservationToSessionEvent, withCategory } from "./kernel-event-log.js";
15
+ import { kernelObservationToSessionEvent } from "./kernel-event-log.js";
16
16
  import { assertNativeProfile } from "./os-profile.js";
17
17
  import { LargeResultSpool } from "./large-result-spool.js";
18
18
  import { formatToolError } from "../tools/errors.js";
@@ -25,11 +25,17 @@ export class RuntimeRunner {
25
25
  activeKernel = null;
26
26
  pendingObservations = [];
27
27
  currentSessionId = null;
28
+ /** O2 (system-reminder channel): host-pushed notes awaiting the next turn-boundary drain. */
29
+ injectedSignals = [];
30
+ /** Skill names whose content has already been pushed into the durable `knowledge` slot this
31
+ * run — guards against re-pushing a duplicate entry if the model calls `skill(name)` again for
32
+ * an already-active skill (loading is idempotent; the knowledge push should be too). */
33
+ knowledgePushedSkills = new Set();
28
34
  nextArchiveStart = 0;
35
+ /** K4: the active run's goal, kept for the renewal-boundary memory re-query. */
36
+ currentGoal = "";
29
37
  /** Full tool outputs keyed by call_id until Layer-1 spool observations are logged. */
30
38
  pendingSpoolOutputs = new Map();
31
- /** Local cache of paged-out/archived messages for priority memory retrieval. */
32
- localPageOutCache = [];
33
39
  /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
34
40
  * at the next safe point (after the tool turn resolves, kernel back in Reason — not suspended). */
35
41
  pendingAuthoredWorkflows = [];
@@ -181,6 +187,22 @@ export class RuntimeRunner {
181
187
  if (groupSpawnsBase !== undefined && groupSpawnsBase > 0) {
182
188
  config.group_spawns_base = groupSpawnsBase;
183
189
  }
190
+ // O6: tune/disable the in-kernel repeat fuse. `false` disables; an object overrides thresholds.
191
+ // Absent ⇒ kernel defaults (enabled, deny_after=5, terminate_after=8).
192
+ if (this.opts.repeatFuse !== undefined) {
193
+ const rf = this.opts.repeatFuse;
194
+ config.repeat_fuse = rf === false
195
+ ? { enabled: false, deny_after: 0, terminate_after: 0 }
196
+ : { enabled: true, deny_after: rf.denyAfter ?? 5, terminate_after: rf.terminateAfter ?? 8 };
197
+ }
198
+ // O4: turn-end criteria gate toggle (absent ⇒ kernel default: enabled).
199
+ if (this.opts.criteriaGate !== undefined) {
200
+ config.criteria_gate = this.opts.criteriaGate;
201
+ }
202
+ // K2: knowledge budget ratio (absent ⇒ kernel default 0.25; 0 disables).
203
+ if (this.opts.knowledgeBudgetRatio !== undefined) {
204
+ config.knowledge_budget_ratio = this.opts.knowledgeBudgetRatio;
205
+ }
184
206
  kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
185
207
  }
186
208
  async appendMemorySyscallObservations(sessionId, observations) {
@@ -221,62 +243,39 @@ export class RuntimeRunner {
221
243
  return;
222
244
  kernelApply(this.activeKernel, this.pendingObservations, capabilityCommandUnmount(kind, id));
223
245
  }
224
- /** Phase 4: satisfy kernel page-in requests before meta-tool execution. */
225
- async applyKernelPageIn(runtime, sessionId) {
226
- const requests = this.pendingObservations.filter((o) => o.kind === "page_in_requested" && typeof o.tool === "string");
227
- if (requests.length === 0)
228
- return;
229
- const entries = [];
230
- for (const req of requests) {
231
- const query = typeof req.query === "string" ? req.query : "";
232
- const topK = typeof req.top_k === "number" ? req.top_k : 5;
233
- if (req.tool === "memory") {
234
- // Priority search: Local Page-Out Cache (lexical/keyword filter)
235
- const localHits = this.localPageOutCache.filter(m => typeof m.content === "string" && m.content.toLowerCase().includes(query.toLowerCase())).slice(0, topK);
236
- for (const hit of localHits) {
237
- entries.push({
238
- content: `[local semantic cache] ${hit.role}: ${hit.content}`,
239
- source: "semantic_cache",
240
- });
241
- }
242
- // Fall back to dreamStore for the remainder if needed
243
- const remainingK = topK - entries.length;
244
- if (remainingK > 0 && this.opts.dreamStore && this.opts.agentId) {
245
- const hits = await this.opts.dreamStore.search(this.opts.agentId, query, remainingK);
246
- for (const hit of hits) {
247
- entries.push({
248
- content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`,
249
- source: "memory",
250
- });
251
- }
252
- }
253
- }
254
- else if (req.tool === "knowledge" && this.opts.knowledgeSource) {
255
- const snippets = await this.opts.knowledgeSource.retrieve(query, topK);
256
- for (const snippet of snippets) {
257
- entries.push({ content: snippet, source: "knowledge" });
258
- }
259
- }
260
- }
261
- if (entries.length === 0)
262
- return;
263
- kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
264
- await this.opts.sessionLog.append(sessionId, withCategory({
265
- kind: "page_in",
266
- turn: runtime.turn(),
267
- entry_count: entries.length,
268
- }));
269
- }
270
- /** Push content into the Knowledge slot (memory retrievals, skill definitions, artifacts). */
271
- pushKnowledge(message, tokens) {
246
+ /** Push content into the Knowledge slot (memory retrievals, skill definitions, artifacts).
247
+ * K1: `opts.key` gives the entry identity — a same-key push upserts (applied at the next
248
+ * compaction/renewal boundary, where the cached system[1] block is rewritten anyway) instead
249
+ * of appending a duplicate. `opts.pinned` exempts the entry from the knowledge-budget sweep. */
250
+ pushKnowledge(message, tokens, opts) {
272
251
  if (!this.activeKernel)
273
252
  return;
274
253
  kernelApply(this.activeKernel, this.pendingObservations, {
275
254
  kind: "add_knowledge_message",
276
255
  content: message.content ?? "",
277
256
  tokens: tokens ?? Math.max(1, Math.ceil((message.content?.length ?? 0) / 4)),
257
+ ...(opts?.key !== undefined ? { key: opts.key } : {}),
258
+ ...(opts?.pinned ? { pinned: true } : {}),
278
259
  });
279
260
  }
261
+ /** K1: mark a keyed knowledge entry for removal at the next compaction/renewal boundary.
262
+ * Errs-open: an unknown key is a kernel-side no-op. */
263
+ removeKnowledge(key) {
264
+ if (!this.activeKernel)
265
+ return;
266
+ kernelApply(this.activeKernel, this.pendingObservations, { kind: "remove_knowledge", key });
267
+ }
268
+ /** K3: host-driven skill deactivation (there is deliberately no model-facing unload — it
269
+ * invites thrash). The toolset re-widens at the next provider call; the skill's knowledge pin
270
+ * drops at the next compaction/renewal boundary. A later `skill(name)` call re-activates and
271
+ * re-pins fresh content. Errs-open: not-active is a kernel-side no-op. */
272
+ deactivateSkill(name) {
273
+ if (!this.activeKernel)
274
+ return;
275
+ kernelApply(this.activeKernel, this.pendingObservations, { kind: "skill_deactivated", name });
276
+ // Re-arm the SDK-side push guard so a re-activation re-pins the content.
277
+ this.knowledgePushedSkills.delete(name);
278
+ }
280
279
  /**
281
280
  * Spawn an isolated sub-agent via the kernel, run it on the host, and feed the result back.
282
281
  * Requires an active parent run (`run()` / `wake()` in progress or paused at milestone).
@@ -571,7 +570,10 @@ export class RuntimeRunner {
571
570
  if (!source)
572
571
  return null;
573
572
  while (!batchState.settled) {
574
- const sig = await source.nextSignal(this.currentSessionId ?? undefined);
573
+ // O2: injected notes participate in the monitor too, so a host `injectNote` mid-batch is not
574
+ // stranded until the batch settles (the drain order matches `nextInboundSignal`).
575
+ const sig = this.injectedSignals.shift()
576
+ ?? await source.nextSignal(this.currentSessionId ?? undefined);
575
577
  if (batchState.settled)
576
578
  break;
577
579
  if (!sig) {
@@ -699,6 +701,30 @@ export class RuntimeRunner {
699
701
  return this.runWorkflow(spec, { resumedCompleted, resumedSubmissions, sessionId });
700
702
  }
701
703
  interrupt() { this.interrupted = true; this.abortController?.abort(); }
704
+ /** Push a contextual note into the run's signal stream (the system-reminder channel): it drains at
705
+ * the next turn boundary, routes through the kernel attention policy, and — once acted on — renders
706
+ * as a `[SIGNAL] <text>` line in the volatile state turn plus a durable directive. Use it to feed
707
+ * host-detected events back to the model mid-run (e.g. "that write was a no-op — stop repeating it")
708
+ * without wiring a full `SignalSource`. `urgency` maps to the kernel disposition ladder: `"normal"`
709
+ * queues for the next boundary (default), `"high"` soft-interrupts, `"critical"` preempts. */
710
+ injectNote(text, urgency = "normal") {
711
+ this.injectedSignals.push({
712
+ source: "custom",
713
+ signalType: "event",
714
+ urgency,
715
+ payload: { goal: text },
716
+ });
717
+ }
718
+ /** Injected-note drain shared by the main loop's per-turn poll: injected notes first (FIFO), then
719
+ * the configured `signalSource`. Keeps the two inbound channels on one code path so they never drift. */
720
+ async nextInboundSignal() {
721
+ const injected = this.injectedSignals.shift();
722
+ if (injected)
723
+ return injected;
724
+ if (!this.opts.signalSource)
725
+ return null;
726
+ return this.opts.signalSource.nextSignal(this.currentSessionId ?? undefined);
727
+ }
702
728
  async *run(req) {
703
729
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
704
730
  const midRun = isMidRun(prior);
@@ -876,6 +902,65 @@ export class RuntimeRunner {
876
902
  }
877
903
  return { approved, denied, events };
878
904
  }
905
+ /**
906
+ * O7: resolve a `read_result` meta-tool call to the full text of a previously-evicted tool
907
+ * output. Resolution order: (a) this turn's in-memory `pendingSpoolOutputs` map (a call spooled
908
+ * earlier in the SAME tool-turn, before the session-log write lands), (b) the on-disk result
909
+ * spool (persisted once the kernel observation `large_result_spooled` was processed), (c) a
910
+ * session-log scan for the original `tool_completed` event carrying that `call_id`. Slices the
911
+ * resolved text by `[offset, offset + maxBytes)` (plain string slice — "bytes-ish").
912
+ */
913
+ async resolveReadResult(sessionId, argsJson) {
914
+ let callId = "";
915
+ let offset = 0;
916
+ let maxBytes = 4000;
917
+ try {
918
+ const args = JSON.parse(argsJson || "{}");
919
+ callId = typeof args.call_id === "string" ? args.call_id : "";
920
+ if (typeof args.offset === "number" && Number.isFinite(args.offset))
921
+ offset = args.offset;
922
+ if (typeof args.max_bytes === "number" && Number.isFinite(args.max_bytes))
923
+ maxBytes = args.max_bytes;
924
+ }
925
+ catch {
926
+ // malformed arguments — callId stays empty, falls through to "not found" below
927
+ }
928
+ let full = this.pendingSpoolOutputs.get(callId)?.output;
929
+ if (full === undefined) {
930
+ const spool = this.opts.resultSpool ?? new LargeResultSpool();
931
+ try {
932
+ full = await spool.findByCallId(callId);
933
+ }
934
+ catch {
935
+ full = undefined;
936
+ }
937
+ }
938
+ if (full === undefined) {
939
+ try {
940
+ const events = await this.opts.sessionLog.read(sessionId);
941
+ for (const { event } of events) {
942
+ if (event.kind !== "tool_completed")
943
+ continue;
944
+ const match = event.results.find(r => r.call_id === callId);
945
+ if (match)
946
+ full = match.output;
947
+ }
948
+ }
949
+ catch {
950
+ full = undefined;
951
+ }
952
+ }
953
+ if (full === undefined) {
954
+ return { text: `no stored output for call_id "${callId}"`, isError: true };
955
+ }
956
+ const start = Math.max(0, offset);
957
+ const end = Math.min(full.length, start + Math.max(0, maxBytes));
958
+ const slice = full.slice(start, end);
959
+ return {
960
+ text: `[read_result ${callId}: chars ${start}–${end} of ${full.length}]\n${slice}`,
961
+ isError: false,
962
+ };
963
+ }
879
964
  async *execute(sessionId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments) {
880
965
  this.interrupted = false;
881
966
  this.abortController = new AbortController();
@@ -999,14 +1084,36 @@ export class RuntimeRunner {
999
1084
  // P1-B B3: rebuild active-skill gating after a wake by re-emitting SkillActivated for each
1000
1085
  // `skill` tool call in the replayed history (active_skills is not snapshotted — graceful).
1001
1086
  // The catalog (set_available_skills) was already fed above, so allowed_tools resolves.
1087
+ // `knowledge` isn't snapshotted either (same graceful-reset philosophy) — best-effort re-push
1088
+ // the skill's content from its replayed tool_result so the durable copy survives a wake too.
1089
+ const toolResultByCallId = new Map();
1090
+ for (const m of replayed) {
1091
+ for (const part of m.contentParts ?? []) {
1092
+ if (part.type === "tool_result")
1093
+ toolResultByCallId.set(part.callId, part.output);
1094
+ }
1095
+ }
1002
1096
  for (const m of replayed) {
1003
1097
  for (const tc of m.toolCalls ?? []) {
1004
1098
  if (tc.name !== "skill")
1005
1099
  continue;
1006
1100
  try {
1007
1101
  const name = JSON.parse(tc.arguments || "{}").name;
1008
- if (name)
1009
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
1102
+ if (!name)
1103
+ continue;
1104
+ kernelApply(runtime, this.pendingObservations, {
1105
+ kind: "skill_activated",
1106
+ name,
1107
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1108
+ });
1109
+ const output = toolResultByCallId.get(tc.id);
1110
+ if (output && !this.knowledgePushedSkills.has(name)) {
1111
+ this.knowledgePushedSkills.add(name);
1112
+ // K1: keyed — the kernel-side upsert is the authoritative dedup, so a wake re-push
1113
+ // of a skill already pinned live can never double-pin (the in-run Set resets with
1114
+ // each runner instance; the key does not).
1115
+ this.pushKnowledge({ role: "system", content: output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1116
+ }
1010
1117
  }
1011
1118
  catch { /* malformed skill args — skip */ }
1012
1119
  }
@@ -1054,30 +1161,21 @@ export class RuntimeRunner {
1054
1161
  message: attachmentsToKernelMessage(attachments),
1055
1162
  });
1056
1163
  }
1057
- // I4: pre-fetch memory into the knowledge partition before the first LLM turn. Skipped on
1058
- // resumes (memory was already on the prior context) and when dreamStore/agentId is absent.
1059
- if (!resumeMidRun && this.opts.preQueryMemory && this.opts.dreamStore && this.opts.agentId) {
1060
- try {
1061
- const queries = await this.opts.preQueryMemory({ goal, runSpec: this.opts.runSpec });
1062
- const entries = [];
1063
- for (const q of queries ?? []) {
1064
- if (typeof q !== "string" || !q.trim())
1065
- continue;
1066
- const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
1067
- for (const hit of hits) {
1068
- entries.push({ content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`, source: "memory" });
1069
- }
1070
- }
1071
- if (entries.length > 0) {
1072
- kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
1073
- }
1074
- }
1075
- catch { /* errs-open — a faulty pre-fetch never breaks the run */ }
1164
+ // I4: pre-fetch memory before the first LLM turn so the model sees it on turn 1 instead of
1165
+ // discovering it via the `memory` tool on turn 3+. Skipped on resumes (already in prior
1166
+ // context) and when dreamStore/agentId is absent.
1167
+ //
1168
+ // Strict dynamic context control: this is single-use retrieval content (facts relevant to
1169
+ // THIS run's goal right now), not a stable method/skill — so it lands in `history` as an
1170
+ // ordinary turn, exactly like a real `memory` tool result would, and decays with the
1171
+ // compression pyramid over subsequent turns instead of pinning itself in `knowledge` forever.
1172
+ this.currentGoal = goal;
1173
+ if (!resumeMidRun) {
1174
+ await this.prefetchMemoryIntoHistory(runtime, "initial");
1076
1175
  }
1077
1176
  let action = resumeMidRun
1078
1177
  ? kernelAction(runtime, this.pendingObservations, { kind: "resume" })
1079
1178
  : kernelAction(runtime, this.pendingObservations, startPayload);
1080
- let hasAttemptedReactiveCompact = false;
1081
1179
  // P0-C: the skill loaded and in effect going into the current turn (updated when the model's
1082
1180
  // `skill` tool call resolves). Drives the per-turn `activeSkill` metric → dwell measurement.
1083
1181
  let activeSkill;
@@ -1089,18 +1187,14 @@ export class RuntimeRunner {
1089
1187
  // an input" from "the run is still in progress."
1090
1188
  try {
1091
1189
  while (!runtime.isTerminal()) {
1092
- // Page-in must run before appendObservations drains pending kernel observations.
1093
- if (action.kind === "execute_tool") {
1094
- await this.applyKernelPageIn(runtime, sessionId);
1095
- }
1096
1190
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1097
1191
  this.nextArchiveStart = nextCompressedArchiveStart;
1098
1192
  if (this.interrupted) {
1099
1193
  action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1100
1194
  break;
1101
1195
  }
1102
- if (this.opts.signalSource) {
1103
- const sig = await this.opts.signalSource.nextSignal(this.currentSessionId ?? undefined);
1196
+ if (this.opts.signalSource || this.injectedSignals.length > 0) {
1197
+ const sig = await this.nextInboundSignal();
1104
1198
  if (sig) {
1105
1199
  // Kernel-routed: the kernel decides disposition (dedup/queue/interrupt) and emits
1106
1200
  // `signal_disposed`. An actionable disposition yields a new action to adopt; queued/observed/
@@ -1158,7 +1252,7 @@ export class RuntimeRunner {
1158
1252
  let turnCacheReadTokens = 0;
1159
1253
  let turnCacheCreationTokens = 0;
1160
1254
  let turnCacheReadBySlot;
1161
- let shouldRetry = false;
1255
+ let turnStopReason;
1162
1256
  const abortSignal = this.abortController?.signal;
1163
1257
  try {
1164
1258
  for await (const evt of this.opts.provider.stream(context, tools, Object.keys(ext).length ? ext : undefined, providerState, abortSignal)) {
@@ -1178,6 +1272,10 @@ export class RuntimeRunner {
1178
1272
  // I1: per-slot attribution forwarded into TurnMetrics. Undefined when the provider
1179
1273
  // doesn't honor cache_control (OpenAI-family auto-cache).
1180
1274
  turnCacheReadBySlot = usageEvt.cacheReadInputTokensBySlot;
1275
+ // Phase 4: stop_reason drives the kernel's max-output-tokens recovery. The closing
1276
+ // usage frame carries it; keep the last non-empty value seen this turn.
1277
+ if (usageEvt.stopReason)
1278
+ turnStopReason = usageEvt.stopReason;
1181
1279
  continue;
1182
1280
  }
1183
1281
  yield evt;
@@ -1190,24 +1288,32 @@ export class RuntimeRunner {
1190
1288
  }
1191
1289
  }
1192
1290
  catch (err) {
1193
- // #2-B-ii: an aborted in-flight request surfaces as an AbortError — treat it as an interrupt
1194
- // (the loop-top `interrupted` check converts it to a clean `timeout`/UserAbort), not a crash.
1195
1291
  if (abortSignal?.aborted) {
1292
+ // #2-B-ii: an aborted in-flight request surfaces as an AbortError — treat it as an
1293
+ // interrupt (the post-stream `aborted` check below converts it to a clean
1294
+ // timeout/UserAbort), not a crash or a provider error.
1196
1295
  this.interrupted = true;
1197
1296
  }
1198
- const errMsg = formatToolError(err).toLowerCase();
1199
- if ((errMsg.includes("413") || errMsg.includes("too long") || errMsg.includes("context length exceeded") || errMsg.includes("context_length_exceeded")) &&
1200
- !hasAttemptedReactiveCompact) {
1201
- hasAttemptedReactiveCompact = true;
1202
- if (forceCompact(runtime, this.pendingObservations)) {
1203
- nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1204
- shouldRetry = true;
1297
+ else {
1298
+ // Reactive recovery is now a kernel decision. Forward the raw provider error and
1299
+ // dispatch whatever the kernel returns: `call_provider` to retry with a freshly
1300
+ // compacted context, or `done` to terminate with an honest `ContextOverflow`. The
1301
+ // classify + compact + retry + give-up policy lives in the kernel (one place), not
1302
+ // duplicated across the four SDK runners. `continue` re-enters the loop: a recovered
1303
+ // turn persists its compaction archive via the loop-top appendObservations, and a
1304
+ // terminal `done` exits through `isTerminal()` into the run_terminal emit below.
1305
+ action = kernelAction(runtime, this.pendingObservations, {
1306
+ kind: "provider_error",
1307
+ message: formatToolError(err),
1308
+ });
1309
+ // Withholding (query.ts parity): surface the raw provider error only when the kernel
1310
+ // could NOT recover (it returned a terminal). On a recovered retry (`call_provider`)
1311
+ // the error stays hidden, so embedders that terminate on `error` events don't see a
1312
+ // phantom failure mid-recovery.
1313
+ if (action.kind === "done") {
1314
+ yield { type: "error", message: formatToolError(err) };
1205
1315
  }
1206
- }
1207
- if (!shouldRetry) {
1208
- yield { type: "error", message: formatToolError(err) };
1209
- action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1210
- break;
1316
+ continue;
1211
1317
  }
1212
1318
  }
1213
1319
  // #2-B-ii: stream aborted (preempt/interrupt) via the break path (provider yielded no error) —
@@ -1217,14 +1323,6 @@ export class RuntimeRunner {
1217
1323
  action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1218
1324
  break;
1219
1325
  }
1220
- if (shouldRetry) {
1221
- action = {
1222
- kind: "call_provider",
1223
- context: runtime.render(),
1224
- tools,
1225
- };
1226
- continue;
1227
- }
1228
1326
  const assistantMessage = {
1229
1327
  role: "assistant",
1230
1328
  content: finalText,
@@ -1237,6 +1335,7 @@ export class RuntimeRunner {
1237
1335
  ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
1238
1336
  ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
1239
1337
  now_ms: Date.now(),
1338
+ ...(turnStopReason ? { stop_reason: turnStopReason } : {}),
1240
1339
  };
1241
1340
  let nextAction = kernelMaybeAction(runtime, this.pendingObservations, providerEvent);
1242
1341
  if (!nextAction && this.pendingObservations.some(o => o.kind === "suspended")) {
@@ -1299,11 +1398,17 @@ export class RuntimeRunner {
1299
1398
  resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
1300
1399
  };
1301
1400
  const toolResults = [];
1302
- const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
1401
+ const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow"
1402
+ && c.name !== "read_result");
1303
1403
  const planCalls = allCalls.filter(c => c.name === "update_plan");
1304
1404
  // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1305
1405
  // `submit_workflow_nodes` — a `WorkflowSpec` is a node batch. (v2 adds top-level bootstrap.)
1306
1406
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1407
+ // O7: `read_result` re-fetches a tool output the kernel evicted from context. Content is
1408
+ // host-resolved: (a) this turn's in-memory pending spool map, (b) the on-disk result spool
1409
+ // (persisted once the kernel observes `large_result_spooled`), (c) a session-log scan for
1410
+ // the original `tool_completed` event. The kernel only advertises the capability.
1411
+ const readResultCalls = allCalls.filter(c => c.name === "read_result");
1307
1412
  for (const call of planCalls) {
1308
1413
  const update = parseUpdatePlanArgs(call.arguments);
1309
1414
  kernelApply(runtime, this.pendingObservations, {
@@ -1314,6 +1419,11 @@ export class RuntimeRunner {
1314
1419
  toolResults.push(result);
1315
1420
  yield { type: "tool_result", callId: call.id, content: "success", isError: false };
1316
1421
  }
1422
+ for (const call of readResultCalls) {
1423
+ const out = await this.resolveReadResult(sessionId, call.arguments);
1424
+ toolResults.push({ callId: call.id, output: out.text, isError: out.isError });
1425
+ yield { type: "tool_result", callId: call.id, content: out.text, isError: out.isError };
1426
+ }
1317
1427
  // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
1318
1428
  // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
1319
1429
  // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
@@ -1343,8 +1453,37 @@ export class RuntimeRunner {
1343
1453
  toolResults.push(result);
1344
1454
  yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
1345
1455
  }
1346
- if (normalCalls.length > 0) {
1347
- for await (const evt of this.opts.executionPlane.executeAll(normalCalls, runCtx)) {
1456
+ // O5 (PreToolUse-hook analog): give the host a STATEFUL veto over each kernel-approved
1457
+ // call. A blocked call never executes; its reason reaches the model as a governance-denied
1458
+ // tool result (the kernel rolls the turn back with the note). Errs-open on hook throw.
1459
+ let executableCalls = normalCalls;
1460
+ if (this.opts.onToolCall) {
1461
+ const allowed = [];
1462
+ for (const call of normalCalls) {
1463
+ let decision;
1464
+ try {
1465
+ decision = await this.opts.onToolCall({ callId: call.id, name: call.name, arguments: call.arguments });
1466
+ }
1467
+ catch {
1468
+ decision = undefined;
1469
+ }
1470
+ if (decision?.block) {
1471
+ const reason = decision.reason ?? "blocked by host onToolCall hook";
1472
+ yield { type: "tool_denied", callId: call.id, toolName: call.name, reason };
1473
+ await this.opts.sessionLog.append(sessionId, {
1474
+ kind: "tool_denied", turn: runtime.turn(), call_id: call.id, tool_name: call.name, reason,
1475
+ });
1476
+ const out = `blocked by host hook: ${reason}`;
1477
+ toolResults.push({ callId: call.id, output: out, isError: true, errorKind: "governance_denied" });
1478
+ yield { type: "tool_result", callId: call.id, name: call.name, content: out, isError: true };
1479
+ continue;
1480
+ }
1481
+ allowed.push(call);
1482
+ }
1483
+ executableCalls = allowed;
1484
+ }
1485
+ if (executableCalls.length > 0) {
1486
+ for await (const evt of this.opts.executionPlane.executeAll(executableCalls, runCtx)) {
1348
1487
  yield evt;
1349
1488
  if (evt.type === "tool_result") {
1350
1489
  const tre = evt;
@@ -1398,12 +1537,38 @@ export class RuntimeRunner {
1398
1537
  });
1399
1538
  }
1400
1539
  }
1401
- const names = normalCalls.map(c => c.name).join(", ");
1540
+ const names = executableCalls.map(c => c.name).join(", ");
1402
1541
  kernelApply(runtime, this.pendingObservations, {
1403
1542
  kind: "update_task",
1404
1543
  update: taskUpdateToKernel({ progress: `Executed tools: ${names}` }),
1405
1544
  });
1406
1545
  }
1546
+ // O5 (PostToolUse-hook analog): let the host inspect each executed result BEFORE it
1547
+ // reaches the kernel/session-log — replace the output (redact/annotate) and/or push a
1548
+ // contextual note into the signal stream. Errs-open on hook throw.
1549
+ if (this.opts.onToolResult) {
1550
+ for (const r of toolResults) {
1551
+ const call = executableCalls.find(c => c.id === r.callId);
1552
+ if (!call)
1553
+ continue; // plan/submit synthetics and hook-blocked calls are not host results
1554
+ let decision;
1555
+ try {
1556
+ decision = await this.opts.onToolResult({
1557
+ callId: r.callId, name: call.name, arguments: call.arguments,
1558
+ output: r.output, isError: r.isError,
1559
+ });
1560
+ }
1561
+ catch {
1562
+ decision = undefined;
1563
+ }
1564
+ if (!decision)
1565
+ continue;
1566
+ if (typeof decision.replaceOutput === "string")
1567
+ r.output = decision.replaceOutput;
1568
+ if (decision.note)
1569
+ this.injectNote(decision.note);
1570
+ }
1571
+ }
1407
1572
  await this.opts.sessionLog.append(sessionId, {
1408
1573
  kind: "tool_completed",
1409
1574
  turn: runtime.turn(),
@@ -1423,6 +1588,13 @@ export class RuntimeRunner {
1423
1588
  // P1-B B3: a `skill` call that resolved successfully activates that skill in the kernel, so
1424
1589
  // the next `call_provider` narrows the toolset to its declared tools. Fed before `tool_results`
1425
1590
  // (which computes the next action). Errs-open: a failed/missing skill load doesn't activate.
1591
+ //
1592
+ // Strict dynamic context control: a skill is METHOD content — how to do something — reused
1593
+ // for the rest of the run, unlike a one-off memory/knowledge lookup (fact content, relevant
1594
+ // for the moment it's used). So its text ALSO goes into the durable `knowledge` slot here
1595
+ // (in addition to the ordinary tool_result already headed for `history`, where it will decay
1596
+ // with the compression pyramid like any other tool output — that's fine, the permanent copy
1597
+ // now lives in `knowledge`). First activation only (see `knowledgePushedSkills`).
1426
1598
  for (const call of allCalls) {
1427
1599
  if (call.name !== "skill")
1428
1600
  continue;
@@ -1431,8 +1603,21 @@ export class RuntimeRunner {
1431
1603
  continue;
1432
1604
  try {
1433
1605
  const name = JSON.parse(call.arguments || "{}").name;
1434
- if (name)
1435
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
1606
+ if (!name)
1607
+ continue;
1608
+ kernelApply(runtime, this.pendingObservations, {
1609
+ kind: "skill_activated",
1610
+ name,
1611
+ ...(this.opts.skillLeaseTurns !== undefined ? { lease_turns: this.opts.skillLeaseTurns } : {}),
1612
+ });
1613
+ // K1: keyed `skill:<name>` — the kernel-side upsert dedupes across runner instances
1614
+ // (wake re-push of an already-pinned skill upserts instead of duplicating). With a
1615
+ // lease configured, the Set optimization is skipped: an expired-then-reloaded skill
1616
+ // must re-pin, and only the kernel knows the lease state — its upsert dedupes anyway.
1617
+ if (this.opts.skillLeaseTurns !== undefined || !this.knowledgePushedSkills.has(name)) {
1618
+ this.knowledgePushedSkills.add(name);
1619
+ this.pushKnowledge({ role: "system", content: res.output, toolCalls: [] }, undefined, { key: `skill:${name}` });
1620
+ }
1436
1621
  }
1437
1622
  catch { /* malformed skill args — skip activation */ }
1438
1623
  }
@@ -1558,6 +1743,38 @@ export class RuntimeRunner {
1558
1743
  this.currentSessionId = null;
1559
1744
  this.dashboard = null;
1560
1745
  }
1746
+ /** I4 + K4: fetch long-term memory hits for the current goal and land them in `history` as an
1747
+ * ordinary user turn — single-use retrieval content that decays with the compression pyramid,
1748
+ * never pinned into `knowledge`. Called once before turn 1 (`phase: "initial"`) and re-fired
1749
+ * after each sprint renewal (`phase: "renewal"`): renewal drops the old history INCLUDING the
1750
+ * earlier memory hits, so the new sprint gets a fresh recall pass. Errs-open throughout. */
1751
+ async prefetchMemoryIntoHistory(runtime, phase) {
1752
+ if (!this.opts.preQueryMemory || !this.opts.dreamStore || !this.opts.agentId)
1753
+ return;
1754
+ try {
1755
+ const queries = await this.opts.preQueryMemory({
1756
+ goal: this.currentGoal,
1757
+ runSpec: this.opts.runSpec,
1758
+ phase,
1759
+ });
1760
+ const lines = [];
1761
+ for (const q of queries ?? []) {
1762
+ if (typeof q !== "string" || !q.trim())
1763
+ continue;
1764
+ const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
1765
+ for (const hit of hits) {
1766
+ lines.push(`[memory score=${hit.score.toFixed(3)}] ${hit.text}`);
1767
+ }
1768
+ }
1769
+ if (lines.length > 0) {
1770
+ kernelApply(runtime, this.pendingObservations, {
1771
+ kind: "add_history_message",
1772
+ message: { role: "user", content: lines.join("\n") },
1773
+ });
1774
+ }
1775
+ }
1776
+ catch { /* errs-open — a faulty pre-fetch never breaks the run */ }
1777
+ }
1561
1778
  async appendObservations(sessionId, runtime, nextArchiveStart) {
1562
1779
  const turn = runtime.turn();
1563
1780
  const preservedRefs = runtime.preservedRefs();
@@ -1580,9 +1797,6 @@ export class RuntimeRunner {
1580
1797
  }
1581
1798
  }
1582
1799
  }
1583
- if (obs.kind === "page_out" && obs.archived) {
1584
- this.localPageOutCache.push(...obs.archived);
1585
- }
1586
1800
  if (obs.kind === "large_result_spooled") {
1587
1801
  const pending = this.pendingSpoolOutputs.get(obs.call_id ?? "");
1588
1802
  if (pending) {
@@ -1624,6 +1838,12 @@ export class RuntimeRunner {
1624
1838
  && obs.archived.length > 0) {
1625
1839
  void this.archiveSemanticPageOut(obs.archived, compressionAction(obs.action));
1626
1840
  }
1841
+ // K4: a sprint renewal dropped the old history — including any earlier memory hits — so
1842
+ // re-run the preQueryMemory prefetch for the new sprint (live observations only: this
1843
+ // consumer sits on the live drain path, same placement as the semantic page-out archival).
1844
+ if (obs.kind === "renewed") {
1845
+ await this.prefetchMemoryIntoHistory(runtime, "renewal");
1846
+ }
1627
1847
  }
1628
1848
  return nextArchiveStart;
1629
1849
  }
@@ -10,7 +10,9 @@ function terminationFromStatus(status) {
10
10
  normalized === "timeout" ||
11
11
  normalized === "user_abort" ||
12
12
  normalized === "error" ||
13
- normalized === "milestone_exceeded") {
13
+ normalized === "milestone_exceeded" ||
14
+ normalized === "context_overflow" ||
15
+ normalized === "no_progress") {
14
16
  return normalized;
15
17
  }
16
18
  return status;
@@ -87,6 +89,9 @@ export class SubAgentOrchestrator {
87
89
  provider: resolveProvider(ctx.parentOpts, ctx.spec.modelHint),
88
90
  // M4/G5: cap the child run at the node's token budget (falls back to the inherited cap).
89
91
  maxTotalTokens: ctx.spec.tokenBudget ?? ctx.parentOpts.maxTotalTokens,
92
+ // O3: per-child turn / wall-clock caps (fall back to the inherited limits).
93
+ maxTurns: ctx.spec.maxTurns ?? ctx.parentOpts.maxTurns,
94
+ timeoutMs: ctx.spec.maxWallMs ?? ctx.parentOpts.timeoutMs,
90
95
  executionPlane: execPlane,
91
96
  agentId: ctx.spec.identity.agentId,
92
97
  systemPrompt,
@@ -127,6 +132,9 @@ export class SubAgentOrchestrator {
127
132
  provider: resolveProvider(ctx.parentOpts, ctx.spec.modelHint),
128
133
  // M4/G5: cap the child run at the node's token budget (falls back to the inherited cap).
129
134
  maxTotalTokens: ctx.spec.tokenBudget ?? ctx.parentOpts.maxTotalTokens,
135
+ // O3: per-child turn / wall-clock caps (fall back to the inherited limits).
136
+ maxTurns: ctx.spec.maxTurns ?? ctx.parentOpts.maxTurns,
137
+ timeoutMs: ctx.spec.maxWallMs ?? ctx.parentOpts.timeoutMs,
130
138
  executionPlane: execPlane,
131
139
  agentId: ctx.spec.identity.agentId,
132
140
  sessionLog: ctx.sessionLog,
@@ -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,12 @@ 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). A child
37
+ * that exhausts it terminates `max_turns` — the parent reads the termination and decides retry/skip. */
38
+ maxTurns?: number;
39
+ /** O3: per-child wall-clock cap in milliseconds (sets the child runner's `timeoutMs`; falls back to
40
+ * the parent's). A hung child terminates `timeout` instead of stalling the parent indefinitely. */
41
+ maxWallMs?: number;
31
42
  }
32
43
  /** Kernel process-table observation (Phase 3 canonical spawn signal). */
33
44
  export interface AgentProcessChangedObservation {
package/dist/types.d.ts CHANGED
@@ -93,6 +93,10 @@ export interface UsageEvent extends StreamEvent {
93
93
  tools?: number;
94
94
  messages?: number;
95
95
  };
96
+ /** Provider stop reason for the response — `max_tokens` (Anthropic) / `length` (OpenAI) flag an
97
+ * output-cap truncation, which drives the kernel's max-output-tokens recovery. Absent when the
98
+ * provider doesn't report one. */
99
+ stopReason?: string;
96
100
  }
97
101
  export type ToolChunk = string | {
98
102
  type: "text";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepstrike/sdk",
3
- "version": "0.2.34",
3
+ "version": "0.2.36",
4
4
  "description": "DeepStrike Node.js SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -72,7 +72,7 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@anthropic-ai/sdk": "^0.99.0",
75
- "@deepstrike/core": "0.2.34",
75
+ "@deepstrike/core": "0.2.36",
76
76
  "@google/generative-ai": "^0.24.1",
77
77
  "openai": "^5.23.2"
78
78
  },