@springbrand/agent-runtime 0.3.0-alpha.16 → 0.3.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.3.0-alpha.16",
3
+ "version": "0.3.0-alpha.18",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -21,13 +21,15 @@
21
21
  "@cloudflare/sandbox": "0.12.4",
22
22
  "@cloudflare/shell": "0.4.3",
23
23
  "@cloudflare/think": "0.18.0",
24
- "@earendil-works/pi-agent-core": "0.86.1",
25
- "@earendil-works/pi-ai": "0.86.1",
24
+ "@earendil-works/pi-agent-core": "1.1.0",
25
+ "@earendil-works/pi-ai": "1.1.0",
26
26
  "@types/lodash-es": "^4.17.12",
27
27
  "cron-schedule": "6.0.0",
28
28
  "isomorphic-git": "1.38.6",
29
29
  "lodash-es": "^4.18.1",
30
- "zod": "^4.4.3"
30
+ "zod": "^4.4.3",
31
+ "@earendil-works/pi-durable": "1.1.0",
32
+ "@earendil-works/chord": "1.1.0"
31
33
  },
32
34
  "peerDependencies": {
33
35
  "agents": "^0.23.0",
@@ -69,9 +69,9 @@ import type {
69
69
  AssistantMessage,
70
70
  JsonObject,
71
71
  } from "@earendil-works/pi-ai";
72
- import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core/harness/context";
72
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
73
73
  import { openCloudflareSession } from "../../session";
74
- import { openSessionHarness, prepareSubmissionOperation } from "../../harness";
74
+ import { ModelRecovery, openSessionHarness, prepareSubmissionOperation } from "../../harness";
75
75
  import type { SessionHarness } from "../../harness";
76
76
  import {
77
77
  compileHarnessTools,
@@ -185,7 +185,7 @@ export abstract class AgentRuntimeKernel<
185
185
 
186
186
  private activeRuntime?: ActiveRuntime;
187
187
  private telemetryCoordinator?: RuntimeTelemetryCoordinator;
188
- private activeHarness?: SessionHarness<object | undefined>;
188
+ private activeHarness?: SessionHarness;
189
189
  private piAdapter?: HarnessRuntimeConfig;
190
190
  private readonly transcript: ConversationHistory;
191
191
  private conversationBridge?: HarnessConversationBridge;
@@ -370,14 +370,14 @@ export abstract class AgentRuntimeKernel<
370
370
  runtime: Awaited<ReturnType<typeof openSessionHarness>>,
371
371
  submissionId: string,
372
372
  ): Promise<void> {
373
- const watch = await runtime.lane.watch(BACKGROUND_CONTEXT);
374
- try {
375
- this.db.transaction(() =>
376
- this.db.submissions.associateSessionEntries(submissionId, watch.snapshot)
377
- );
378
- } finally {
379
- watch.unsubscribe();
380
- }
373
+ const native = await runtime.submissionFor(submissionId, BACKGROUND_CONTEXT);
374
+ if (!native || native.entry === undefined) return;
375
+ const entries = await runtime.entries();
376
+ const start = entries.findIndex((entry) => entry.id === native.entry);
377
+ if (start < 0) throw new Error("Native Submission entry is missing from its conversation");
378
+ this.db.transaction(() => {
379
+ for (const entry of entries.slice(start)) this.db.submissions.linkSessionEntry(String(entry.id), submissionId);
380
+ });
381
381
  }
382
382
 
383
383
  /**
@@ -632,11 +632,16 @@ export abstract class AgentRuntimeKernel<
632
632
  releaseAbortedHarness: async () => {
633
633
  const runtime = this.activeHarness;
634
634
  if (!runtime) return;
635
- await runtime.lane.waitForIdle(BACKGROUND_CONTEXT);
636
- if (this.activeHarness === runtime) this.activeHarness = undefined;
637
- await runtime.close(BACKGROUND_CONTEXT);
635
+ await runtime.conversation.waitForIdle(BACKGROUND_CONTEXT);
636
+ // The execution owner closes after transport projections settle.
637
+
638
+ },
639
+ cancelQueued: async (id) => {
640
+ const runtime = this.activeHarness;
641
+ if (!runtime) return undefined;
642
+ const result = await runtime.harness.abortSubmission(Number(id) as import("@earendil-works/pi-durable").SubmissionId, BACKGROUND_CONTEXT, runtime.conversation.id);
643
+ return { kind: result === "already_placed" || result === "settled" ? "already_consumed" as const : "cancelled" as const };
638
644
  },
639
- cancelQueued: async (id) => this.activeHarness?.lane.cancelQueued(id, BACKGROUND_CONTEXT),
640
645
  submit: (message, options) => this.submitMessage(message, options),
641
646
  waitUntil: (promise) => this.ctx.waitUntil(promise),
642
647
  });
@@ -784,12 +789,19 @@ export abstract class AgentRuntimeKernel<
784
789
  (key) => this.ensureRuntimeVersion(key),
785
790
  );
786
791
  const activeRuntime = this.requireRuntime();
792
+ // A fresh provider Session per Submission keeps one failing upstream route from pinning later turns.
793
+ const recovery = new ModelRecovery(submissionId);
787
794
  const harnessInput = this.pi.harnessInputs(
788
795
  activeRuntime.pi,
789
796
  submission.assemblyDescriptor,
790
797
  {
791
- modelSessionId: submissionId,
792
- onGeneration: (event) => this.telemetry.generation(submissionId, event),
798
+ modelSessionId: () => recovery.sessionId(),
799
+ assertRequestAllowed: () => {
800
+ const current = this.db.submissions.find(submissionId);
801
+ if (current?.abortReason || current?.unattendedFailure) throw new Error(current.abortReason ?? current.unattendedFailure!);
802
+ },
803
+ onGeneration: (event) => this.telemetry.generation(submissionId, event),
804
+ onCompactionGeneration: (event) => this.runtimeTurnEvents.compactionGeneration(submissionId, event),
793
805
  },
794
806
  );
795
807
  const prepareToolInput = (toolName: string, input: unknown): unknown => {
@@ -848,9 +860,8 @@ export abstract class AgentRuntimeKernel<
848
860
  unattendedFailure: () => this.db.submissions.find(submissionId)?.unattendedFailure,
849
861
  });
850
862
  runtime = await openSessionHarness({
851
- session: openCloudflareSession(this.ctx.storage),
863
+ storage: openCloudflareSession(this.ctx.storage),
852
864
  models: harnessInput.models,
853
- modelSessionId: submissionId,
854
865
  model: harnessInput.model,
855
866
  thinkingLevel: harnessInput.thinkingLevel,
856
867
  systemPrompt: harnessInput.systemPrompt,
@@ -867,35 +878,20 @@ export abstract class AgentRuntimeKernel<
867
878
  streamOptions: { maxRetries: 0 },
868
879
  compaction: harnessInput.compaction,
869
880
  toolExecution: "parallel",
881
+ recovery,
882
+ beforeTool: (call, _api, context) => gate.beforeTool({ toolName: call.name, toolCallId: call.id, args: call.arguments }, context),
883
+ afterResponse: (message) => {
884
+ for (const part of message.content) if (part.type === "toolCall") part.arguments = prepareToolInput(part.name, part.arguments) as JsonObject;
885
+ },
870
886
  }, BACKGROUND_CONTEXT);
871
887
  this.activeHarness = runtime;
872
- let stream: ReturnType<typeof bindHarnessStream> | undefined;
888
+ let stream: Awaited<ReturnType<typeof bindHarnessStream>> | undefined;
873
889
  let deactivate = () => {};
890
+ let released = false;
874
891
  try {
875
- runtime.harness.hooks.on("after_response", ({ message }) => {
876
- for (const part of message.content) {
877
- if (part.type === "toolCall") {
878
- part.arguments = prepareToolInput(
879
- part.name,
880
- part.arguments,
881
- ) as JsonObject;
882
- }
883
- }
884
- return { message };
885
- });
886
- runtime.harness.hooks.on("before_tool", gate.beforeTool);
887
- runtime.harness.hooks.on("before_drive", async (event, context) => {
888
- const current = this.db.submissions.find(submissionId);
889
- if (current?.abortReason || current?.unattendedFailure) {
890
- await runtime.requestAbort(event.runId, context);
891
- }
892
- });
893
892
 
894
893
  if ((submission.abortReason || submission.unattendedFailure) && runtime.openOperation) {
895
- await runtime.requestAbort(
896
- runtime.openOperation.operationId,
897
- BACKGROUND_CONTEXT,
898
- );
894
+ await runtime.abort(BACKGROUND_CONTEXT);
899
895
  }
900
896
 
901
897
  await this.associateHarnessEntries(runtime, submissionId);
@@ -906,25 +902,6 @@ export abstract class AgentRuntimeKernel<
906
902
  (id) => this.transcript.regenerationTarget(id),
907
903
  BACKGROUND_CONTEXT,
908
904
  );
909
- if (preparation.kind === "settled") {
910
- const record = preparation.outcome;
911
- const unattendedFailure = this.db.submissions.find(submissionId)
912
- ?.unattendedFailure;
913
- const terminal = await this.submissions.finish(
914
- submission,
915
- unattendedFailure
916
- ? "failed"
917
- : record.status === "completed" || record.status === "declined"
918
- ? "succeeded" : record.status === "aborted" ? "aborted" : "failed",
919
- unattendedFailure ?? record.error?.message,
920
- );
921
- this.broadcast(json({
922
- type: MessageType.CF_AGENT_CHAT_MESSAGES,
923
- messages: await this.getMessages(),
924
- }));
925
- return terminal;
926
- }
927
-
928
905
  if (submission.status === "pending") {
929
906
  this.db.submissions.transition(submissionId, "running", ["pending"]);
930
907
  this.telemetry.capture("turnStarted", {
@@ -938,71 +915,58 @@ export abstract class AgentRuntimeKernel<
938
915
  steers: this.db.steers,
939
916
  messageUi: this.db.messageUi,
940
917
  transaction: (run) => this.db.transaction(run),
941
- enqueue: async (message) => {
942
- const queued = await runtime.lane.steer(message, undefined, BACKGROUND_CONTEXT);
943
- if (!queued.ok) throw queued.error;
944
- return queued.value.entryId;
918
+ enqueue: async (steer) => {
919
+ const message = JSON.parse(steer.canonicalJson) as PiCanonicalUserInput;
920
+ const queued = await runtime.conversation.submit({ type: "input", content: message.content, requestId: `steer:${steer.messageId}`, whenBusy: "steer" }, BACKGROUND_CONTEXT);
921
+ return queued.status(BACKGROUND_CONTEXT);
945
922
  },
923
+ find: (messageId) => runtime.submissionFor(`steer:${messageId}`, BACKGROUND_CONTEXT),
946
924
  });
947
925
  const uiMessageBody = submission.queuedUiMessageJson;
948
926
  let initialUserSidecarPersisted = (await this.transcript.storedMessages())
949
927
  .some((entry) =>
950
928
  entry.submissionId === submissionId && entry.message.role === "user"
951
929
  );
952
- runtime.harness.events.on("entry_added", (event) => {
953
- const entry = event.entry;
930
+ const unsubscribeEntries = runtime.harness.subscribeCommits((publication) => {
954
931
  this.db.transaction(() => {
955
- this.db.submissions.linkSessionEntry(entry.id, submissionId);
956
- if (entry.type === "message" && entry.message.role === "user") {
957
- if (!steerDelivery.settle(entry.id) && !initialUserSidecarPersisted && uiMessageBody) {
958
- this.db.messageUi.upsert(entry.id, uiMessageBody);
932
+ for (const change of publication.changes) {
933
+ if (change.type === "submission") steerDelivery.settleRecord(change.value);
934
+ if (change.type !== "entry" || change.value.conversationId !== runtime.conversation.id) continue;
935
+ const entry = change.value;
936
+ this.db.submissions.linkSessionEntry(String(entry.id), submissionId);
937
+ if (entry.kind === "pi.user" && !initialUserSidecarPersisted && uiMessageBody) {
938
+ this.db.messageUi.upsert(String(entry.id), uiMessageBody);
959
939
  initialUserSidecarPersisted = true;
960
940
  }
961
941
  }
962
942
  });
963
-
964
943
  });
965
- const pendingWatch = await runtime.lane.watch(BACKGROUND_CONTEXT);
966
- try {
967
- await steerDelivery.reconcile(submissionId, pendingWatch.snapshot);
968
- } finally {
969
- pendingWatch.unsubscribe();
970
- }
971
-
972
- this.runtimeTurnEvents.observe(runtime.harness, submissionId, harnessInput.model);
973
- stream = bindHarnessStream(runtime.harness.events, {
944
+ await this.runtimeTurnEvents.observe(runtime, submissionId, harnessInput.model);
945
+ stream = await bindHarnessStream(runtime, {
974
946
  messageId: submission.assistantMessageId,
975
947
  userMessageId: submission.userMessageId ?? undefined,
976
948
  startedAt: submission.createdAt,
977
949
  turnId: submissionId,
978
950
  prepareToolInput,
979
951
  publish: async (chunks) => {
980
- for (const chunk of chunks) {
981
- this.conversation.broadcastResponse({
982
- id: submission.requestId,
983
- body: json(chunk),
984
- done: false,
985
- continuation,
986
- });
987
- }
952
+ for (const chunk of chunks) this.conversation.broadcastResponse({ id: submission.requestId, body: json(chunk), done: false, continuation });
988
953
  },
989
954
  });
955
+ deactivate = unsubscribeEntries;
956
+
990
957
  publishApprovalRequest = stream.publishApprovalRequest;
991
- this.conversation.activate(runtime.lane, submission);
958
+ this.conversation.activate(runtime, submission);
992
959
 
993
960
  const control = {
994
961
  abort: async () => {
995
962
  if (runtime.openOperation) {
996
- await runtime.requestAbort(
997
- runtime.openOperation.operationId,
998
- BACKGROUND_CONTEXT,
999
- );
963
+ await runtime.abort(BACKGROUND_CONTEXT);
1000
964
  }
1001
965
  },
1002
966
  interrupt: () => void runtime.close(BACKGROUND_CONTEXT),
1003
967
  steer: async (_message: PiCanonicalUserInput, messageId: string) => {
1004
968
  const steer = this.db.steers.findByMessageId(messageId);
1005
- if (steer) await steerDelivery.queue(steer);
969
+ if (steer && runtime.openOperation) await steerDelivery.queue(steer);
1006
970
  },
1007
971
  };
1008
972
  const turn: ActiveTurn = {
@@ -1013,32 +977,20 @@ export abstract class AgentRuntimeKernel<
1013
977
  continuation,
1014
978
  agent: control,
1015
979
  };
1016
- deactivate = this.submissions.activate(submission, turn);
1017
- const outcome = await runtime.run(preparation.request, BACKGROUND_CONTEXT, {
1018
- pollDeferred: !preparation.request,
1019
- });
980
+ const deactivateTurn = this.submissions.activate(submission, turn);
981
+ const unsubscribeEntriesAndTurn = deactivate;
982
+ deactivate = () => { unsubscribeEntriesAndTurn(); deactivateTurn(); };
983
+ await runtime.admit(preparation, BACKGROUND_CONTEXT);
984
+ await steerDelivery.reconcile(submissionId);
985
+ const outcome = await runtime.run(undefined, BACKGROUND_CONTEXT);
1020
986
  this.db.submissions.clearQueuedPayload(submissionId);
1021
987
  if (outcome.kind === "released") {
1022
- if (
1023
- outcome.reason === "slice" ||
1024
- outcome.reason === "budget" ||
1025
- outcome.reason === "tool-failure"
1026
- ) {
988
+ if (outcome.reason === "slice") {
1027
989
  await this.schedule(0, "_piHarnessContinuation", { submissionId }, {
1028
990
  idempotent: true,
1029
991
  });
1030
992
  }
1031
- return this.db.submissions.find(submissionId)!;
1032
- }
1033
- if (outcome.kind === "waiting") {
1034
- await this.schedule(
1035
- outcome.reason === "retry"
1036
- ? Math.max(0, Math.ceil((outcome.notBefore - Date.now()) / 1000))
1037
- : Math.max(0, Math.ceil((outcome.deferred.pollAfterMs ?? 1_000) / 1000)),
1038
- "_piHarnessContinuation",
1039
- { submissionId },
1040
- { idempotent: true },
1041
- );
993
+ released = true;
1042
994
  return this.db.submissions.find(submissionId)!;
1043
995
  }
1044
996
  const record = outcome.outcome;
@@ -1072,13 +1024,21 @@ export abstract class AgentRuntimeKernel<
1072
1024
  }));
1073
1025
  throw error;
1074
1026
  } finally {
1075
- await this.conversation.release(runtime.lane).catch((error) => {
1027
+ await this.conversation.release(runtime).catch((error) => {
1076
1028
  console.warn("[runtime-stream:degraded]", errorText(error));
1077
1029
  });
1078
- stream?.unsubscribe();
1030
+ await stream?.unsubscribe();
1079
1031
  deactivate();
1080
1032
  if (this.activeHarness === runtime) this.activeHarness = undefined;
1081
1033
  await runtime.close(BACKGROUND_CONTEXT).catch(() => undefined);
1034
+ if (released) {
1035
+ // Pi closes the Harness on release and drops undelivered stream frames; the
1036
+ // canonical transcript gives a paused question or approval its complete input.
1037
+ this.broadcast(json({
1038
+ type: MessageType.CF_AGENT_CHAT_MESSAGES,
1039
+ messages: await this.getMessages(),
1040
+ }));
1041
+ }
1082
1042
  }
1083
1043
  }
1084
1044
 
@@ -1209,10 +1169,8 @@ export abstract class AgentRuntimeKernel<
1209
1169
 
1210
1170
  if (event.type === "clear") {
1211
1171
  if (this.submissions.isBusy()) return;
1212
- this.db.transaction(() => {
1213
- this.transcript.clear();
1214
- this.db.clearAll();
1215
- });
1172
+ await this.transcript.clear();
1173
+ this.db.transaction(() => this.db.clearAll());
1216
1174
  await this.broadcastApprovals();
1217
1175
  this.broadcast(json({ type: MessageType.CF_AGENT_CHAT_CLEAR }));
1218
1176
  return;
@@ -1354,7 +1312,7 @@ export abstract class AgentRuntimeKernel<
1354
1312
  await this.broadcastApprovals();
1355
1313
  return;
1356
1314
  }
1357
- this.transcript.append(crypto.randomUUID(), message);
1315
+ await this.transcript.append(crypto.randomUUID(), message);
1358
1316
  }
1359
1317
 
1360
1318
  async _hostGetSessionInfo(): Promise<{ messageCount: number }> {
@@ -1377,7 +1335,7 @@ export abstract class AgentRuntimeKernel<
1377
1335
  async addCanonicalSnapshot(
1378
1336
  snapshot: PiCanonicalTranscriptSnapshot,
1379
1337
  ): Promise<void> {
1380
- this.transcript.importSnapshot(snapshot);
1338
+ await this.transcript.importSnapshot(snapshot);
1381
1339
  }
1382
1340
 
1383
1341
  /**
@@ -7,7 +7,7 @@ import {
7
7
  type FiberRecoveryContext,
8
8
  type FiberRecoveryResult,
9
9
  } from "agents";
10
- import type { HarnessEvent } from "@earendil-works/pi-agent-core";
10
+ import type { AgentEvent as HarnessEvent } from "@earendil-works/pi-durable";
11
11
  import type { UIMessageChunk } from "ai";
12
12
  import {
13
13
  AGENT_TYPES,
@@ -96,25 +96,27 @@ function stringifyJson(value: unknown): string {
96
96
  function progressFor(
97
97
  event: HarnessEvent,
98
98
  ): Omit<AgentToolProgressSnapshot, "at"> | null {
99
- if (event.type === "run_start" || event.type === "run_resume") {
99
+ if (event.type === "run_start") {
100
100
  return { phase: "running", message: "SubAgent started" };
101
101
  }
102
- if (event.type === "tool_start") {
102
+ if (event.type === "tool_execution_start") {
103
103
  return {
104
104
  phase: "tool",
105
105
  message: `Running ${event.toolName}`,
106
106
  };
107
107
  }
108
- if (event.type === "tool_update") {
108
+ if (event.type === "tool_execution_update") {
109
109
  return {
110
110
  phase: "tool",
111
111
  message: `${event.toolName} is still running`,
112
112
  };
113
113
  }
114
- if (event.type === "tool_end") {
114
+ if (event.type === "tool_execution_end") {
115
+ const result = event.entry?.model?.[0];
116
+ const isError = result?.role !== "toolResult" || result.isError;
115
117
  return {
116
- phase: event.isError ? "tool-error" : "tool",
117
- message: event.isError
118
+ phase: isError ? "tool-error" : "tool",
119
+ message: isError
118
120
  ? `${event.toolName} failed`
119
121
  : `${event.toolName} completed`,
120
122
  };
@@ -414,14 +416,24 @@ export abstract class CloudflareSubAgent<
414
416
  tools,
415
417
  signal,
416
418
  onEvent: (event) => {
417
- if (event.type === "message_end" && event.message.role === "assistant") {
418
- addSubagentUsage(usage, event.message.usage);
419
+ if (event.type === "message_end" && event.entry.model?.[0]?.role === "assistant") {
420
+ addSubagentUsage(usage, event.entry.model[0].usage);
419
421
  this.persistUsage(runId, usage);
420
422
  }
421
423
  publish(projection!.event(event));
422
424
  const progress = progressFor(event);
423
425
  if (progress) this.persistProgress(runId, progress);
424
426
  },
427
+ // Live frames can stop short of the last response; the canonical entries are the billed total.
428
+ onEntries: (entries) => {
429
+ const canonical = emptySubagentUsage();
430
+ for (const entry of entries) {
431
+ const message = entry.model?.[0];
432
+ if (message?.role === "assistant") addSubagentUsage(canonical, message.usage);
433
+ }
434
+ Object.assign(usage, canonical);
435
+ this.persistUsage(runId, usage);
436
+ },
425
437
  });
426
438
  publish(projection.finish({ status: "completed", endedAt: Date.now() }));
427
439
  this.completeRun(runId, output, usage);
@@ -1,14 +1,10 @@
1
- import {
2
- type AgentHarnessTool,
3
- type HarnessEvent,
4
- type Session,
5
- type StreamFn,
6
- } from "@earendil-works/pi-agent-core";
1
+ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
+ import type { AgentEvent as HarnessEvent, AgentEventStream, EntryRecord, Storage as Session, ToolRegistration } from "@earendil-works/pi-durable";
7
3
  import {
8
4
  awaitWithContext,
9
5
  BACKGROUND_CONTEXT,
10
6
  withAbortSignal,
11
- } from "@earendil-works/pi-agent-core/harness/context";
7
+ } from "@earendil-works/chord/context";
12
8
  import type { Api, AssistantMessage, Model, Models } from "@earendil-works/pi-ai";
13
9
  import { z } from "zod";
14
10
  import { assembleSubagentPrompt } from "../../../lib/prompt";
@@ -39,11 +35,24 @@ export interface CloudflareSubAgentRun {
39
35
  openSession(): Session | Promise<Session>;
40
36
  operationId?: string;
41
37
  /** Tools compiled by the package's single Harness adapter. */
42
- tools?: AgentHarnessTool<object | undefined>[];
38
+ tools?: ToolRegistration<any, any>[];
43
39
  signal?: AbortSignal;
44
40
  timeoutMs?: number;
45
- /** Native operation events for projections that must survive model retries. */
41
+ /** Native operation events for live projections; they may stop short of the final frame. */
46
42
  onEvent?: (event: HarnessEvent, signal: AbortSignal) => void | Promise<void>;
43
+ /** This operation's canonical entries after each settle or release, the source of its final usage. */
44
+ onEntries?: (entries: readonly EntryRecord[]) => void | Promise<void>;
45
+ }
46
+
47
+ /** The first failed Tool result among an operation's canonical entries. */
48
+ function toolFailureOf(entries: readonly EntryRecord[]): { name: string; error: string } | undefined {
49
+ for (const entry of entries) {
50
+ const result = entry.model?.[0];
51
+ if (result?.role !== "toolResult" || !result.isError) continue;
52
+ const diagnostics = (entry.data as { diagnostics?: Array<{ severity: string; message: string }> } | undefined)?.diagnostics;
53
+ return { name: result.toolName, error: diagnostics?.find((item) => item.severity === "error")?.message ?? toolErrorText(result) };
54
+ }
55
+ return undefined;
47
56
  }
48
57
 
49
58
  function textOf(message: AssistantMessage): string {
@@ -131,9 +140,15 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
131
140
  streamOptions: { maxRetries: 0 },
132
141
  toolExecution: "parallel",
133
142
  compaction: { enabled: false, reserveTokens: 1, keepRecentTokens: 1 },
134
- } satisfies Omit<SessionHarnessOptions<object | undefined>, "session">;
135
- let runtime: SessionHarness<object | undefined> | undefined;
143
+ } satisfies Omit<SessionHarnessOptions, "storage">;
144
+ let runtime: SessionHarness | undefined;
145
+ let events: AgentEventStream | undefined;
136
146
  let toolFailure: { name: string; error: string } | undefined;
147
+ const operationEntries = async (active: SessionHarness): Promise<EntryRecord[]> => {
148
+ const submission = await active.submissionFor(operationId, BACKGROUND_CONTEXT);
149
+ const entries = await active.entries(BACKGROUND_CONTEXT);
150
+ return submission?.entry === undefined ? [] : entries.filter((entry) => entry.id >= submission.entry!);
151
+ };
137
152
 
138
153
  let timedOut = false;
139
154
  const timeout = setTimeout(() => {
@@ -141,61 +156,43 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
141
156
  controller.abort(new Error("SubAgent run timed out"));
142
157
  }, run.timeoutMs ?? DEFAULT_TIMEOUT_MS);
143
158
  try {
144
- let settled: Awaited<ReturnType<SessionHarness<object | undefined>["run"]>>;
159
+ let settled: Awaited<ReturnType<SessionHarness["run"]>>;
145
160
  while (true) {
146
161
  signal.throwIfAborted();
147
162
  if (!runtime) {
148
163
  runtime = await openSessionHarness({
149
164
  ...options,
150
- session: await run.openSession(),
165
+ storage: await run.openSession(),
151
166
  }, context);
152
167
  if (runtime.openOperation && runtime.openOperation.operationId !== operationId) {
153
168
  throw new Error("SubAgent Session has an unrelated open operation");
154
169
  }
155
- const harness = runtime.harness;
156
- const onEvent = run.onEvent;
157
- if (onEvent) {
158
- for (const type of ["run_start", "run_resume", "run_end", "turn_start", "message_start", "message_update", "message_end", "tool_start", "tool_update", "tool_end"] as const) {
159
- harness.events.on(type, (event) => onEvent(event, signal));
160
- }
161
- }
162
- harness.events.on("tool_end", (event) => {
163
- if (event.isError) {
164
- toolFailure ??= { name: event.toolName, error: toolErrorText(event.result) };
165
- }
170
+ events = await runtime.watch(context);
171
+ events.start(async (batch) => {
172
+ for (const event of batch) await run.onEvent?.(event, signal);
166
173
  });
167
174
  }
168
175
  const result = await runtime.run(
169
176
  !runtime.openOperation
170
177
  ? {
171
- kind: "prompt",
172
178
  operationId,
173
179
  prompt: taskPrompt(input.data, structuredStream ? undefined : outputSchema),
174
180
  }
175
181
  : undefined,
176
182
  context,
177
- { waitForRetry: true, pollDeferred: true },
178
183
  );
184
+ await events?.stop();
185
+ events = undefined;
179
186
  if (result.kind === "released") {
180
187
  await runtime.close(BACKGROUND_CONTEXT);
181
188
  runtime = undefined;
182
189
  continue;
183
190
  }
184
- if (result.kind === "waiting") {
185
- const delay = result.reason === "deferred"
186
- ? result.deferred.pollAfterMs ?? 1_000
187
- : Math.max(0, result.notBefore - Date.now());
188
- let timer: ReturnType<typeof setTimeout> | undefined;
189
- try {
190
- await awaitWithContext(new Promise<void>((resolve) => {
191
- timer = setTimeout(resolve, delay);
192
- }), context);
193
- } finally {
194
- clearTimeout(timer);
195
- }
196
- continue;
197
- }
198
191
  settled = result;
192
+ // Pi's stop may drop the last frames; failures and usage come from the canonical record.
193
+ const owned = await operationEntries(runtime);
194
+ toolFailure = toolFailureOf(owned);
195
+ await run.onEntries?.(owned);
199
196
  break;
200
197
  }
201
198
  if (timedOut) throw new Error("SubAgent run timed out");
@@ -212,14 +209,9 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
212
209
  if (toolFailure) {
213
210
  throw new Error(`SubAgent tool "${toolFailure.name}" failed: ${toolFailure.error}`);
214
211
  }
215
- const assistant = (await runtime.lane.findEntries(
216
- { type: "message", order: "newestFirst" },
217
- context,
218
- )).find((entry) => entry.type === "message" && entry.message.role === "assistant");
219
- if (!assistant || assistant.type !== "message" || assistant.message.role !== "assistant") {
220
- throw new Error("SubAgent produced no structured output");
221
- }
222
- const text = textOf(assistant.message);
212
+ const assistant = [...await runtime.entries(context)].reverse().flatMap((entry) => entry.model ?? []).find((message) => message.role === "assistant");
213
+ if (assistant?.role !== "assistant") throw new Error("SubAgent produced no structured output");
214
+ const text = textOf(assistant);
223
215
  if (!text) throw new Error("SubAgent produced no structured output");
224
216
  let output: unknown;
225
217
  try {
@@ -239,9 +231,10 @@ export async function runCloudflareSubAgent(run: CloudflareSubAgentRun): Promise
239
231
  } finally {
240
232
  clearTimeout(timeout);
241
233
  if (signal.aborted && runtime?.openOperation) {
242
- await runtime.requestAbort(operationId, BACKGROUND_CONTEXT).catch(() => undefined);
234
+ await runtime.abort(BACKGROUND_CONTEXT).catch(() => undefined);
243
235
  await runtime.run(undefined, BACKGROUND_CONTEXT).catch(() => undefined);
244
236
  }
237
+ await events?.stop();
245
238
  await runtime?.close(BACKGROUND_CONTEXT).catch(() => undefined);
246
239
  }
247
240
  }
@@ -1,3 +1,4 @@
1
+ import type { ToolRegistration } from "@earendil-works/pi-durable";
1
2
  import {
2
3
  DynamicWorkerExecutor,
3
4
  resolveProvider,
@@ -9,7 +10,6 @@ import {
9
10
  } from "@cloudflare/shell";
10
11
  import { stateToolsFromBackend } from "@cloudflare/shell/workers";
11
12
  import type {
12
- AgentHarnessTool,
13
13
  AgentTool,
14
14
  AgentToolResult,
15
15
  } from "@earendil-works/pi-agent-core";
@@ -221,7 +221,7 @@ export function createCloudflareSubAgentTools(options: {
221
221
  loader: WorkerLoader;
222
222
  outbound: Fetcher;
223
223
  settle: NonNullable<CompileHarnessToolsOptions["onSettlement"]>;
224
- }): AgentHarnessTool<object | undefined>[] {
224
+ }): ToolRegistration<any, any>[] {
225
225
  const workspace = workspacePiToolCandidates(options.workspace).filter(
226
226
  (candidate) =>
227
227
  options.profile === "capable" ||