@deepstrike/wasm 0.2.35 → 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.
@@ -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>;
@@ -11,7 +11,7 @@ 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,24 +465,13 @@ 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" })
@@ -413,16 +481,13 @@ export class RuntimeRunner {
413
481
  // I0b: kernel-throw safety net — see Node runner for full rationale.
414
482
  try {
415
483
  while (!runtime.isTerminal()) {
416
- if (action.kind === "execute_tool") {
417
- await this.applyKernelPageIn(runtime, sessionId);
418
- }
419
484
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
420
485
  if (this.interrupted) {
421
486
  action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
422
487
  break;
423
488
  }
424
- if (this.opts.signalSource) {
425
- const sig = await this.opts.signalSource.nextSignal();
489
+ if (this.opts.signalSource || this.injectedSignals.length > 0) {
490
+ const sig = await this.nextInboundSignal();
426
491
  if (sig) {
427
492
  const sigAction = kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig));
428
493
  if (sigAction)
@@ -608,7 +673,16 @@ export class RuntimeRunner {
608
673
  // the orchestrator collects them and `runWorkflow` sends them to the parent kernel.
609
674
  // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path.
610
675
  const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
611
- 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
+ }
612
686
  for (const call of submitCalls) {
613
687
  // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
614
688
  // spec and AUTO-PIVOT once this tool turn resolves. A workflow-NODE's `start_workflow` (and
@@ -630,7 +704,35 @@ export class RuntimeRunner {
630
704
  toolResults.push({ callId: call.id, output: "submitted", isError: false });
631
705
  yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
632
706
  }
633
- 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)) {
634
736
  yield evt;
635
737
  if (evt.type === "tool_result") {
636
738
  const tre = evt;
@@ -684,6 +786,31 @@ export class RuntimeRunner {
684
786
  });
685
787
  }
686
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
+ }
687
814
  await this.opts.sessionLog.append(sessionId, {
688
815
  kind: "tool_completed",
689
816
  turn: runtime.turn(),
@@ -701,6 +828,12 @@ export class RuntimeRunner {
701
828
  }
702
829
  }
703
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.
704
837
  for (const call of allCalls) {
705
838
  if (call.name !== "skill")
706
839
  continue;
@@ -709,8 +842,20 @@ export class RuntimeRunner {
709
842
  continue;
710
843
  try {
711
844
  const name = JSON.parse(call.arguments || "{}").name;
712
- if (name)
713
- 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
+ }
714
859
  }
715
860
  catch { /* skip */ }
716
861
  }
@@ -991,6 +1136,21 @@ export class RuntimeRunner {
991
1136
  : {}),
992
1137
  };
993
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
+ }
994
1154
  kernelApply(runtime, this.pendingObservations, { kind: "configure_run", config });
995
1155
  if (this.opts.memoryPolicy) {
996
1156
  const m = this.opts.memoryPolicy;
@@ -1113,7 +1273,8 @@ export class RuntimeRunner {
1113
1273
  if (!source)
1114
1274
  return null;
1115
1275
  while (!batchState.settled) {
1116
- 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();
1117
1278
  if (batchState.settled)
1118
1279
  break;
1119
1280
  if (!sig) {
@@ -1258,9 +1419,6 @@ export class RuntimeRunner {
1258
1419
  });
1259
1420
  if (!event)
1260
1421
  continue;
1261
- if (obs.kind === "page_out" && obs.archived) {
1262
- this.localPageOutCache.push(...obs.archived);
1263
- }
1264
1422
  const compressedSeq = await this.opts.sessionLog.append(sessionId, event);
1265
1423
  if (event.kind === "compressed") {
1266
1424
  nextArchiveStart = compressedSeq + 1;
@@ -1271,9 +1429,42 @@ export class RuntimeRunner {
1271
1429
  && obs.archived.length > 0) {
1272
1430
  void this.archiveSemanticPageOut(obs.archived, compressionAction(obs.action));
1273
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
+ }
1274
1437
  }
1275
1438
  return nextArchiveStart;
1276
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
+ }
1277
1468
  async archiveSemanticPageOut(archived, action) {
1278
1469
  if (!this.opts.dreamStore || !this.opts.agentId)
1279
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepstrike/wasm",
3
- "version": "0.2.35",
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.35"
18
+ "@deepstrike/wasm-kernel": "0.2.36"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/jest": "^30.0.0",