@deepstrike/sdk 0.2.22 → 0.2.23

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.
@@ -29,7 +29,28 @@ export interface GovernancePolicy {
29
29
  windowMs: number;
30
30
  }[];
31
31
  constraints?: GovernanceConstraint[];
32
+ /** I5: when true (default), the runner pre-filters denied tools out of the schema passed to the
33
+ * provider — the model never sees them and never tries to call them, eliminating the rollback
34
+ * turn the kernel would otherwise produce. The denied tool names are also surfaced as a single
35
+ * line on the system slot so the model knows not to plan around them. Set to false to fall
36
+ * back to the v0.2.22 rollback-based behavior (useful for measuring the delta or when the
37
+ * agent should learn the denial via a real attempt). */
38
+ surfaceDeniedInSystem?: boolean;
32
39
  }
40
+ /** I5: walk the tool list and bucket each tool into `allowed` / `denied` based on a declarative
41
+ * policy. A tool is denied when:
42
+ * - the tool name appears in `vetoes`
43
+ * - a `rules[i].pattern` matches the tool name and the rule's `action === "deny"`
44
+ * - or `defaultAction === "deny"` and no `allow` rule matches
45
+ * `ask_user` is treated as allowed at the schema layer — the runtime decides at call time.
46
+ * Pattern matching is exact match or a glob with a single trailing `*` (so `"write_*"` denies
47
+ * `write_file` and `write_db`). Pure — no side effects. */
48
+ export declare function governanceFilterSchema<T extends {
49
+ name: string;
50
+ }>(tools: T[], policy: GovernancePolicy | undefined): {
51
+ allowed: T[];
52
+ denied: string[];
53
+ };
33
54
  export type GovernanceConstraint = {
34
55
  kind: "required";
35
56
  tool: string;
@@ -32,6 +32,38 @@ export class Governance {
32
32
  return this.inner.evaluate(toolName, argsJson);
33
33
  }
34
34
  }
35
+ /** I5: walk the tool list and bucket each tool into `allowed` / `denied` based on a declarative
36
+ * policy. A tool is denied when:
37
+ * - the tool name appears in `vetoes`
38
+ * - a `rules[i].pattern` matches the tool name and the rule's `action === "deny"`
39
+ * - or `defaultAction === "deny"` and no `allow` rule matches
40
+ * `ask_user` is treated as allowed at the schema layer — the runtime decides at call time.
41
+ * Pattern matching is exact match or a glob with a single trailing `*` (so `"write_*"` denies
42
+ * `write_file` and `write_db`). Pure — no side effects. */
43
+ export function governanceFilterSchema(tools, policy) {
44
+ if (!policy)
45
+ return { allowed: tools, denied: [] };
46
+ const vetoes = new Set(policy.vetoes ?? []);
47
+ const allowed = [];
48
+ const denied = [];
49
+ const matches = (pat, name) => pat === name || (pat.endsWith("*") && name.startsWith(pat.slice(0, -1)));
50
+ for (const tool of tools) {
51
+ if (vetoes.has(tool.name)) {
52
+ denied.push(tool.name);
53
+ continue;
54
+ }
55
+ let action = policy.defaultAction ?? "allow";
56
+ for (const r of policy.rules ?? []) {
57
+ if (matches(r.pattern, tool.name))
58
+ action = r.action;
59
+ }
60
+ if (action === "deny")
61
+ denied.push(tool.name);
62
+ else
63
+ allowed.push(tool);
64
+ }
65
+ return { allowed, denied };
66
+ }
35
67
  /**
36
68
  * Convert a declarative {@link GovernancePolicy} into the `load_governance_policy`
37
69
  * kernel event payload (snake_case wire fields). Pure — no side effects.
@@ -5,6 +5,14 @@ export interface Criterion {
5
5
  text: string;
6
6
  required: boolean;
7
7
  weight?: number;
8
+ /** I3.3 (A4): optional stable identifier from the host's contract layer (e.g. an
9
+ * `acceptance[].id` field). The harness does not interpret it; it just threads it through to
10
+ * `verdictFn` so the host can dispatch per-criterion deterministic checks by id. */
11
+ id?: string;
12
+ /** I3.3 (A4): host hint — when true, the host has a deterministic check for this criterion
13
+ * and would short-circuit the LLM eval. The harness still defers to the host's `verdictFn`
14
+ * for the actual decision; this is purely a transparency field on the request. */
15
+ machineCheckable?: boolean;
8
16
  }
9
17
  export interface CriterionResult {
10
18
  criterion: string;
@@ -83,6 +91,14 @@ export declare class SinglePassHarness {
83
91
  run(request: HarnessRequest): Promise<HarnessOutcome>;
84
92
  stream(request: HarnessRequest): AsyncIterable<StreamEvent>;
85
93
  }
94
+ /**
95
+ * @deprecated I3.4 (A1): prefer {@link HarnessLoop} with `verdictFn` for host-defined judgment.
96
+ * `EvalLoopHarness.stream()` does NOT honor the `gate` passed via `request.gate` (only `.run()`
97
+ * does), which is a long-standing footgun for streaming hosts. `HarnessLoop` runs the eval loop
98
+ * uniformly across both stream and run, accepts an optional `verdictFn` for short-circuiting the
99
+ * built-in LLM eval, and otherwise mirrors `EvalLoopHarness`'s behavior. New code should use
100
+ * `HarnessLoop`; existing call sites can migrate by switching the class + (if applicable)
101
+ * passing a `verdictFn` for the same gate logic. Slated for removal in a future major. */
86
102
  export declare class EvalLoopHarness {
87
103
  private runner;
88
104
  private gate;
@@ -91,15 +107,29 @@ export declare class EvalLoopHarness {
91
107
  run(request: HarnessRequest): Promise<HarnessOutcome>;
92
108
  stream(request: HarnessRequest): AsyncIterable<StreamEvent>;
93
109
  }
110
+ /** I3.2 (A2/A3): host-supplied judgment for each attempt's result. Returning a `Verdict` short-
111
+ * circuits the built-in LLM eval (no `evalProvider.stream` call); returning `undefined` defers to
112
+ * the built-in eval (enables hybrid judgment: machine-checkable items deterministic, subjective
113
+ * items LLM). Pure addition — when not set, HarnessLoop.stream() is byte-equivalent to its prior
114
+ * behavior. The closure owns its own context (doc reader, deterministic checks, etc.); the SDK
115
+ * is intentionally agnostic about what it inspects. */
116
+ export type VerdictFn = (ctx: {
117
+ goal: string;
118
+ criteria: Criterion[];
119
+ attempt: number;
120
+ result: string;
121
+ }) => Verdict | undefined | Promise<Verdict | undefined>;
94
122
  export interface HarnessLoopOptions {
95
123
  maxAttempts?: number;
96
124
  skillDir?: string;
125
+ verdictFn?: VerdictFn;
97
126
  }
98
127
  export declare class HarnessLoop {
99
128
  private runner;
100
129
  private evalProvider;
101
130
  private maxAttempts;
102
131
  private skillDir?;
132
+ private verdictFn?;
103
133
  constructor(runner: RuntimeRunner, evalProvider: import("../types.js").LLMProvider, options?: HarnessLoopOptions);
104
134
  run(request: HarnessRequest): Promise<HarnessOutcome>;
105
135
  stream(request: HarnessRequest): AsyncIterable<HarnessEvent>;
@@ -26,6 +26,14 @@ export class SinglePassHarness {
26
26
  yield* this.runner.run({ sessionId: crypto.randomUUID(), goal: request.goal, criteria: request.criteria?.map(c => c.text), extensions: request.extensions });
27
27
  }
28
28
  }
29
+ /**
30
+ * @deprecated I3.4 (A1): prefer {@link HarnessLoop} with `verdictFn` for host-defined judgment.
31
+ * `EvalLoopHarness.stream()` does NOT honor the `gate` passed via `request.gate` (only `.run()`
32
+ * does), which is a long-standing footgun for streaming hosts. `HarnessLoop` runs the eval loop
33
+ * uniformly across both stream and run, accepts an optional `verdictFn` for short-circuiting the
34
+ * built-in LLM eval, and otherwise mirrors `EvalLoopHarness`'s behavior. New code should use
35
+ * `HarnessLoop`; existing call sites can migrate by switching the class + (if applicable)
36
+ * passing a `verdictFn` for the same gate logic. Slated for removal in a future major. */
29
37
  export class EvalLoopHarness {
30
38
  runner;
31
39
  gate;
@@ -53,11 +61,13 @@ export class HarnessLoop {
53
61
  evalProvider;
54
62
  maxAttempts;
55
63
  skillDir;
64
+ verdictFn;
56
65
  constructor(runner, evalProvider, options = {}) {
57
66
  this.runner = runner;
58
67
  this.evalProvider = evalProvider;
59
68
  this.maxAttempts = options.maxAttempts ?? 3;
60
69
  this.skillDir = options.skillDir;
70
+ this.verdictFn = options.verdictFn;
61
71
  }
62
72
  async run(request) {
63
73
  let last;
@@ -125,28 +135,38 @@ export class HarnessLoop {
125
135
  }
126
136
  }
127
137
  yield { type: "supervising" };
128
- // #6 (0.5.0): the eval/verdict compute is the kernel's stateless free functions (was the
129
- // EvalPipeline state machine). Build the eval prompt, call the eval LLM, parse the verdict.
130
- const evalMsgs = kernel.buildEvalMessages(request.goal, criteria, lastResult, attempt, true);
131
- let evalText = "";
132
- const evalContext = {
133
- systemText: evalMsgs.filter((m) => m.role === "system").map((m) => m.content).join("\n\n"),
134
- turns: evalMsgs.filter((m) => m.role !== "system"),
135
- };
136
- for await (const evt of this.evalProvider.stream(evalContext, [], undefined)) {
137
- if (evt.type === "text_delta")
138
- evalText += evt.delta;
138
+ // I3.2 (A2/A3): host-supplied `verdictFn` short-circuits the LLM eval. When it returns a
139
+ // Verdict, use it; when it returns undefined, defer to the built-in eval (hybrid path).
140
+ let verdict;
141
+ let skillCandidate;
142
+ if (this.verdictFn) {
143
+ verdict = await this.verdictFn({ goal: request.goal, criteria, attempt, result: lastResult });
144
+ }
145
+ if (!verdict) {
146
+ // #6 (0.5.0): the eval/verdict compute is the kernel's stateless free functions (was the
147
+ // EvalPipeline state machine). Build the eval prompt, call the eval LLM, parse the verdict.
148
+ const evalMsgs = kernel.buildEvalMessages(request.goal, criteria, lastResult, attempt, true);
149
+ let evalText = "";
150
+ const evalContext = {
151
+ systemText: evalMsgs.filter((m) => m.role === "system").map((m) => m.content).join("\n\n"),
152
+ turns: evalMsgs.filter((m) => m.role !== "system"),
153
+ };
154
+ for await (const evt of this.evalProvider.stream(evalContext, [], undefined)) {
155
+ if (evt.type === "text_delta")
156
+ evalText += evt.delta;
157
+ }
158
+ const parsed = kernel.parseVerdict(evalText);
159
+ verdict = {
160
+ passed: parsed.passed,
161
+ overallScore: parsed.overallScore,
162
+ feedback: parsed.feedback,
163
+ details: parsed.details ?? [],
164
+ };
165
+ skillCandidate = parsed.skillCandidate;
139
166
  }
140
- const parsed = kernel.parseVerdict(evalText);
141
- const verdict = {
142
- passed: parsed.passed,
143
- overallScore: parsed.overallScore,
144
- feedback: parsed.feedback,
145
- details: parsed.details ?? [],
146
- };
147
167
  if (verdict.passed) {
148
- if (parsed.skillCandidate && this.skillDir) {
149
- const { name, description, whenToUse, content } = parsed.skillCandidate;
168
+ if (skillCandidate && this.skillDir) {
169
+ const { name, description, whenToUse, content } = skillCandidate;
150
170
  const fm = ["---", `name: ${name}`, `description: ${description}`,
151
171
  whenToUse ? `when_to_use: ${whenToUse}` : null, "---", ""]
152
172
  .filter(Boolean).join("\n");
package/dist/index.d.ts CHANGED
@@ -71,7 +71,7 @@ export type { PermissionDecision, Permission } from "./safety/permissions.js";
71
71
  export { Governance, governancePolicyToKernelEvent } from "./governance.js";
72
72
  export type { GovernanceVerdict, GovernancePolicy, GovernanceConstraint } from "./governance.js";
73
73
  export { SinglePassHarness, EvalLoopHarness, HarnessLoop } from "./harness/harness.js";
74
- export type { HarnessRequest, HarnessOutcome, HarnessLoopOptions, QualityGate } from "./harness/harness.js";
74
+ export type { HarnessRequest, HarnessOutcome, HarnessLoopOptions, QualityGate, CriterionResult, HarnessEvent, VerdictFn } from "./harness/harness.js";
75
75
  export type { Message, ToolCall, ToolResult, ToolSchema, ContentPart, TextPart, ImagePart, AudioPart, StreamEvent, TextDelta, ThinkingDelta, ToolCallEvent, ToolChunk, ToolDeltaEvent, ToolSuspendEvent, ToolResultEvent, DoneEvent, ErrorEvent, PermissionRequestEvent, PermissionResolvedEvent, PermissionResponse, LLMProvider, RetryConfig, TokenUsage, ProviderToolSpec, ProviderRunState, ProviderReplay, RenderedContext, ReplayabilityAssessment, CacheBreakpointStrategy, } from "./types.js";
76
76
  export type { AgentCapabilityFilter, AgentIdentity, AgentIsolation, AgentRunSpec, AgentProcessChangedObservation, ContextInheritance, KernelAgentRole, LoopResult, MilestoneCheckResult, MilestoneContract, MilestonePhase, MilestonePolicy, SubAgentResult, TerminationReason, WorkflowSpec, WorkflowNodeSpec, WorkflowTaskSpec, WorkflowSpawnInfo, } from "./types/agent.js";
77
77
  export { agentIdentitySub, agentRunSpecToKernel, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowSpecToKernel, workflowNodeSpecToKernel, submitWorkflowNodesToKernel, submitWorkflowToKernel, submitWorkflowNodesTool, startWorkflowTool, fanoutSynthesize, generateAndFilter, verifyRules, genEval, } from "./types/agent.js";
@@ -139,6 +139,11 @@ export class AnthropicProvider {
139
139
  const msgs = this.buildMessages(context, strategy);
140
140
  assertCacheBudget(system, tools.length);
141
141
  const requestExtensions = this.requestExtensions(extensions);
142
+ const builtTools = tools.length ? this.buildTools(tools, !Array.isArray(system), strategy) : undefined;
143
+ // I1: capture which slots will carry cache_control so cache_read_input_tokens can be
144
+ // attributed pro-rata when the response arrives. Honest annotation: Anthropic returns one
145
+ // scalar (no per-slot breakdown), so this is an estimate, not authoritative.
146
+ const slotBp = countCacheControlSlots(system, builtTools, msgs);
142
147
  const toolBlocks = {};
143
148
  const nativeBlocks = {};
144
149
  let finalText = "";
@@ -149,7 +154,7 @@ export class AnthropicProvider {
149
154
  max_tokens: typeof extensions?.max_tokens === "number" ? extensions.max_tokens : 8096,
150
155
  ...(system ? { system } : {}),
151
156
  messages: msgs,
152
- ...(tools.length ? { tools: this.buildTools(tools, !Array.isArray(system), strategy) } : {}),
157
+ ...(builtTools ? { tools: builtTools } : {}),
153
158
  }, extensions, signal);
154
159
  let uncachedInput = 0;
155
160
  let cacheReadTokens = 0;
@@ -171,6 +176,7 @@ export class AnthropicProvider {
171
176
  // context-pressure/compaction — excluding cached tokens would make a
172
177
  // cache-heavy turn look tiny and suppress compaction until a 413.
173
178
  const inputTokens = uncachedInput + cacheReadTokens + cacheCreationTokens;
179
+ const bySlot = estimateCacheReadBySlot(cacheReadTokens, slotBp);
174
180
  yield {
175
181
  type: "usage",
176
182
  totalTokens: inputTokens + outputTokens,
@@ -178,6 +184,7 @@ export class AnthropicProvider {
178
184
  outputTokens,
179
185
  cacheReadInputTokens: cacheReadTokens,
180
186
  cacheCreationInputTokens: cacheCreationTokens,
187
+ ...(bySlot ? { cacheReadInputTokensBySlot: bySlot } : {}),
181
188
  };
182
189
  }
183
190
  }
@@ -304,6 +311,58 @@ function resolveCacheBreakpointStrategy(extensions) {
304
311
  }
305
312
  return "default";
306
313
  }
314
+ /**
315
+ * I1: count which slots of the outgoing request carry a `cache_control` breakpoint. Used to
316
+ * pro-rata-attribute the response's `cache_read_input_tokens` (a single scalar with no per-slot
317
+ * breakdown) across the slots that contributed to the cache hit. Returns whether each slot has
318
+ * any breakpoint — not the actual count, since pro-rata only needs the contributing slot set.
319
+ */
320
+ function countCacheControlSlots(system, builtTools, msgs) {
321
+ const sysBp = Array.isArray(system) && system.some(b => b?.cache_control != null);
322
+ const toolBp = !!builtTools && builtTools.some(t => t?.cache_control != null);
323
+ let msgBp = false;
324
+ for (const m of msgs) {
325
+ if (Array.isArray(m.content)) {
326
+ if (m.content.some(b => b?.cache_control != null)) {
327
+ msgBp = true;
328
+ break;
329
+ }
330
+ }
331
+ }
332
+ return { system: sysBp, tools: toolBp, messages: msgBp };
333
+ }
334
+ /**
335
+ * I1: split the response's `cache_read_input_tokens` evenly across the slots that carried a
336
+ * cache_control breakpoint on the request. Returns undefined when there's no cache read or no
337
+ * contributing slot — in those cases the consumer is better off seeing the field absent than
338
+ * seeing all zeros. The remainder (if the total doesn't divide evenly) lands on the first
339
+ * contributing slot to keep the sum exact.
340
+ */
341
+ function estimateCacheReadBySlot(cacheRead, slotBp) {
342
+ if (cacheRead <= 0)
343
+ return undefined;
344
+ const count = (slotBp.system ? 1 : 0) + (slotBp.tools ? 1 : 0) + (slotBp.messages ? 1 : 0);
345
+ if (count === 0)
346
+ return undefined;
347
+ const share = Math.floor(cacheRead / count);
348
+ const remainder = cacheRead - share * count;
349
+ const out = {};
350
+ let firstDone = false;
351
+ const give = () => {
352
+ if (!firstDone) {
353
+ firstDone = true;
354
+ return share + remainder;
355
+ }
356
+ return share;
357
+ };
358
+ if (slotBp.system)
359
+ out.system = give();
360
+ if (slotBp.tools)
361
+ out.tools = give();
362
+ if (slotBp.messages)
363
+ out.messages = give();
364
+ return out;
365
+ }
307
366
  /** Anthropic accepts at most this many cache_control breakpoints per request. */
308
367
  const MAX_CACHE_BREAKPOINTS = 4;
309
368
  /**
@@ -36,6 +36,16 @@ export interface TurnMetrics {
36
36
  inputTokens: number;
37
37
  /** Tokens served from the prompt cache this turn (Anthropic `cache_read_input_tokens`). */
38
38
  cacheReadTokens: number;
39
+ /** I1: per-slot attribution of `cacheReadTokens`. Anthropic reports a single cache-read total,
40
+ * not a per-block breakdown — this field is a pro-rata estimate over the slots that actually
41
+ * carried a `cache_control` breakpoint on the request. Missing / empty when the provider does
42
+ * not honor `cache_control` (OpenAI-family auto-cache) or when no breakpoints were placed.
43
+ * Useful for diagnosing which slot is buying the cache hit when comparing strategies. */
44
+ cacheReadTokensBySlot?: {
45
+ system?: number;
46
+ tools?: number;
47
+ messages?: number;
48
+ };
39
49
  /** Tokens written to the prompt cache this turn (Anthropic `cache_creation_input_tokens`). */
40
50
  cacheCreationTokens: number;
41
51
  }
@@ -60,6 +70,17 @@ export interface RuntimeOptions {
60
70
  maxTurns?: number;
61
71
  timeoutMs?: number;
62
72
  agentId?: string;
73
+ /** I4: optional run-start memory pre-fetch hook. The runner calls this ONCE per run, before the
74
+ * first LLM turn, with the request's goal and (optional) run-spec. Each returned query string
75
+ * becomes a `dreamStore.search(agentId, q, 5)` and the resulting hits are paged into the
76
+ * context's knowledge partition before turn 1, so the model sees them on first call. Returning
77
+ * `undefined` / empty array is a no-op. Requires `dreamStore` + `agentId`; missing either ⇒
78
+ * silently skipped (errs-open). Bench memory-recall shows -57% turns / -55% dollars when
79
+ * relevant memories land on turn 1 instead of being discovered via the meta-tool on turn 3+. */
80
+ preQueryMemory?: (ctx: {
81
+ goal: string;
82
+ runSpec?: AgentRunSpec;
83
+ }) => Promise<string[] | undefined> | string[] | undefined;
63
84
  systemPrompt?: string;
64
85
  initialMemory?: string[];
65
86
  skillDir?: 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 { governancePolicyToKernelEvent } from "../governance.js";
14
+ import { governancePolicyToKernelEvent, governanceFilterSchema } from "../governance.js";
15
15
  import { kernelObservationToSessionEvent, withCategory } from "./kernel-event-log.js";
16
16
  import { assertNativeProfile } from "./os-profile.js";
17
17
  import { LargeResultSpool } from "./large-result-spool.js";
@@ -973,6 +973,26 @@ export class RuntimeRunner {
973
973
  message: attachmentsToKernelMessage(attachments),
974
974
  });
975
975
  }
976
+ // I4: pre-fetch memory into the knowledge partition before the first LLM turn. Skipped on
977
+ // resumes (memory was already on the prior context) and when dreamStore/agentId is absent.
978
+ if (!resumeMidRun && this.opts.preQueryMemory && this.opts.dreamStore && this.opts.agentId) {
979
+ try {
980
+ const queries = await this.opts.preQueryMemory({ goal, runSpec: this.opts.runSpec });
981
+ const entries = [];
982
+ for (const q of queries ?? []) {
983
+ if (typeof q !== "string" || !q.trim())
984
+ continue;
985
+ const hits = await this.opts.dreamStore.search(this.opts.agentId, q, 5);
986
+ for (const hit of hits) {
987
+ entries.push({ content: `[memory score=${hit.score.toFixed(3)}] ${hit.text}`, source: "memory" });
988
+ }
989
+ }
990
+ if (entries.length > 0) {
991
+ kernelApply(runtime, this.pendingObservations, { kind: "page_in", entries });
992
+ }
993
+ }
994
+ catch { /* errs-open — a faulty pre-fetch never breaks the run */ }
995
+ }
976
996
  let action = resumeMidRun
977
997
  ? kernelAction(runtime, this.pendingObservations, { kind: "resume" })
978
998
  : kernelAction(runtime, this.pendingObservations, startPayload);
@@ -980,369 +1000,440 @@ export class RuntimeRunner {
980
1000
  // P0-C: the skill loaded and in effect going into the current turn (updated when the model's
981
1001
  // `skill` tool call resolves). Drives the per-turn `activeSkill` metric → dwell measurement.
982
1002
  let activeSkill;
983
- while (!runtime.isTerminal()) {
984
- // Page-in must run before appendObservations drains pending kernel observations.
985
- if (action.kind === "execute_tool") {
986
- await this.applyKernelPageIn(runtime, sessionId);
987
- }
988
- nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
989
- this.nextArchiveStart = nextCompressedArchiveStart;
990
- if (this.interrupted) {
991
- action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
992
- break;
993
- }
994
- if (this.opts.signalSource) {
995
- const sig = await this.opts.signalSource.nextSignal();
996
- if (sig) {
997
- // Kernel-routed: the kernel decides disposition (dedup/queue/interrupt) and emits
998
- // `signal_disposed`. An actionable disposition yields a new action to adopt; queued/observed/
999
- // ignored yields none (kernel buffers).
1000
- const sigAction = kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig));
1001
- if (sigAction)
1002
- action = sigAction;
1003
+ // I0b: wrap the main loop so any uncaught kernel exception (typically a NAPI
1004
+ // Status::InvalidArg from a malformed input — e.g. RuntimeSignal.source with a wrong shape,
1005
+ // or an unrecognized event kind) is observable rather than silently propagating out of the
1006
+ // async generator. Without this wrap the runner emits no `run_terminal` event, so downstream
1007
+ // observability (session log, bench mechanism hooks) can't distinguish "the kernel rejected
1008
+ // an input" from "the run is still in progress."
1009
+ try {
1010
+ while (!runtime.isTerminal()) {
1011
+ // Page-in must run before appendObservations drains pending kernel observations.
1012
+ if (action.kind === "execute_tool") {
1013
+ await this.applyKernelPageIn(runtime, sessionId);
1003
1014
  }
1004
- }
1005
- if (runtime.isTerminal())
1006
- break;
1007
- if (action.kind === "call_provider") {
1008
- // M5 v2.1: top-level auto-pivot at the safe point. If the agent authored sub-workflow(s) via
1009
- // `start_workflow`, drive each in THIS kernel now (the kernel is in Reason / `call_provider`,
1010
- // NOT suspended — driving mid-suspend would clobber the single-slot suspend state), inject the
1011
- // outcome into context, and re-render. Loop-top placement (vs only after `tool_results`) catches
1012
- // EVERY path to `call_provider` — including resuming after an approval gate — so a queued spec
1013
- // is never stranded. Drains the queue; fires once per authored batch.
1014
- if (this.pendingAuthoredWorkflows.length > 0) {
1015
- action = await this.driveAuthoredWorkflows(runtime, action);
1015
+ nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1016
+ this.nextArchiveStart = nextCompressedArchiveStart;
1017
+ if (this.interrupted) {
1018
+ action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1019
+ break;
1016
1020
  }
1017
- const finalToolCalls = [];
1018
- let finalText = "";
1019
- const context = action.context;
1020
- const tools = action.tools;
1021
- let turnTokens = 0;
1022
- let turnInputTokens = 0;
1023
- let turnOutputTokens = 0;
1024
- let turnCacheReadTokens = 0;
1025
- let turnCacheCreationTokens = 0;
1026
- let shouldRetry = false;
1027
- const abortSignal = this.abortController?.signal;
1028
- try {
1029
- for await (const evt of this.opts.provider.stream(context, tools, Object.keys(ext).length ? ext : undefined, providerState, abortSignal)) {
1030
- // #2-B-ii: a preempting `interrupt()` fires `abortController` — stop consuming the live
1031
- // stream immediately (providers that forward `signal` also abort the socket; the rest at
1032
- // least stop here at the next event). The loop-top `interrupted` check then ends the run.
1033
- if (abortSignal?.aborted)
1034
- break;
1035
- if (evt.type === "usage") {
1036
- const usageEvt = evt;
1037
- turnTokens = usageEvt.totalTokens;
1038
- turnInputTokens = usageEvt.inputTokens ?? 0;
1039
- turnOutputTokens = usageEvt.outputTokens ?? 0;
1040
- // P0-C: capture the prompt-cache split for the tool-gating hit-rate baseline.
1041
- turnCacheReadTokens = usageEvt.cacheReadInputTokens ?? 0;
1042
- turnCacheCreationTokens = usageEvt.cacheCreationInputTokens ?? 0;
1043
- continue;
1044
- }
1045
- yield evt;
1046
- if (evt.type === "text_delta")
1047
- finalText += evt.delta;
1048
- else if (evt.type === "tool_call") {
1049
- const tc = evt;
1050
- finalToolCalls.push({ id: tc.id, name: tc.name, arguments: JSON.stringify(tc.arguments) });
1051
- }
1021
+ if (this.opts.signalSource) {
1022
+ const sig = await this.opts.signalSource.nextSignal();
1023
+ if (sig) {
1024
+ // Kernel-routed: the kernel decides disposition (dedup/queue/interrupt) and emits
1025
+ // `signal_disposed`. An actionable disposition yields a new action to adopt; queued/observed/
1026
+ // ignored yields none (kernel buffers).
1027
+ const sigAction = kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig));
1028
+ if (sigAction)
1029
+ action = sigAction;
1030
+ // I0a: a Critical-urgency signal carries user_abort intent. The kernel disposes it as
1031
+ // InterruptNow (forces a Reason turn) but does NOT call abortController.abort() unless
1032
+ // sub-agents are suspended — so the no-sub-agent path (e.g. the signal-injection bench
1033
+ // scenario) wouldn't otherwise set `this.interrupted`, and the eventual run_terminal would
1034
+ // report `reason: "error"` indistinguishable from a crash. Mark it here so the final
1035
+ // classification in the run_terminal emit picks `user_abort`.
1036
+ if (sig.urgency === "critical")
1037
+ this.interrupted = true;
1052
1038
  }
1053
1039
  }
1054
- catch (err) {
1055
- // #2-B-ii: an aborted in-flight request surfaces as an AbortError — treat it as an interrupt
1056
- // (the loop-top `interrupted` check converts it to a clean `timeout`/UserAbort), not a crash.
1057
- if (abortSignal?.aborted) {
1058
- this.interrupted = true;
1040
+ if (runtime.isTerminal())
1041
+ break;
1042
+ if (action.kind === "call_provider") {
1043
+ // M5 v2.1: top-level auto-pivot at the safe point. If the agent authored sub-workflow(s) via
1044
+ // `start_workflow`, drive each in THIS kernel now (the kernel is in Reason / `call_provider`,
1045
+ // NOT suspended — driving mid-suspend would clobber the single-slot suspend state), inject the
1046
+ // outcome into context, and re-render. Loop-top placement (vs only after `tool_results`) catches
1047
+ // EVERY path to `call_provider` — including resuming after an approval gate — so a queued spec
1048
+ // is never stranded. Drains the queue; fires once per authored batch.
1049
+ if (this.pendingAuthoredWorkflows.length > 0) {
1050
+ action = await this.driveAuthoredWorkflows(runtime, action);
1059
1051
  }
1060
- const errMsg = String(err).toLowerCase();
1061
- if ((errMsg.includes("413") || errMsg.includes("too long") || errMsg.includes("context length exceeded") || errMsg.includes("context_length_exceeded")) &&
1062
- !hasAttemptedReactiveCompact) {
1063
- hasAttemptedReactiveCompact = true;
1064
- if (forceCompact(runtime, this.pendingObservations)) {
1065
- nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1066
- shouldRetry = true;
1052
+ const finalToolCalls = [];
1053
+ let finalText = "";
1054
+ // I5: governance schema-level pre-filter. When a declarative GovernancePolicy is loaded
1055
+ // and `surfaceDeniedInSystem !== false`, drop denied tools from the schema BEFORE the
1056
+ // model sees them — the model can't plan a call it doesn't know about, so the rollback
1057
+ // overhead disappears. The list of denied names is appended to systemKnowledge so the
1058
+ // model knows not to plan around them.
1059
+ let context = action.context;
1060
+ let tools = action.tools;
1061
+ if (this.opts.governancePolicy && this.opts.governancePolicy.surfaceDeniedInSystem !== false) {
1062
+ const { allowed, denied } = governanceFilterSchema(tools, this.opts.governancePolicy);
1063
+ if (denied.length > 0) {
1064
+ tools = allowed;
1065
+ const note = `[governance] the following tools are denied for this run and will fail if called: ${denied.join(", ")}.`;
1066
+ context = {
1067
+ ...context,
1068
+ systemKnowledge: context.systemKnowledge
1069
+ ? `${context.systemKnowledge}\n\n${note}`
1070
+ : note,
1071
+ };
1067
1072
  }
1068
1073
  }
1069
- if (!shouldRetry) {
1070
- yield { type: "error", message: String(err) };
1074
+ let turnTokens = 0;
1075
+ let turnInputTokens = 0;
1076
+ let turnOutputTokens = 0;
1077
+ let turnCacheReadTokens = 0;
1078
+ let turnCacheCreationTokens = 0;
1079
+ let turnCacheReadBySlot;
1080
+ let shouldRetry = false;
1081
+ const abortSignal = this.abortController?.signal;
1082
+ try {
1083
+ for await (const evt of this.opts.provider.stream(context, tools, Object.keys(ext).length ? ext : undefined, providerState, abortSignal)) {
1084
+ // #2-B-ii: a preempting `interrupt()` fires `abortController` — stop consuming the live
1085
+ // stream immediately (providers that forward `signal` also abort the socket; the rest at
1086
+ // least stop here at the next event). The loop-top `interrupted` check then ends the run.
1087
+ if (abortSignal?.aborted)
1088
+ break;
1089
+ if (evt.type === "usage") {
1090
+ const usageEvt = evt;
1091
+ turnTokens = usageEvt.totalTokens;
1092
+ turnInputTokens = usageEvt.inputTokens ?? 0;
1093
+ turnOutputTokens = usageEvt.outputTokens ?? 0;
1094
+ // P0-C: capture the prompt-cache split for the tool-gating hit-rate baseline.
1095
+ turnCacheReadTokens = usageEvt.cacheReadInputTokens ?? 0;
1096
+ turnCacheCreationTokens = usageEvt.cacheCreationInputTokens ?? 0;
1097
+ // I1: per-slot attribution forwarded into TurnMetrics. Undefined when the provider
1098
+ // doesn't honor cache_control (OpenAI-family auto-cache).
1099
+ turnCacheReadBySlot = usageEvt.cacheReadInputTokensBySlot;
1100
+ continue;
1101
+ }
1102
+ yield evt;
1103
+ if (evt.type === "text_delta")
1104
+ finalText += evt.delta;
1105
+ else if (evt.type === "tool_call") {
1106
+ const tc = evt;
1107
+ finalToolCalls.push({ id: tc.id, name: tc.name, arguments: JSON.stringify(tc.arguments) });
1108
+ }
1109
+ }
1110
+ }
1111
+ catch (err) {
1112
+ // #2-B-ii: an aborted in-flight request surfaces as an AbortError — treat it as an interrupt
1113
+ // (the loop-top `interrupted` check converts it to a clean `timeout`/UserAbort), not a crash.
1114
+ if (abortSignal?.aborted) {
1115
+ this.interrupted = true;
1116
+ }
1117
+ const errMsg = String(err).toLowerCase();
1118
+ if ((errMsg.includes("413") || errMsg.includes("too long") || errMsg.includes("context length exceeded") || errMsg.includes("context_length_exceeded")) &&
1119
+ !hasAttemptedReactiveCompact) {
1120
+ hasAttemptedReactiveCompact = true;
1121
+ if (forceCompact(runtime, this.pendingObservations)) {
1122
+ nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
1123
+ shouldRetry = true;
1124
+ }
1125
+ }
1126
+ if (!shouldRetry) {
1127
+ yield { type: "error", message: String(err) };
1128
+ action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1129
+ break;
1130
+ }
1131
+ }
1132
+ // #2-B-ii: stream aborted (preempt/interrupt) via the break path (provider yielded no error) —
1133
+ // end the turn now with a timeout so the kernel terminates the run, rather than feeding the
1134
+ // partial assistant output as a normal turn.
1135
+ if (abortSignal?.aborted) {
1071
1136
  action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1072
1137
  break;
1073
1138
  }
1074
- }
1075
- // #2-B-ii: stream aborted (preempt/interrupt) via the break path (provider yielded no error) —
1076
- // end the turn now with a timeout so the kernel terminates the run, rather than feeding the
1077
- // partial assistant output as a normal turn.
1078
- if (abortSignal?.aborted) {
1079
- action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1080
- break;
1081
- }
1082
- if (shouldRetry) {
1083
- action = {
1084
- kind: "call_provider",
1085
- context: runtime.render(),
1086
- tools,
1139
+ if (shouldRetry) {
1140
+ action = {
1141
+ kind: "call_provider",
1142
+ context: runtime.render(),
1143
+ tools,
1144
+ };
1145
+ continue;
1146
+ }
1147
+ const assistantMessage = {
1148
+ role: "assistant",
1149
+ content: finalText,
1150
+ toolCalls: finalToolCalls,
1151
+ tokenCount: turnOutputTokens || turnTokens || undefined,
1087
1152
  };
1088
- continue;
1089
- }
1090
- const assistantMessage = {
1091
- role: "assistant",
1092
- content: finalText,
1093
- toolCalls: finalToolCalls,
1094
- tokenCount: turnOutputTokens || turnTokens || undefined,
1095
- };
1096
- const providerEvent = {
1097
- kind: "provider_result",
1098
- message: messageToKernelMessage(assistantMessage),
1099
- ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
1100
- ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
1101
- now_ms: Date.now(),
1102
- };
1103
- let nextAction = kernelMaybeAction(runtime, this.pendingObservations, providerEvent);
1104
- if (!nextAction && this.pendingObservations.some(o => o.kind === "suspended")) {
1105
- const resolved = await this.resolveKernelSuspend(runtime, sessionId);
1106
- for (const evt of resolved.events)
1107
- yield evt;
1108
- nextAction = kernelAction(runtime, this.pendingObservations, {
1109
- kind: "resume",
1110
- approved_calls: resolved.approved,
1111
- denied_calls: resolved.denied,
1112
- });
1113
- }
1114
- action = nextAction ?? kernelAction(runtime, this.pendingObservations, providerEvent);
1115
- const providerReplay = peekProviderReplay(this.opts.provider, finalText, finalToolCalls);
1116
- await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
1117
- turn: runtime.turn(),
1118
- content: finalText,
1119
- tokenCount: turnOutputTokens || turnTokens || undefined,
1120
- toolCalls: finalToolCalls,
1121
- providerReplay,
1122
- }));
1123
- // P0-C: emit per-turn tool-gating telemetry. `activeSkill` reflects the skill in effect
1124
- // GOING INTO this turn; a `skill` call here only takes effect next turn, so emit first, then
1125
- // advance. Wrapped so a faulty sink can never break the run (pure observation).
1126
- if (this.opts.onTurnMetrics) {
1127
- try {
1128
- this.opts.onTurnMetrics({
1129
- turn: runtime.turn(),
1130
- toolsExposed: tools.length,
1131
- toolsCalled: finalToolCalls.length,
1132
- activeSkill,
1133
- inputTokens: turnInputTokens,
1134
- cacheReadTokens: turnCacheReadTokens,
1135
- cacheCreationTokens: turnCacheCreationTokens,
1153
+ const providerEvent = {
1154
+ kind: "provider_result",
1155
+ message: messageToKernelMessage(assistantMessage),
1156
+ ...(turnInputTokens > 0 ? { observed_input_tokens: turnInputTokens } : {}),
1157
+ ...(turnOutputTokens > 0 ? { observed_output_tokens: turnOutputTokens } : {}),
1158
+ now_ms: Date.now(),
1159
+ };
1160
+ let nextAction = kernelMaybeAction(runtime, this.pendingObservations, providerEvent);
1161
+ if (!nextAction && this.pendingObservations.some(o => o.kind === "suspended")) {
1162
+ const resolved = await this.resolveKernelSuspend(runtime, sessionId);
1163
+ for (const evt of resolved.events)
1164
+ yield evt;
1165
+ nextAction = kernelAction(runtime, this.pendingObservations, {
1166
+ kind: "resume",
1167
+ approved_calls: resolved.approved,
1168
+ denied_calls: resolved.denied,
1136
1169
  });
1137
1170
  }
1138
- catch { /* metrics must never break the run */ }
1139
- }
1140
- const skillCall = finalToolCalls.find(c => c.name === "skill");
1141
- if (skillCall) {
1142
- try {
1143
- const name = JSON.parse(skillCall.arguments || "{}").name;
1144
- if (name)
1145
- activeSkill = name;
1146
- }
1147
- catch { /* malformed skill args — leave activeSkill unchanged */ }
1148
- }
1149
- }
1150
- else if (action.kind === "execute_tool") {
1151
- const allCalls = action.calls;
1152
- await this.opts.sessionLog.append(sessionId, { kind: "tool_requested", turn: runtime.turn(), calls: allCalls });
1153
- const runCtx = {
1154
- agentId: this.opts.agentId,
1155
- skillDir: this.opts.skillDir,
1156
- dreamStore: this.opts.dreamStore,
1157
- knowledgeSource: this.opts.knowledgeSource,
1158
- onToolSuspend: this.opts.onToolSuspend,
1159
- onPermissionRequest: this.opts.onPermissionRequest,
1160
- resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
1161
- };
1162
- const toolResults = [];
1163
- const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
1164
- const planCalls = allCalls.filter(c => c.name === "update_plan");
1165
- // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1166
- // `submit_workflow_nodes` — a `WorkflowSpec` is a node batch. (v2 adds top-level bootstrap.)
1167
- const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1168
- for (const call of planCalls) {
1169
- const update = parseUpdatePlanArgs(call.arguments);
1170
- kernelApply(runtime, this.pendingObservations, {
1171
- kind: "update_task",
1172
- update: taskUpdateToKernel(update),
1173
- });
1174
- const result = { callId: call.id, output: "success", isError: false };
1175
- toolResults.push(result);
1176
- yield { type: "tool_result", callId: call.id, content: "success", isError: false };
1177
- }
1178
- // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
1179
- // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
1180
- // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
1181
- // sends `submit_workflow_nodes` to the parent kernel. (When not a workflow node, the event is
1182
- // simply unconsumed — a no-op.)
1183
- for (const call of submitCalls) {
1184
- // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
1185
- // full spec and AUTO-PIVOT once this tool turn resolves (the loop drives it in this kernel and
1186
- // injects the outcome). A workflow-NODE's `start_workflow` (and every `submit_workflow_nodes`)
1187
- // instead FLATTENS: the batch is surfaced for the parent `runWorkflow` to append.
1188
- if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
1189
- const spec = parseStartWorkflowSpec(call.arguments);
1190
- if (spec) {
1191
- this.pendingAuthoredWorkflows.push(spec);
1192
- const out = "workflow authored; executing now";
1193
- toolResults.push({ callId: call.id, output: out, isError: false });
1194
- yield { type: "tool_result", callId: call.id, content: out, isError: false };
1195
- continue;
1196
- }
1197
- }
1198
- // `start_workflow` wraps the batch as `{ spec: { nodes } }`; `submit_workflow_nodes` is `{ nodes }`.
1199
- const nodes = call.name === "start_workflow"
1200
- ? parseStartWorkflowArgs(call.arguments)
1201
- : parseSubmitWorkflowNodesArgs(call.arguments);
1202
- yield { type: "workflow_nodes_submitted", nodes };
1203
- const result = { callId: call.id, output: "submitted", isError: false };
1204
- toolResults.push(result);
1205
- yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
1206
- }
1207
- if (normalCalls.length > 0) {
1208
- for await (const evt of this.opts.executionPlane.executeAll(normalCalls, runCtx)) {
1209
- yield evt;
1210
- if (evt.type === "tool_result") {
1211
- const tre = evt;
1212
- toolResults.push({
1213
- callId: tre.callId,
1214
- output: tre.content,
1215
- isError: tre.isError,
1216
- isFatal: tre.isFatal,
1217
- errorKind: tre.errorKind,
1218
- });
1219
- }
1220
- else if (evt.type === "tool_argument_repaired") {
1221
- const tare = evt;
1222
- await this.opts.sessionLog.append(sessionId, {
1223
- kind: "tool_argument_repaired",
1171
+ action = nextAction ?? kernelAction(runtime, this.pendingObservations, providerEvent);
1172
+ const providerReplay = peekProviderReplay(this.opts.provider, finalText, finalToolCalls);
1173
+ await this.opts.sessionLog.append(sessionId, buildLlmCompletedEvent({
1174
+ turn: runtime.turn(),
1175
+ content: finalText,
1176
+ tokenCount: turnOutputTokens || turnTokens || undefined,
1177
+ toolCalls: finalToolCalls,
1178
+ providerReplay,
1179
+ }));
1180
+ // P0-C: emit per-turn tool-gating telemetry. `activeSkill` reflects the skill in effect
1181
+ // GOING INTO this turn; a `skill` call here only takes effect next turn, so emit first, then
1182
+ // advance. Wrapped so a faulty sink can never break the run (pure observation).
1183
+ if (this.opts.onTurnMetrics) {
1184
+ try {
1185
+ this.opts.onTurnMetrics({
1224
1186
  turn: runtime.turn(),
1225
- tool: tare.name,
1226
- original_arguments: tare.originalArguments,
1227
- repaired_arguments: tare.repairedArguments,
1187
+ toolsExposed: tools.length,
1188
+ toolsCalled: finalToolCalls.length,
1189
+ activeSkill,
1190
+ inputTokens: turnInputTokens,
1191
+ cacheReadTokens: turnCacheReadTokens,
1192
+ cacheCreationTokens: turnCacheCreationTokens,
1193
+ ...(turnCacheReadBySlot ? { cacheReadTokensBySlot: turnCacheReadBySlot } : {}),
1228
1194
  });
1229
1195
  }
1230
- else if (evt.type === "tool_denied") {
1231
- const tde = evt;
1232
- await this.opts.sessionLog.append(sessionId, {
1233
- kind: "tool_denied",
1234
- turn: runtime.turn(),
1235
- call_id: tde.callId,
1236
- tool_name: tde.toolName,
1237
- reason: tde.reason,
1238
- });
1196
+ catch { /* metrics must never break the run */ }
1197
+ }
1198
+ const skillCall = finalToolCalls.find(c => c.name === "skill");
1199
+ if (skillCall) {
1200
+ try {
1201
+ const name = JSON.parse(skillCall.arguments || "{}").name;
1202
+ if (name)
1203
+ activeSkill = name;
1239
1204
  }
1240
- else if (evt.type === "permission_request") {
1241
- const pre = evt;
1242
- const turn = runtime.turn();
1243
- await this.opts.sessionLog.append(sessionId, {
1244
- kind: "permission_requested",
1245
- turn,
1246
- tool: pre.toolName,
1247
- arguments: pre.arguments,
1248
- reason: pre.reason,
1249
- });
1205
+ catch { /* malformed skill args — leave activeSkill unchanged */ }
1206
+ }
1207
+ }
1208
+ else if (action.kind === "execute_tool") {
1209
+ const allCalls = action.calls;
1210
+ await this.opts.sessionLog.append(sessionId, { kind: "tool_requested", turn: runtime.turn(), calls: allCalls });
1211
+ const runCtx = {
1212
+ agentId: this.opts.agentId,
1213
+ skillDir: this.opts.skillDir,
1214
+ dreamStore: this.opts.dreamStore,
1215
+ knowledgeSource: this.opts.knowledgeSource,
1216
+ onToolSuspend: this.opts.onToolSuspend,
1217
+ onPermissionRequest: this.opts.onPermissionRequest,
1218
+ resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
1219
+ };
1220
+ const toolResults = [];
1221
+ const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
1222
+ const planCalls = allCalls.filter(c => c.name === "update_plan");
1223
+ // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1224
+ // `submit_workflow_nodes` — a `WorkflowSpec` is a node batch. (v2 adds top-level bootstrap.)
1225
+ const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
1226
+ for (const call of planCalls) {
1227
+ const update = parseUpdatePlanArgs(call.arguments);
1228
+ kernelApply(runtime, this.pendingObservations, {
1229
+ kind: "update_task",
1230
+ update: taskUpdateToKernel(update),
1231
+ });
1232
+ const result = { callId: call.id, output: "success", isError: false };
1233
+ toolResults.push(result);
1234
+ yield { type: "tool_result", callId: call.id, content: "success", isError: false };
1235
+ }
1236
+ // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
1237
+ // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
1238
+ // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
1239
+ // sends `submit_workflow_nodes` to the parent kernel. (When not a workflow node, the event is
1240
+ // simply unconsumed — a no-op.)
1241
+ for (const call of submitCalls) {
1242
+ // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
1243
+ // full spec and AUTO-PIVOT once this tool turn resolves (the loop drives it in this kernel and
1244
+ // injects the outcome). A workflow-NODE's `start_workflow` (and every `submit_workflow_nodes`)
1245
+ // instead FLATTENS: the batch is surfaced for the parent `runWorkflow` to append.
1246
+ if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
1247
+ const spec = parseStartWorkflowSpec(call.arguments);
1248
+ if (spec) {
1249
+ this.pendingAuthoredWorkflows.push(spec);
1250
+ const out = "workflow authored; executing now";
1251
+ toolResults.push({ callId: call.id, output: out, isError: false });
1252
+ yield { type: "tool_result", callId: call.id, content: out, isError: false };
1253
+ continue;
1254
+ }
1250
1255
  }
1251
- else if (evt.type === "permission_resolved") {
1252
- const resolved = evt;
1253
- const turn = runtime.turn();
1254
- await this.opts.sessionLog.append(sessionId, {
1255
- kind: "permission_resolved",
1256
- turn,
1257
- approved: resolved.approved,
1258
- responder: resolved.responder,
1259
- });
1256
+ // `start_workflow` wraps the batch as `{ spec: { nodes } }`; `submit_workflow_nodes` is `{ nodes }`.
1257
+ const nodes = call.name === "start_workflow"
1258
+ ? parseStartWorkflowArgs(call.arguments)
1259
+ : parseSubmitWorkflowNodesArgs(call.arguments);
1260
+ yield { type: "workflow_nodes_submitted", nodes };
1261
+ const result = { callId: call.id, output: "submitted", isError: false };
1262
+ toolResults.push(result);
1263
+ yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
1264
+ }
1265
+ if (normalCalls.length > 0) {
1266
+ for await (const evt of this.opts.executionPlane.executeAll(normalCalls, runCtx)) {
1267
+ yield evt;
1268
+ if (evt.type === "tool_result") {
1269
+ const tre = evt;
1270
+ toolResults.push({
1271
+ callId: tre.callId,
1272
+ output: tre.content,
1273
+ isError: tre.isError,
1274
+ isFatal: tre.isFatal,
1275
+ errorKind: tre.errorKind,
1276
+ });
1277
+ }
1278
+ else if (evt.type === "tool_argument_repaired") {
1279
+ const tare = evt;
1280
+ await this.opts.sessionLog.append(sessionId, {
1281
+ kind: "tool_argument_repaired",
1282
+ turn: runtime.turn(),
1283
+ tool: tare.name,
1284
+ original_arguments: tare.originalArguments,
1285
+ repaired_arguments: tare.repairedArguments,
1286
+ });
1287
+ }
1288
+ else if (evt.type === "tool_denied") {
1289
+ const tde = evt;
1290
+ await this.opts.sessionLog.append(sessionId, {
1291
+ kind: "tool_denied",
1292
+ turn: runtime.turn(),
1293
+ call_id: tde.callId,
1294
+ tool_name: tde.toolName,
1295
+ reason: tde.reason,
1296
+ });
1297
+ }
1298
+ else if (evt.type === "permission_request") {
1299
+ const pre = evt;
1300
+ const turn = runtime.turn();
1301
+ await this.opts.sessionLog.append(sessionId, {
1302
+ kind: "permission_requested",
1303
+ turn,
1304
+ tool: pre.toolName,
1305
+ arguments: pre.arguments,
1306
+ reason: pre.reason,
1307
+ });
1308
+ }
1309
+ else if (evt.type === "permission_resolved") {
1310
+ const resolved = evt;
1311
+ const turn = runtime.turn();
1312
+ await this.opts.sessionLog.append(sessionId, {
1313
+ kind: "permission_resolved",
1314
+ turn,
1315
+ approved: resolved.approved,
1316
+ responder: resolved.responder,
1317
+ });
1318
+ }
1260
1319
  }
1320
+ const names = normalCalls.map(c => c.name).join(", ");
1321
+ kernelApply(runtime, this.pendingObservations, {
1322
+ kind: "update_task",
1323
+ update: taskUpdateToKernel({ progress: `Executed tools: ${names}` }),
1324
+ });
1261
1325
  }
1262
- const names = normalCalls.map(c => c.name).join(", ");
1263
- kernelApply(runtime, this.pendingObservations, {
1264
- kind: "update_task",
1265
- update: taskUpdateToKernel({ progress: `Executed tools: ${names}` }),
1326
+ await this.opts.sessionLog.append(sessionId, {
1327
+ kind: "tool_completed",
1328
+ turn: runtime.turn(),
1329
+ results: toolResults.map(r => ({
1330
+ call_id: r.callId,
1331
+ output: r.output,
1332
+ is_error: r.isError,
1333
+ token_count: r.tokenCount,
1334
+ })),
1266
1335
  });
1267
- }
1268
- await this.opts.sessionLog.append(sessionId, {
1269
- kind: "tool_completed",
1270
- turn: runtime.turn(),
1271
- results: toolResults.map(r => ({
1272
- call_id: r.callId,
1273
- output: r.output,
1274
- is_error: r.isError,
1275
- token_count: r.tokenCount,
1276
- })),
1277
- });
1278
- for (const call of normalCalls) {
1279
- const result = toolResults.find(r => r.callId === call.id);
1280
- if (result) {
1281
- this.pendingSpoolOutputs.set(call.id, { tool: call.name, output: result.output });
1336
+ for (const call of normalCalls) {
1337
+ const result = toolResults.find(r => r.callId === call.id);
1338
+ if (result) {
1339
+ this.pendingSpoolOutputs.set(call.id, { tool: call.name, output: result.output });
1340
+ }
1282
1341
  }
1283
- }
1284
- // P1-B B3: a `skill` call that resolved successfully activates that skill in the kernel, so
1285
- // the next `call_provider` narrows the toolset to its declared tools. Fed before `tool_results`
1286
- // (which computes the next action). Errs-open: a failed/missing skill load doesn't activate.
1287
- for (const call of allCalls) {
1288
- if (call.name !== "skill")
1289
- continue;
1290
- const res = toolResults.find(r => r.callId === call.id);
1291
- if (!res || res.isError)
1292
- continue;
1293
- try {
1294
- const name = JSON.parse(call.arguments || "{}").name;
1295
- if (name)
1296
- kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
1342
+ // P1-B B3: a `skill` call that resolved successfully activates that skill in the kernel, so
1343
+ // the next `call_provider` narrows the toolset to its declared tools. Fed before `tool_results`
1344
+ // (which computes the next action). Errs-open: a failed/missing skill load doesn't activate.
1345
+ for (const call of allCalls) {
1346
+ if (call.name !== "skill")
1347
+ continue;
1348
+ const res = toolResults.find(r => r.callId === call.id);
1349
+ if (!res || res.isError)
1350
+ continue;
1351
+ try {
1352
+ const name = JSON.parse(call.arguments || "{}").name;
1353
+ if (name)
1354
+ kernelApply(runtime, this.pendingObservations, { kind: "skill_activated", name });
1355
+ }
1356
+ catch { /* malformed skill args — skip activation */ }
1297
1357
  }
1298
- catch { /* malformed skill args — skip activation */ }
1299
- }
1300
- action = kernelAction(runtime, this.pendingObservations, {
1301
- kind: "tool_results",
1302
- results: toolResults.map(toolResultToKernel),
1303
- });
1304
- }
1305
- else if (action.kind === "evaluate_milestone") {
1306
- const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
1307
- if (milestonePolicy === "auto_pass") {
1308
1358
  action = kernelAction(runtime, this.pendingObservations, {
1309
- kind: "milestone_result",
1310
- result: milestoneCheckResultToKernel(milestoneCheckPass(action.phaseId)),
1359
+ kind: "tool_results",
1360
+ results: toolResults.map(toolResultToKernel),
1311
1361
  });
1312
- this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1313
1362
  }
1314
- else if (this.opts.onMilestoneEvaluate) {
1315
- const check = await this.opts.onMilestoneEvaluate({
1316
- phaseId: action.phaseId,
1317
- criteria: action.criteria,
1318
- requiredEvidence: action.requiredEvidence,
1319
- });
1320
- action = kernelAction(runtime, this.pendingObservations, {
1321
- kind: "milestone_result",
1322
- result: milestoneCheckResultToKernel(check),
1323
- });
1324
- this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1363
+ else if (action.kind === "evaluate_milestone") {
1364
+ const milestonePolicy = this.opts.milestonePolicy ?? "require_verifier";
1365
+ if (milestonePolicy === "auto_pass") {
1366
+ action = kernelAction(runtime, this.pendingObservations, {
1367
+ kind: "milestone_result",
1368
+ result: milestoneCheckResultToKernel(milestoneCheckPass(action.phaseId)),
1369
+ });
1370
+ this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1371
+ }
1372
+ else if (this.opts.onMilestoneEvaluate) {
1373
+ const check = await this.opts.onMilestoneEvaluate({
1374
+ phaseId: action.phaseId,
1375
+ criteria: action.criteria,
1376
+ requiredEvidence: action.requiredEvidence,
1377
+ });
1378
+ action = kernelAction(runtime, this.pendingObservations, {
1379
+ kind: "milestone_result",
1380
+ result: milestoneCheckResultToKernel(check),
1381
+ });
1382
+ this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1383
+ }
1384
+ else {
1385
+ this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1386
+ const turnsUsed = Math.max(1, runtime.turn());
1387
+ await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
1388
+ reason: "milestone_pending",
1389
+ turnsUsed,
1390
+ totalTokens: 0,
1391
+ }));
1392
+ yield { type: "done", iterations: turnsUsed, totalTokens: 0, status: "milestone_pending" };
1393
+ this.activeKernel = null;
1394
+ this.currentSessionId = null;
1395
+ return;
1396
+ }
1325
1397
  }
1326
- else {
1327
- this.nextArchiveStart = await this.appendObservations(sessionId, runtime, this.nextArchiveStart);
1328
- const turnsUsed = Math.max(1, runtime.turn());
1329
- await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
1330
- reason: "milestone_pending",
1331
- turnsUsed,
1332
- totalTokens: 0,
1333
- }));
1334
- yield { type: "done", iterations: turnsUsed, totalTokens: 0, status: "milestone_pending" };
1335
- this.activeKernel = null;
1336
- this.currentSessionId = null;
1337
- return;
1398
+ else if (action.kind === "done") {
1399
+ break;
1338
1400
  }
1339
1401
  }
1340
- else if (action.kind === "done") {
1341
- break;
1402
+ }
1403
+ catch (err) {
1404
+ // I0b: kernel rejection (or any other thrown error inside the loop) reaches us here.
1405
+ // Classify by NAPI status code or message pattern — `invalid_arg` for surface-shape rejects,
1406
+ // `error` for everything else — then emit run_terminal so observability sees a clean end.
1407
+ // The yield-error path mirrors what the in-flight provider-stream catch does.
1408
+ const errMsg = err instanceof Error ? err.message : String(err);
1409
+ const code = err.code;
1410
+ const isInvalidArg = code === "InvalidArg" ||
1411
+ errMsg.toLowerCase().includes("invalidarg") ||
1412
+ errMsg.toLowerCase().includes("invalid argument");
1413
+ const reason = isInvalidArg ? "invalid_arg" : "error";
1414
+ yield { type: "error", message: errMsg };
1415
+ try {
1416
+ await this.opts.sessionLog.append(sessionId, buildRunTerminalEvent({
1417
+ reason,
1418
+ turnsUsed: runtime.turn() || 0,
1419
+ totalTokens: 0,
1420
+ }));
1342
1421
  }
1422
+ catch { /* session log failure must not mask the original error */ }
1423
+ yield { type: "done", iterations: runtime.turn() || 0, totalTokens: 0, status: reason };
1424
+ this.activeKernel = null;
1425
+ this.currentSessionId = null;
1426
+ this.dashboard = null;
1427
+ return;
1343
1428
  }
1344
1429
  const result = action.kind === "done" ? action.result : undefined;
1345
- const status = result?.termination ?? "error";
1430
+ // I0a: when the loop exits without a clean kernel-done — typically because a hard interrupt
1431
+ // aborted the in-flight LLM stream and the catch path sent `timeout` (which the kernel handles
1432
+ // by injecting a rollback note and continuing, not by terminating) — preserve the preempt
1433
+ // intent in the run_terminal reason. Without this, every interrupt-curtailed run reports
1434
+ // `reason: "error"` and the bench / observability layer can't distinguish preemption from a
1435
+ // genuine crash. Mirrors WASM/Python/Rust.
1436
+ const status = result?.termination ?? (this.interrupted ? "user_abort" : "error");
1346
1437
  const turnsUsed = result ? Math.max(1, result.turnsUsed) : runtime.turn() || 0;
1347
1438
  const totalTokens = result?.totalTokensUsed ?? 0;
1348
1439
  nextCompressedArchiveStart = await this.appendObservations(sessionId, runtime, nextCompressedArchiveStart);
package/dist/types.d.ts CHANGED
@@ -84,6 +84,15 @@ export interface UsageEvent extends StreamEvent {
84
84
  cacheReadInputTokens?: number;
85
85
  /** Prompt tokens written to cache this request (billed ~1.25x). Subset of inputTokens. */
86
86
  cacheCreationInputTokens?: number;
87
+ /** I1: per-slot pro-rata attribution of `cacheReadInputTokens`. Estimated, not authoritative —
88
+ * Anthropic returns a single cache-read total, so the SDK divides it evenly across the slots
89
+ * that carried a `cache_control` breakpoint on the request. Missing when the provider doesn't
90
+ * honor `cache_control` (OpenAI-family auto-cache) or when no breakpoints were placed. */
91
+ cacheReadInputTokensBySlot?: {
92
+ system?: number;
93
+ tools?: number;
94
+ messages?: number;
95
+ };
87
96
  }
88
97
  export type ToolChunk = string | {
89
98
  type: "text";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepstrike/sdk",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "DeepStrike Node.js SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@anthropic-ai/sdk": "^0.99.0",
23
- "@deepstrike/core": "0.2.22",
23
+ "@deepstrike/core": "0.2.23",
24
24
  "@google/generative-ai": "^0.24.1",
25
25
  "openai": "^5.23.2"
26
26
  },