@deepstrike/wasm 0.2.73 → 0.2.75

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.
@@ -2,6 +2,7 @@ import { normalizeProviderStopReason } from "../providers/stop-reason.js";
2
2
  import { sha256Hex } from "./sha256.js";
3
3
  import { JournalCasConflictError, MAX_CHAIN_POSITION as JOURNAL_MAX_CHAIN_POSITION, } from "./kernel-journal.js";
4
4
  import { encodeCanonicalContentParts, kernelMessageToSdk, renderedContextToSdk, } from "./kernel-step.js";
5
+ import { toolArgumentsFromWire } from "./tool-arguments.js";
5
6
  export const MAX_CHAIN_POSITION = JOURNAL_MAX_CHAIN_POSITION;
6
7
  const utf8ByteLength = (value) => new TextEncoder().encode(value).byteLength;
7
8
  function asObject(value) {
@@ -46,7 +47,7 @@ function canonicalDoneFromTerminal(terminal) {
46
47
  content: String(finalMessage.content ?? ""),
47
48
  toolCalls: (Array.isArray(finalMessage.tool_calls) ? finalMessage.tool_calls : []).map(value => {
48
49
  const call = asObject(value);
49
- return { id: String(call.call_id ?? ""), name: String(call.name ?? ""), arguments: JSON.stringify(call.arguments ?? {}) };
50
+ return { id: String(call.call_id ?? ""), name: String(call.name ?? ""), arguments: toolArgumentsFromWire(call.arguments) };
50
51
  }),
51
52
  } } : {}),
52
53
  ...(Object.keys(pace).length > 0 ? { paceDecision: {
@@ -95,7 +96,7 @@ export function canonicalActionFromProjectionJson(raw) {
95
96
  return {
96
97
  id: String(call.call_id ?? ""),
97
98
  name: String(call.name ?? ""),
98
- arguments: JSON.stringify(call.arguments ?? {}),
99
+ arguments: toolArgumentsFromWire(call.arguments),
99
100
  };
100
101
  }),
101
102
  };
@@ -126,7 +127,7 @@ export function canonicalActionFromProjectionJson(raw) {
126
127
  return {
127
128
  callId: String(request.call_id ?? ""),
128
129
  tool: String(request.tool_name ?? ""),
129
- arguments: JSON.stringify(request.arguments ?? {}),
130
+ arguments: toolArgumentsFromWire(request.arguments),
130
131
  reason: String(request.reason ?? ""),
131
132
  };
132
133
  }),
@@ -224,6 +225,18 @@ export class CanonicalKernelRejectedError extends Error {
224
225
  this.fault = fault;
225
226
  }
226
227
  }
228
+ /** Fault codes that name a malformed input — the input was wrong, not the run. */
229
+ const INPUT_SHAPE_FAULTS = new Set(["malformed_envelope", "invalid_config"]);
230
+ /**
231
+ * Whether a run-loop failure was the kernel refusing a malformed input (`invalid_arg`) rather than
232
+ * any other error. Decided by the fault the kernel named, or a binding's argument-conversion
233
+ * status — never by searching the message text.
234
+ */
235
+ export function isInvalidInputError(error) {
236
+ if (error instanceof CanonicalKernelRejectedError)
237
+ return INPUT_SHAPE_FAULTS.has(String(error.fault.code));
238
+ return error?.code === "InvalidArg";
239
+ }
227
240
  /**
228
241
  * A record is already authoritative once the journal append returns. A later native commit failure
229
242
  * is therefore a rebuild boundary, never an abort boundary.
@@ -289,28 +302,41 @@ export class CanonicalKernelHost {
289
302
  if (!operationId)
290
303
  throw new TypeError("canonical kernel operationId must not be empty");
291
304
  }
305
+ /**
306
+ * The journal holds one outbound envelope slot per operation, so two transitions of the same
307
+ * operation must never overlap: the second would overwrite the first's staged bytes before its
308
+ * append was acknowledged. Every entry point that stages or drains goes through this queue.
309
+ */
310
+ exclusiveTail = Promise.resolve();
311
+ exclusive(work) {
312
+ const run = this.exclusiveTail.then(work, work);
313
+ this.exclusiveTail = run.catch(() => undefined);
314
+ return run;
315
+ }
292
316
  async transition(input, options = {}) {
293
- const inputJson = JSON.stringify({
294
- operation_id: this.operationId,
295
- input_id: options.inputId ?? `wasm-input-${crypto.randomUUID()}`,
296
- observed_at_ms: options.observedAtMs ?? String(Date.now()),
297
- input,
298
- });
299
- await this.journal.stageOutboundEnvelope(this.operationId, inputJson);
300
- try {
301
- const transition = await this.transitionEnvelope(inputJson, 1, 1);
302
- await this.journal.clearOutboundEnvelope(this.operationId);
303
- return transition;
304
- }
305
- catch (error) {
306
- // Append-acked records own the input; rejected inputs will not retry this envelope.
307
- // Append-before failures leave the staged bytes so wake can drain byte-identical retries.
308
- if (error instanceof CanonicalKernelRebuildRequiredError
309
- || error instanceof CanonicalKernelRejectedError) {
317
+ return this.exclusive(async () => {
318
+ const inputJson = JSON.stringify({
319
+ operation_id: this.operationId,
320
+ input_id: options.inputId ?? `wasm-input-${crypto.randomUUID()}`,
321
+ observed_at_ms: options.observedAtMs ?? String(Date.now()),
322
+ input,
323
+ });
324
+ await this.journal.stageOutboundEnvelope(this.operationId, inputJson);
325
+ try {
326
+ const transition = await this.transitionEnvelope(inputJson, 1, 1);
310
327
  await this.journal.clearOutboundEnvelope(this.operationId);
328
+ return transition;
311
329
  }
312
- throw error;
313
- }
330
+ catch (error) {
331
+ // Append-acked records own the input; rejected inputs will not retry this envelope.
332
+ // Append-before failures leave the staged bytes so wake can drain byte-identical retries.
333
+ if (error instanceof CanonicalKernelRebuildRequiredError
334
+ || error instanceof CanonicalKernelRejectedError) {
335
+ await this.journal.clearOutboundEnvelope(this.operationId);
336
+ }
337
+ throw error;
338
+ }
339
+ });
314
340
  }
315
341
  /** Restore the latest installed checkpoint and its authoritative record tail in place. */
316
342
  async restore() {
@@ -323,21 +349,23 @@ export class CanonicalKernelHost {
323
349
  * No-op when nothing is staged. Clears the stage after commit/replay/reject.
324
350
  */
325
351
  async drainOutboundEnvelope() {
326
- const pending = await this.journal.readOutboundEnvelope(this.operationId);
327
- if (!pending)
328
- return undefined;
329
- try {
330
- const transition = await this.transitionEnvelope(pending, 1, 1);
331
- await this.journal.clearOutboundEnvelope(this.operationId);
332
- return transition;
333
- }
334
- catch (error) {
335
- if (error instanceof CanonicalKernelRebuildRequiredError
336
- || error instanceof CanonicalKernelRejectedError) {
352
+ return this.exclusive(async () => {
353
+ const pending = await this.journal.readOutboundEnvelope(this.operationId);
354
+ if (!pending)
355
+ return undefined;
356
+ try {
357
+ const transition = await this.transitionEnvelope(pending, 1, 1);
337
358
  await this.journal.clearOutboundEnvelope(this.operationId);
359
+ return transition;
338
360
  }
339
- throw error;
340
- }
361
+ catch (error) {
362
+ if (error instanceof CanonicalKernelRebuildRequiredError
363
+ || error instanceof CanonicalKernelRejectedError) {
364
+ await this.journal.clearOutboundEnvelope(this.operationId);
365
+ }
366
+ throw error;
367
+ }
368
+ });
341
369
  }
342
370
  /** Execute the full §12.3 install/ack/reclaim boundary. */
343
371
  async checkpoint() {
@@ -446,7 +474,35 @@ export class CanonicalKernelHost {
446
474
  return transition;
447
475
  }
448
476
  }
449
- function canonicalProviderMessage(raw) {
477
+ /** Host-reported per-task failures: `[{ agent_id, error, kind? }]`. */
478
+ function launchFailures(raw) {
479
+ const failures = new Map();
480
+ for (const value of Array.isArray(raw) ? raw : []) {
481
+ const failure = asObject(value);
482
+ const taskId = String(failure.agent_id ?? failure.task_id ?? "");
483
+ if (!taskId)
484
+ continue;
485
+ failures.set(taskId, {
486
+ kind: String(failure.kind ?? "unknown"),
487
+ message: String(failure.error ?? failure.message ?? ""),
488
+ });
489
+ }
490
+ return failures;
491
+ }
492
+ /** Child usage as observed: the split only when the host measured it, never an invented zero. */
493
+ function childUsage(result) {
494
+ const usage = {};
495
+ if (result.input_tokens !== undefined)
496
+ usage.input_tokens = String(result.input_tokens);
497
+ if (result.output_tokens !== undefined)
498
+ usage.output_tokens = String(result.output_tokens);
499
+ if (result.total_tokens_used !== undefined)
500
+ usage.total_tokens = String(result.total_tokens_used);
501
+ if (result.turns_used !== undefined)
502
+ usage.turns = Number(result.turns_used);
503
+ return usage;
504
+ }
505
+ function canonicalProviderMessage(raw, appendBase = 0) {
450
506
  const content = raw.content;
451
507
  return {
452
508
  role: String(raw.role ?? "assistant"),
@@ -458,7 +514,9 @@ function canonicalProviderMessage(raw) {
458
514
  return {
459
515
  call_id: String(call.call_id ?? call.id ?? ""),
460
516
  name: String(call.name ?? ""),
461
- arguments: canonicalProviderToolArguments(String(call.name ?? ""), asObject(call.arguments)),
517
+ arguments: typeof call.arguments === "string"
518
+ ? call.arguments
519
+ : canonicalProviderToolArguments(String(call.name ?? ""), asObject(call.arguments), appendBase),
462
520
  };
463
521
  }),
464
522
  }
@@ -466,7 +524,18 @@ function canonicalProviderMessage(raw) {
466
524
  ...(raw.token_count !== undefined ? { tokens: Number(raw.token_count) } : {}),
467
525
  };
468
526
  }
469
- function canonicalProviderToolArguments(name, argumentsValue) {
527
+ function canonicalProviderToolArguments(name, argumentsValue, appendBase) {
528
+ try {
529
+ return canonicalWorkflowToolArguments(name, argumentsValue, appendBase);
530
+ }
531
+ catch {
532
+ // The model wrote a workflow the canonical DAG cannot express. That is the model's bad
533
+ // arguments, not a host protocol violation: forward them verbatim so the kernel's syscall
534
+ // decoder answers with a model-visible rejection instead of the host failing the whole run.
535
+ return argumentsValue;
536
+ }
537
+ }
538
+ function canonicalWorkflowToolArguments(name, argumentsValue, appendBase) {
470
539
  if (name === "start_workflow") {
471
540
  const wrapped = asObject(argumentsValue.spec);
472
541
  return canonicalWorkflowSpec(Object.keys(wrapped).length > 0 ? wrapped : argumentsValue);
@@ -474,22 +543,53 @@ function canonicalProviderToolArguments(name, argumentsValue) {
474
543
  if (name === "submit_workflow_nodes") {
475
544
  const spec = canonicalWorkflowSpec({
476
545
  nodes: Array.isArray(argumentsValue.nodes) ? argumentsValue.nodes : [],
477
- });
546
+ }, false, appendBase);
478
547
  return { nodes: spec.nodes };
479
548
  }
480
549
  return argumentsValue;
481
550
  }
551
+ function anonymousNodeId(base, index) {
552
+ return typeof base === "number" ? `wf-node${base + index}` : `${base}-${index}`;
553
+ }
482
554
  function canonicalInitialMessage(raw) {
555
+ const tokens = raw.token_count !== undefined ? { tokens: Number(raw.token_count) } : {};
483
556
  if (Array.isArray(raw.content)) {
557
+ // A lone tool result is a structural pairing, not opaque parts: send it as `tool_call_id` so
558
+ // the kernel keeps it bound to the assistant call that produced it.
559
+ const parts = raw.content.map(asObject);
560
+ if (parts.length === 1 && parts[0].type === "tool_result" && typeof parts[0].call_id === "string") {
561
+ return {
562
+ role: String(raw.role ?? "tool"),
563
+ content: String(parts[0].output ?? ""),
564
+ tool_call_id: parts[0].call_id,
565
+ ...(parts[0].is_error === true ? { is_error: true } : {}),
566
+ ...tokens,
567
+ };
568
+ }
484
569
  return {
485
570
  role: String(raw.role ?? "user"),
486
571
  content: encodeCanonicalContentParts(raw.content),
487
- ...(raw.token_count !== undefined ? { tokens: Number(raw.token_count) } : {}),
572
+ ...tokens,
488
573
  };
489
574
  }
490
- const message = canonicalProviderMessage(raw);
491
- delete message.tool_calls;
492
- return message;
575
+ // History tool calls are replayed facts, not new syscalls: keep them verbatim.
576
+ const content = raw.content;
577
+ const toolCalls = Array.isArray(raw.tool_calls) ? raw.tool_calls.map(asObject) : [];
578
+ return {
579
+ role: String(raw.role ?? "assistant"),
580
+ content: typeof content === "string" ? content : JSON.stringify(content ?? ""),
581
+ ...(toolCalls.length > 0
582
+ ? {
583
+ tool_calls: toolCalls.map(call => ({
584
+ call_id: String(call.call_id ?? call.id ?? ""),
585
+ name: String(call.name ?? ""),
586
+ arguments: asObject(call.arguments),
587
+ })),
588
+ }
589
+ : {}),
590
+ ...(typeof raw.tool_call_id === "string" ? { tool_call_id: raw.tool_call_id } : {}),
591
+ ...tokens,
592
+ };
493
593
  }
494
594
  function logicalRunSpec(raw, goal) {
495
595
  if (!raw)
@@ -538,9 +638,17 @@ function logicalRunSpec(raw, goal) {
538
638
  ...(raw.metadata && typeof raw.metadata === "object" ? { metadata: raw.metadata } : {}),
539
639
  };
540
640
  }
541
- function canonicalWorkflowSpec(raw, allowHostSchedulingFactors = false) {
641
+ function canonicalWorkflowSpec(raw, allowHostSchedulingFactors = false, nodeIdBase = 0) {
542
642
  const nodes = Array.isArray(raw.nodes) ? raw.nodes.map(asObject) : [];
543
- const nodeIds = nodes.map((_node, index) => `wf-node${index}`);
643
+ // A caller-declared id is the node's wire identity. Only an anonymous node gets a positional id,
644
+ // offset by the DAG's current size so ids stay unique across appended batches — the kernel
645
+ // refuses an append whose id is already declared.
646
+ const nodeIds = nodes.map((node, index) => {
647
+ const declared = node.node_id ?? node.nodeId;
648
+ return typeof declared === "string" && declared.length > 0
649
+ ? declared
650
+ : anonymousNodeId(nodeIdBase, index);
651
+ });
544
652
  return {
545
653
  nodes: nodes.map((node, index) => {
546
654
  const unsupported = [];
@@ -654,9 +762,17 @@ export class CanonicalRunnerRuntime {
654
762
  started = false;
655
763
  turns = 0;
656
764
  lastAction = null;
765
+ /** Live policy revision (§13.2): 0 at genesis, then each `live_policy_changed`. Unknown after a
766
+ * restore, when a patch must name the revision it expects explicitly. */
767
+ policyRevision = 0;
768
+ /** Kernel-minted attempt per launched task, learned from each launch acknowledgement. */
769
+ taskAttempts = new Map();
657
770
  newMessages = [];
658
771
  hostObservations = [];
659
772
  spawnedTasks = 0;
773
+ /** Nodes in the active workflow DAG, mirrored from committed facts so anonymous appended nodes get
774
+ * unique positional ids. `undefined` after a restore until the next committed submission. */
775
+ workflowNodeCount = 0;
660
776
  memoryBindingId;
661
777
  payloadInlineThreshold = 50 * 1024;
662
778
  payloadPreviewBytes = 2 * 1024;
@@ -714,6 +830,10 @@ export class CanonicalRunnerRuntime {
714
830
  isTerminal() {
715
831
  return ["completed", "cancelled", "failed"].includes(this.host.kernel.lifecycle());
716
832
  }
833
+ /** Whether the operation has a root — the precondition for any live host control command. */
834
+ hasStarted() {
835
+ return this.started;
836
+ }
717
837
  recoveryContentBytes() {
718
838
  return Math.max(1_024, this.options.maxContextTokens * 4);
719
839
  }
@@ -735,11 +855,16 @@ export class CanonicalRunnerRuntime {
735
855
  }
736
856
  async restore() {
737
857
  await this.host.restore();
858
+ this.workflowNodeCount = undefined;
859
+ this.policyRevision = undefined;
738
860
  this.configured = this.host.kernel.lifecycle() !== "created";
739
861
  this.started = !["created", "configured"].includes(this.host.kernel.lifecycle());
740
862
  // A crash between stage and append-ack leaves a byte-identical envelope; drain it before
741
863
  // the host effect loop so retries never remint observed_at_ms.
742
- await this.host.drainOutboundEnvelope();
864
+ // The crash-window envelope commits here, so its observations are this run's facts too.
865
+ const drained = await this.host.drainOutboundEnvelope();
866
+ if (drained)
867
+ this.absorbTransition(drained);
743
868
  this.lastAction = this.currentAction();
744
869
  }
745
870
  resumeAction() {
@@ -768,25 +893,35 @@ export class CanonicalRunnerRuntime {
768
893
  }
769
894
  async startWorkflow(specValue) {
770
895
  await this.ensureConfigured();
896
+ const spec = canonicalWorkflowSpec(specValue, true);
771
897
  const action = await this.commit({
772
898
  kind: "start_operation",
773
899
  entry: {
774
900
  kind: "workflow",
775
- spec: canonicalWorkflowSpec(specValue, true),
901
+ spec,
776
902
  },
777
903
  initial_context: this.initialContext,
778
904
  });
779
905
  this.started = true;
906
+ this.workflowNodeCount = spec.nodes.length;
780
907
  return action;
781
908
  }
909
+ appendBase() {
910
+ return this.workflowNodeCount ?? `wf-append-${crypto.randomUUID().slice(0, 8)}`;
911
+ }
782
912
  async applyHostEvent(event) {
783
913
  if (!this.started && this.applyBootstrapEvent(event))
784
914
  return null;
785
915
  let input;
916
+ // What this input adds to the host's view (new messages, the turn count) is staged and only
917
+ // applied once the kernel commits it: a refused input must leave the host exactly where the
918
+ // kernel still is.
919
+ const staged = [];
920
+ let stagedTurns = 0;
786
921
  switch (event.kind) {
787
922
  case "provider_result": {
788
- const message = canonicalProviderMessage(asObject(event.message));
789
- this.newMessages.push({
923
+ const message = canonicalProviderMessage(asObject(event.message), this.appendBase());
924
+ staged.push({
790
925
  role: message.role,
791
926
  content: String(message.content ?? ""),
792
927
  toolCalls: (Array.isArray(message.tool_calls) ? message.tool_calls : []).map(raw => {
@@ -794,11 +929,11 @@ export class CanonicalRunnerRuntime {
794
929
  return {
795
930
  id: String(call.call_id ?? ""),
796
931
  name: String(call.name ?? ""),
797
- arguments: JSON.stringify(call.arguments ?? {}),
932
+ arguments: toolArgumentsFromWire(call.arguments),
798
933
  };
799
934
  }),
800
935
  });
801
- this.turns += 1;
936
+ stagedTurns += 1;
802
937
  input = {
803
938
  kind: "resolve_effect",
804
939
  effect_id: String(event.effect_id ?? ""),
@@ -881,7 +1016,7 @@ export class CanonicalRunnerRuntime {
881
1016
  },
882
1017
  });
883
1018
  }
884
- this.newMessages.push({ role: "tool", content: output, toolCalls: [] });
1019
+ staged.push({ role: "tool", content: output, toolCalls: [] });
885
1020
  }
886
1021
  input = this.succeededEffect(event, {
887
1022
  kind: "tools",
@@ -898,26 +1033,51 @@ export class CanonicalRunnerRuntime {
898
1033
  });
899
1034
  break;
900
1035
  case "workflow_spawn_result": {
901
- const spawn = this.lastAction?.kind === "spawn_workflow" ? this.lastAction : undefined;
902
- this.spawnedTasks += spawn?.nodes.length ?? 0;
903
- input = this.succeededEffect(event, {
904
- kind: "tasks_spawned",
905
- attempts: (spawn?.nodes ?? []).map(node => ({
906
- task_id: String(node.task_id ?? node.agent_id ?? ""),
907
- attempt_id: String(node.attempt_id ?? ""),
908
- outcome: { status: "started" },
909
- })),
1036
+ // The launch set comes from the kernel's own pending effect (by id, never "the last
1037
+ // action"), and each task's outcome from what the host reports it actually started.
1038
+ const tasks = this.pendingEffectPayload(String(event.effect_id ?? ""), "spawn_tasks", "tasks");
1039
+ const started = Array.isArray(event.started_agent_ids)
1040
+ ? new Set(event.started_agent_ids.map(String))
1041
+ : undefined;
1042
+ const failures = launchFailures(event.failures);
1043
+ const attempts = tasks.map(task => {
1044
+ const taskId = String(task.task_id ?? "");
1045
+ const attemptId = String(task.attempt_id ?? "");
1046
+ const failure = failures.get(taskId) ??
1047
+ (started && !started.has(taskId)
1048
+ ? { kind: "protocol_error", message: "the host did not report launching this task" }
1049
+ : undefined);
1050
+ if (!failure)
1051
+ this.taskAttempts.set(taskId, attemptId);
1052
+ return {
1053
+ task_id: taskId,
1054
+ attempt_id: attemptId,
1055
+ outcome: failure ? { status: "failed", failure } : { status: "started" },
1056
+ };
910
1057
  });
1058
+ this.spawnedTasks += attempts.filter(attempt => attempt.outcome.status === "started").length;
1059
+ input = this.succeededEffect(event, { kind: "tasks_spawned", attempts });
911
1060
  break;
912
1061
  }
913
1062
  case "preempt_result": {
914
- const preempt = this.lastAction?.kind === "preempt_sub_agents" ? this.lastAction : undefined;
1063
+ const refs = this.pendingEffectPayload(String(event.effect_id ?? ""), "preempt_tasks", "attempts");
1064
+ const finished = new Set(Array.isArray(event.already_finished_agent_ids)
1065
+ ? event.already_finished_agent_ids.map(String)
1066
+ : []);
1067
+ const failures = launchFailures(event.failures);
915
1068
  input = this.succeededEffect(event, {
916
1069
  kind: "tasks_preempted",
917
- attempts: (preempt?.attempts ?? []).map(attempt => ({
918
- ...attempt,
919
- outcome: { status: "preempted" },
920
- })),
1070
+ attempts: refs.map(ref => {
1071
+ const taskId = String(ref.task_id ?? "");
1072
+ const failure = failures.get(taskId);
1073
+ return {
1074
+ task_id: taskId,
1075
+ attempt_id: String(ref.attempt_id ?? ""),
1076
+ outcome: failure
1077
+ ? { status: "failed", failure }
1078
+ : { status: finished.has(taskId) ? "already_finished" : "preempted" },
1079
+ };
1080
+ }),
921
1081
  });
922
1082
  break;
923
1083
  }
@@ -928,40 +1088,36 @@ export class CanonicalRunnerRuntime {
928
1088
  ? raw.submitted_nodes.map(asObject)
929
1089
  : [];
930
1090
  const taskId = String(raw.agent_id ?? "");
931
- const pending = this.pendingEffects().find(effect => {
932
- const kind = asObject(effect.effect);
933
- return kind.kind === "spawn_tasks" &&
934
- (Array.isArray(kind.tasks) ? kind.tasks : []).some(task => asObject(task).task_id === taskId);
935
- });
936
- const launch = pending
937
- ? (Array.isArray(asObject(pending.effect).tasks) ? asObject(pending.effect).tasks : [])
938
- .map(asObject).find(task => task.task_id === taskId)
939
- : undefined;
1091
+ // Child identity is the kernel's: the attempt it minted for this launch, carried by the
1092
+ // host from the spawned node or remembered from the launch acknowledgement.
1093
+ const attemptId = typeof event.attempt_id === "string" && event.attempt_id
1094
+ ? event.attempt_id
1095
+ : this.taskAttempts.get(taskId);
1096
+ if (!attemptId) {
1097
+ throw new Error(`sub_agent_completed for ${taskId} names no kernel-minted attempt id`);
1098
+ }
1099
+ const termination = String(result.termination ?? "error");
1100
+ const status = termination === "completed"
1101
+ ? "completed"
1102
+ : termination === "user_abort" ? "cancelled" : "failed";
1103
+ const output = asObject(result.final_message).content;
940
1104
  input = {
941
1105
  kind: "deliver_external_event",
942
1106
  event: {
943
1107
  kind: "child_completed",
944
1108
  task_id: taskId,
945
- attempt_id: String(launch?.attempt_id ?? `${taskId}:attempt:1`),
1109
+ attempt_id: attemptId,
946
1110
  result: {
947
- status: result.termination === "completed" ? "completed" : "failed",
948
- ...(asObject(result.final_message).content
949
- ? { output: String(asObject(result.final_message).content) }
950
- : {}),
951
- ...(!["completed", "max_turns", "token_budget"].includes(String(result.termination))
952
- ? { error: String(result.termination ?? "failed") }
953
- : {}),
954
- usage: {
955
- input_tokens: "0",
956
- output_tokens: String(result.total_tokens_used ?? 0),
957
- turns: Number(result.turns_used ?? 0),
958
- },
1111
+ status,
1112
+ ...(output ? { output: String(output) } : {}),
1113
+ ...(status !== "completed" ? { error: termination } : {}),
1114
+ usage: childUsage(result),
959
1115
  },
960
1116
  ...(submittedNodes.length > 0
961
1117
  ? {
962
1118
  parent_requests: [{
963
1119
  kind: "append_workflow_nodes",
964
- nodes: asObject(canonicalWorkflowSpec({ nodes: submittedNodes })).nodes,
1120
+ nodes: asObject(canonicalWorkflowSpec({ nodes: submittedNodes }, false, this.appendBase())).nodes,
965
1121
  }],
966
1122
  }
967
1123
  : {}),
@@ -970,31 +1126,43 @@ export class CanonicalRunnerRuntime {
970
1126
  break;
971
1127
  }
972
1128
  case "memory_persist_result":
1129
+ // A receipt names what the store actually holds. Without the write's record ref and
1130
+ // content digest there is nothing to attest: fail the effect rather than resolve it with
1131
+ // an identity minted here (non-retryable — re-publishing cannot conjure a receipt).
973
1132
  input = event.error
974
1133
  ? this.failedEffect(event, "storage_unavailable", String(event.error), true)
975
- : this.succeededEffect(event, {
976
- kind: "memory_persisted",
977
- receipt: {
978
- binding_id: this.memoryBindingId,
979
- record_ref: String(event.record_ref ?? `memory:${crypto.randomUUID()}`),
980
- digest: String(event.digest ?? sha256(String(event.record_ref ?? event.effect_id ?? ""))),
981
- },
982
- });
1134
+ : !event.record_ref || !event.digest
1135
+ ? this.failedEffect(event, "storage_unavailable", "memory store returned no record_ref/digest receipt", false)
1136
+ : this.succeededEffect(event, {
1137
+ kind: "memory_persisted",
1138
+ receipt: {
1139
+ binding_id: this.memoryBindingId,
1140
+ record_ref: String(event.record_ref),
1141
+ digest: String(event.digest),
1142
+ },
1143
+ });
983
1144
  break;
984
1145
  case "memory_query_result":
985
1146
  input = event.error
986
1147
  ? this.failedEffect(event, "storage_unavailable", String(event.error), true)
987
1148
  : this.succeededEffect(event, {
988
1149
  kind: "memory_queried",
989
- recalls: (Array.isArray(event.hits) ? event.hits : []).map(value => {
1150
+ // A recall without a store identity cannot be attributed or re-read; drop it
1151
+ // instead of minting one.
1152
+ recalls: (Array.isArray(event.hits) ? event.hits : []).filter(value => Boolean(asObject(asObject(value).record).record_id)).map(value => {
990
1153
  const hit = asObject(value);
991
1154
  const record = asObject(hit.record);
992
1155
  return {
993
- record_ref: String(record.record_id ?? `memory:${crypto.randomUUID()}`),
1156
+ record_ref: String(record.record_id),
994
1157
  name: String(record.name ?? ""),
995
1158
  kind: String(record.kind ?? "reference"),
996
1159
  content: String(record.content ?? ""),
997
1160
  ...(typeof hit.score === "number" ? { score: hit.score } : {}),
1161
+ // M3: the store reports the count it holds; the kernel derives the next one.
1162
+ ...(Number(record.recall_count) > 0
1163
+ ? { recall_count: String(Math.trunc(Number(record.recall_count))) }
1164
+ : {}),
1165
+ ...(record.pinned === true ? { pinned: true } : {}),
998
1166
  };
999
1167
  }),
1000
1168
  });
@@ -1065,6 +1233,54 @@ export class CanonicalRunnerRuntime {
1065
1233
  },
1066
1234
  };
1067
1235
  break;
1236
+ case "force_compact":
1237
+ input = { kind: "host_control", command: { kind: "force_compact" } };
1238
+ break;
1239
+ case "admit_memory_write":
1240
+ input = {
1241
+ kind: "host_control",
1242
+ command: {
1243
+ kind: "admit_memory_write",
1244
+ record_id: String(event.record_id ?? ""),
1245
+ name: String(event.name ?? ""),
1246
+ content_bytes: Number(event.content_bytes ?? 0),
1247
+ },
1248
+ };
1249
+ break;
1250
+ case "update_deadline":
1251
+ input = {
1252
+ kind: "host_control",
1253
+ command: {
1254
+ kind: "update_deadline",
1255
+ ...(event.deadline_ms !== undefined && event.deadline_ms !== null
1256
+ ? { deadline_ms: String(event.deadline_ms) }
1257
+ : {}),
1258
+ },
1259
+ };
1260
+ break;
1261
+ case "apply_policy_patch": {
1262
+ const expected = event.expected_revision ?? this.policyRevision;
1263
+ if (expected === undefined) {
1264
+ throw new Error("the live policy revision is unknown after a restore; pass expectedRevision explicitly");
1265
+ }
1266
+ input = {
1267
+ kind: "host_control",
1268
+ command: { kind: "apply_policy_patch", expected_revision: String(expected), patch: event.patch },
1269
+ };
1270
+ break;
1271
+ }
1272
+ case "skill_activation":
1273
+ input = {
1274
+ kind: "host_control",
1275
+ command: {
1276
+ kind: "apply_skill_activation",
1277
+ activate: [{
1278
+ name: String(event.name ?? ""),
1279
+ ...(event.lease_turns !== undefined ? { lease_turns: Number(event.lease_turns) } : {}),
1280
+ }],
1281
+ },
1282
+ };
1283
+ break;
1068
1284
  case "skill_deactivated":
1069
1285
  input = {
1070
1286
  kind: "host_control",
@@ -1085,7 +1301,19 @@ export class CanonicalRunnerRuntime {
1085
1301
  default:
1086
1302
  throw new Error(`WASM host fact has no canonical ABI input: ${String(event.kind)}`);
1087
1303
  }
1088
- return this.commit(input);
1304
+ const action = await this.commit(input);
1305
+ this.newMessages.push(...staged);
1306
+ this.turns += stagedTurns;
1307
+ // A model-authored `start_workflow` replaces the DAG (§10.2); an admitted one publishes its spawn.
1308
+ if (event.kind === "provider_result" && action?.kind === "spawn_workflow") {
1309
+ const message = input.outcome;
1310
+ const started = (message.result?.outcome?.message?.tool_calls ?? [])
1311
+ .map(asObject)
1312
+ .find(call => call.name === "start_workflow");
1313
+ if (started)
1314
+ this.workflowNodeCount = asObject(started.arguments).nodes?.length ?? 0;
1315
+ }
1316
+ return action;
1089
1317
  }
1090
1318
  async ensureConfigured() {
1091
1319
  if (this.configured)
@@ -1113,36 +1341,7 @@ export class CanonicalRunnerRuntime {
1113
1341
  this.lastAction = this.currentAction();
1114
1342
  }
1115
1343
  if (transition) {
1116
- if (!transition.replayed) {
1117
- for (const raw of transition.plannedStep.observations ?? []) {
1118
- const kind = String(raw.kind ?? "");
1119
- if (!kind)
1120
- throw new Error("canonical observation is missing kind");
1121
- this.hostObservations.push({ ...raw, kind });
1122
- }
1123
- // §7.11 · a committed step that publishes effects is a fact worth recording in the
1124
- // host event log: the journal stores only a digest of the step, so this manifest is
1125
- // what makes the published effect ids + kinds recoverable post-hoc without replaying.
1126
- const published = JSON.parse(this.host.kernel.publishedEffectsManifestJson(JSON.stringify(transition.plannedStep)));
1127
- if (published.length > 0) {
1128
- this.hostObservations.push({
1129
- kind: "step_published_effects",
1130
- effects: published,
1131
- });
1132
- }
1133
- if (transition.checkpointAdvice) {
1134
- this.hostObservations.push({
1135
- kind: "checkpoint_advised",
1136
- ...transition.checkpointAdvice,
1137
- });
1138
- }
1139
- if (transition.checkpointFailure) {
1140
- this.hostObservations.push({
1141
- kind: "checkpoint_deferred",
1142
- reason: transition.checkpointFailure,
1143
- });
1144
- }
1145
- }
1344
+ this.absorbTransition(transition);
1146
1345
  this.lastAction = canonicalActionFromProjectionJson(this.host.kernel.projectPlannedStepJson(JSON.stringify(transition.plannedStep)));
1147
1346
  }
1148
1347
  if (this.lastAction?.kind !== "unsupported_effect")
@@ -1150,12 +1349,59 @@ export class CanonicalRunnerRuntime {
1150
1349
  nextInput = canonicalUnsupportedEffectResolution(this.lastAction.effectId, this.lastAction.effectKind);
1151
1350
  }
1152
1351
  }
1352
+ /** Record what a committed transition tells the host: its observations and published effects. */
1353
+ absorbTransition(transition) {
1354
+ if (!transition.replayed) {
1355
+ for (const raw of transition.plannedStep.observations ?? []) {
1356
+ const kind = String(raw.kind ?? "");
1357
+ if (!kind)
1358
+ throw new Error("canonical observation is missing kind");
1359
+ if (kind === "workflow_nodes_submitted") {
1360
+ this.workflowNodeCount = Number(raw.base ?? 0) + Number(raw.count ?? 0);
1361
+ }
1362
+ if (kind === "live_policy_changed")
1363
+ this.policyRevision = Number(raw.revision ?? 0);
1364
+ this.hostObservations.push({ ...raw, kind });
1365
+ }
1366
+ // §7.11 · a committed step that publishes effects is a fact worth recording in the
1367
+ // host event log: the journal stores only a digest of the step, so this manifest is
1368
+ // what makes the published effect ids + kinds recoverable post-hoc without replaying.
1369
+ const published = JSON.parse(this.host.kernel.publishedEffectsManifestJson(JSON.stringify(transition.plannedStep)));
1370
+ if (published.length > 0) {
1371
+ this.hostObservations.push({
1372
+ kind: "step_published_effects",
1373
+ effects: published,
1374
+ });
1375
+ }
1376
+ if (transition.checkpointAdvice) {
1377
+ this.hostObservations.push({
1378
+ kind: "checkpoint_advised",
1379
+ ...transition.checkpointAdvice,
1380
+ });
1381
+ }
1382
+ if (transition.checkpointFailure) {
1383
+ this.hostObservations.push({
1384
+ kind: "checkpoint_deferred",
1385
+ reason: transition.checkpointFailure,
1386
+ });
1387
+ }
1388
+ }
1389
+ }
1153
1390
  currentAction() {
1154
1391
  return canonicalActionFromProjectionJson(this.host.kernel.projectionJson());
1155
1392
  }
1156
1393
  pendingEffects() {
1157
1394
  return JSON.parse(this.host.kernel.pendingEffectsJson());
1158
1395
  }
1396
+ /** One list field of the pending effect `effectId`, which must be of kind `kind`. */
1397
+ pendingEffectPayload(effectId, kind, field) {
1398
+ const pending = this.pendingEffects().find(effect => String(effect.effect_id ?? "") === effectId);
1399
+ const effect = asObject(pending?.effect);
1400
+ if (effect.kind !== kind) {
1401
+ throw new Error(`effect ${effectId} is not a pending ${kind} effect`);
1402
+ }
1403
+ return (Array.isArray(effect[field]) ? effect[field] : []).map(asObject);
1404
+ }
1159
1405
  succeededEffect(event, result) {
1160
1406
  return {
1161
1407
  kind: "resolve_effect",
@@ -1182,11 +1428,16 @@ export class CanonicalRunnerRuntime {
1182
1428
  const effect = asObject(pending?.effect);
1183
1429
  const payload = asObject(effect.payload);
1184
1430
  const content = String(payload.content ?? "");
1431
+ const payloadRef = event.payload_ref ?? event.archive_ref;
1432
+ // The kernel later `load_payload`s this ref; a minted ref names bytes no store holds.
1433
+ if (!payloadRef) {
1434
+ return this.failedEffect(event, "storage_unavailable", "archive store returned no payload_ref; nothing was persisted", false);
1435
+ }
1185
1436
  return this.succeededEffect(event, {
1186
1437
  kind: "page_out_archived",
1187
1438
  receipt: {
1188
1439
  handle_id: String(effect.handle_id ?? ""),
1189
- payload_ref: String(event.payload_ref ?? `payload:${crypto.randomUUID()}`),
1440
+ payload_ref: String(payloadRef),
1190
1441
  digest: String(payload.digest ?? sha256(content)),
1191
1442
  original_size: String(payload.original_size ?? utf8ByteLength(content)),
1192
1443
  },
@@ -1194,24 +1445,23 @@ export class CanonicalRunnerRuntime {
1194
1445
  }
1195
1446
  canonicalSignal(event) {
1196
1447
  const signal = asObject(event.signal);
1197
- const deliveryId = String(event.delivery_id ?? crypto.randomUUID());
1198
- const payload = deliveryId.startsWith("injected-") && typeof signal.summary === "string"
1199
- ? signal.summary
1200
- : signal.payload ?? {};
1201
1448
  return {
1202
1449
  kind: "deliver_signal",
1203
- delivery_id: deliveryId,
1450
+ delivery_id: String(event.delivery_id ?? crypto.randomUUID()),
1204
1451
  attempt: Number(event.attempt ?? 1),
1205
1452
  signal: {
1206
1453
  signal_id: String(signal.signal_id ?? signal.id ?? crypto.randomUUID()),
1207
1454
  ...(signal.source ? { source: signal.source } : {}),
1208
- target: signal.recipient
1209
- ? { kind: "task", task_id: String(signal.recipient) }
1210
- : { kind: "operation" },
1455
+ target: signal.target && typeof signal.target === "object"
1456
+ ? signal.target
1457
+ : signal.recipient
1458
+ ? { kind: "task", task_id: String(signal.recipient) }
1459
+ : { kind: "operation" },
1211
1460
  ...(signal.urgency ? { urgency: signal.urgency } : {}),
1212
- payload,
1213
- ...(signal.timestamp_ms !== undefined ? { source_timestamp_ms: String(signal.timestamp_ms) } : {}),
1461
+ payload: signal.payload ?? {},
1462
+ ...(signal.source_timestamp_ms !== undefined ? { source_timestamp_ms: String(signal.source_timestamp_ms) } : {}),
1214
1463
  ...(signal.dedupe_key ? { dedupe_key: signal.dedupe_key } : {}),
1464
+ ...(signal.escalate_after_ms !== undefined ? { escalate_after_ms: String(signal.escalate_after_ms) } : {}),
1215
1465
  },
1216
1466
  };
1217
1467
  }