@springbrand/agent-runtime 0.3.0-alpha.2 → 0.3.0-alpha.21

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.
Files changed (131) hide show
  1. package/package.json +11 -9
  2. package/src/adapter/cloudflare/agent.ts +233 -179
  3. package/src/adapter/cloudflare/define-agent.ts +252 -104
  4. package/src/adapter/cloudflare/index.ts +18 -0
  5. package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
  6. package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
  7. package/src/adapter/cloudflare/schema.ts +21 -2
  8. package/src/adapter/cloudflare/subagent/definition.ts +25 -13
  9. package/src/adapter/cloudflare/subagent/runner.ts +43 -50
  10. package/src/adapter/cloudflare/subagent/tools.ts +4 -4
  11. package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
  12. package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
  13. package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
  14. package/src/config/activation.ts +13 -15
  15. package/src/config/agent-context.ts +1 -1
  16. package/src/config/assembler.ts +48 -31
  17. package/src/config/assembly-snapshot.ts +1 -1
  18. package/src/config/definition.ts +1 -1
  19. package/src/config/harness-runtime.ts +77 -49
  20. package/src/config/index.ts +1 -1
  21. package/src/config/model-guidance.ts +20 -0
  22. package/src/config/ports.ts +57 -7
  23. package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
  24. package/src/config/public-contracts.ts +2 -2
  25. package/src/config/runtime-config.ts +2 -1
  26. package/src/context/assembly.ts +1 -1
  27. package/src/context/extension/assembly.ts +2 -2
  28. package/src/context/memory/budget/gate.ts +19 -3
  29. package/src/conversation/harness-resume.ts +89 -238
  30. package/src/conversation/harness-stream.ts +87 -71
  31. package/src/conversation/history.ts +154 -152
  32. package/src/conversation/message/contract.ts +28 -4
  33. package/src/conversation/message/conversion.ts +46 -5
  34. package/src/conversation/message/projection.ts +61 -21
  35. package/src/conversation/session-entry.ts +29 -0
  36. package/src/conversation/state.ts +23 -2
  37. package/src/harness/governance.ts +206 -0
  38. package/src/harness/index.ts +3 -0
  39. package/src/harness/session-harness.ts +269 -445
  40. package/src/harness/submission-operation.ts +15 -40
  41. package/src/index.ts +18 -8
  42. package/src/lib/artifacts.ts +18 -0
  43. package/src/lib/execution-level.ts +13 -0
  44. package/src/lib/prompt.ts +9 -10
  45. package/src/lib/token-estimate.ts +8 -0
  46. package/src/model-protocol/openrouter.ts +41 -0
  47. package/src/models/index.ts +200 -35
  48. package/src/models/openrouter/messages.ts +13 -8
  49. package/src/models/openrouter/openai.ts +200 -0
  50. package/src/models/openrouter/protocol-response.ts +85 -0
  51. package/src/models/provider-streams.ts +45 -0
  52. package/src/session/cloudflare/migration.ts +382 -864
  53. package/src/session/cloudflare/storage.ts +13 -536
  54. package/src/session/index.ts +22 -35
  55. package/src/skills/springbrand-worker-website/index.ts +1 -1
  56. package/src/submission/admission.ts +20 -30
  57. package/src/submission/contracts.ts +42 -2
  58. package/src/submission/delivery.ts +1 -1
  59. package/src/submission/finalization.ts +36 -15
  60. package/src/submission/lifecycle.ts +28 -15
  61. package/src/submission/repository.ts +97 -49
  62. package/src/submission/steer-delivery.ts +32 -76
  63. package/src/submission/unattended.ts +40 -0
  64. package/src/telemetry/contract.ts +0 -1
  65. package/src/telemetry/coordinator.ts +1 -1
  66. package/src/telemetry/runtime-telemetry.ts +0 -4
  67. package/src/telemetry/subagent-lifecycle.ts +1 -1
  68. package/src/telemetry/turn-events.ts +35 -25
  69. package/src/tools/README.md +44 -0
  70. package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
  71. package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
  72. package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
  73. package/src/tools/assistant/ask-user.ts +319 -0
  74. package/src/tools/assistant/candidate.ts +15 -0
  75. package/src/tools/assistant/followups.ts +34 -0
  76. package/src/tools/assistant/index.ts +19 -0
  77. package/src/tools/assistant/memory.ts +73 -0
  78. package/src/tools/assistant/plan.ts +98 -0
  79. package/src/tools/{time.ts → assistant/time.ts} +1 -1
  80. package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
  81. package/src/tools/{core.ts → codemode/index.ts} +36 -92
  82. package/src/tools/codemode/workspace-connector.ts +198 -0
  83. package/src/tools/extension/index.ts +42 -0
  84. package/src/tools/index.ts +29 -26
  85. package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
  86. package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
  87. package/src/tools/runtime/activation.ts +40 -0
  88. package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
  89. package/src/tools/runtime/compiler.ts +246 -0
  90. package/src/tools/runtime/contracts.ts +140 -0
  91. package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
  92. package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
  93. package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
  94. package/src/tools/runtime/result.ts +22 -0
  95. package/src/tools/runtime/unattended-error.ts +4 -0
  96. package/src/tools/sandbox/index.ts +228 -0
  97. package/src/tools/schedule/index.ts +301 -0
  98. package/src/tools/skill/contracts.ts +20 -0
  99. package/src/tools/skill/index.ts +99 -0
  100. package/src/tools/skill/resources.ts +322 -0
  101. package/src/tools/skill/scripts.ts +168 -0
  102. package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
  103. package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
  104. package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
  105. package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
  106. package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
  107. package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
  108. package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
  109. package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
  110. package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
  111. package/src/tools/web/index.ts +2 -0
  112. package/src/tools/web/search/api.ts +1322 -0
  113. package/src/tools/web/search/index.ts +2 -0
  114. package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
  115. package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
  116. package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
  117. package/src/tools/base.ts +0 -534
  118. package/src/tools/compiler.ts +0 -327
  119. package/src/tools/schedule.ts +0 -261
  120. package/src/tools/skill.ts +0 -565
  121. package/src/tools/web-search/api.ts +0 -1318
  122. package/src/tools/web-search/index.ts +0 -2
  123. package/src/tools/workspace-state-connector.ts +0 -146
  124. package/src/tools/{approval → runtime/approval}/index.ts +0 -0
  125. package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
  126. package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
  127. package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
  128. package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
  129. package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
  130. package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
  131. /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
@@ -1,14 +1,14 @@
1
- import { HarnessClosed, type AgentLane, type LaneSnapshot, type WatchHandle } from "@earendil-works/pi-agent-core";
2
- import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core/harness/context";
3
- import type { OperationResultRecord } from "@earendil-works/pi-agent-core/harness/session";
1
+ import type { AgentEventStream } from "@earendil-works/pi-durable";
2
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
4
3
  import type { Connection } from "agents";
5
- import { MessageType } from "agents/chat";
4
+ import { MessageType, STREAM_RESUME_NONE_REASONS } from "agents/chat";
6
5
  import type { UIMessage, UIMessageChunk } from "ai";
6
+ import type { SessionHarness, SessionRunOutcome } from "../harness/session-harness";
7
7
  import type { ConversationHistory } from "./history";
8
8
  import { HarnessStreamProjection } from "./harness-stream";
9
9
 
10
10
  interface ActiveConversation {
11
- lane: AgentLane;
11
+ runtime: SessionHarness;
12
12
  submissionId: string;
13
13
  requestId: string;
14
14
  assistantMessageId: string;
@@ -18,271 +18,122 @@ interface ActiveConversation {
18
18
  interface ResumeConnection {
19
19
  connection: Connection;
20
20
  source: ActiveConversation;
21
- ready: Promise<void>;
22
- watch?: WatchHandle<LaneSnapshot>;
23
- projection?: HarnessStreamProjection;
24
- acknowledging: boolean;
25
- replay?: Promise<void>;
26
- terminal?: OperationResultRecord;
27
- released?: boolean;
28
- finished?: boolean;
21
+ watch: AgentEventStream;
22
+ projection: HarnessStreamProjection;
23
+ delivery: Promise<void>;
24
+ started: boolean;
29
25
  }
30
26
 
31
-
32
- /** CF's resume handshake, with its replay prefix and subsequent events sourced only from Pi. */
27
+ /** Pi supplies the atomic snapshot and deltas; this boundary owns the CF transport handshake. */
33
28
  export class HarnessConversationBridge {
34
29
  private active?: ActiveConversation;
35
30
  private readonly connections = new Map<string, ResumeConnection>();
36
-
37
31
  constructor(private readonly host: {
38
32
  history: Pick<ConversationHistory, "browserMessages" | "browserSnapshot">;
39
33
  send: (connection: Connection, body: string) => void | Promise<void>;
40
34
  broadcast: (body: string, excludedConnectionIds: string[]) => void;
41
35
  }) {}
42
-
43
- activate(lane: AgentLane, submission: Omit<ActiveConversation, "lane">): void {
44
- this.active = { lane, ...submission };
36
+ activate(runtime: SessionHarness, submission: Omit<ActiveConversation, "runtime">): void { this.active = { runtime, ...submission }; }
37
+ private replayMessage(messages: UIMessage[], submissionId: string) {
38
+ return [...messages].reverse().find((message) => message.role === "assistant" && (message.metadata as { turnId?: string } | undefined)?.turnId === submissionId);
45
39
  }
46
-
47
- private replayMessage(messages: UIMessage[], submissionId: string): UIMessage | undefined {
48
- return messages.filter((message) => message.role === "assistant" &&
49
- (message.metadata as { turnId?: string } | undefined)?.turnId === submissionId).at(-1);
50
- }
51
-
52
- private historyBeforeReplay(messages: UIMessage[], submissionId: string): UIMessage[] {
53
- const target = this.replayMessage(messages, submissionId);
54
- return target ? messages.filter((message) => message.id !== target.id) : messages;
55
- }
56
-
57
- /** Reads never release lifecycle state; hydration leaves the active reply to replay. */
58
40
  async messages(view: "full" | "hydration" = "full"): Promise<UIMessage[]> {
59
- for (;;) {
60
- const source = this.active;
61
- let watch: WatchHandle<LaneSnapshot> | undefined;
62
- if (source) {
63
- try { watch = await source.lane.watch(BACKGROUND_CONTEXT); }
64
- catch (error) {
65
- if (!(error instanceof HarnessClosed)) throw error;
66
- }
67
- }
68
- try {
69
- const messages = watch
70
- ? await this.host.history.browserSnapshot(watch.snapshot)
71
- : await this.host.history.browserMessages();
72
- if (this.active !== source) continue;
73
- return view === "hydration" && source && watch?.snapshot.operation?.id === source.submissionId
74
- ? this.historyBeforeReplay(messages, source.submissionId)
75
- : messages;
76
- } finally { watch?.unsubscribe(); }
77
- }
78
- }
79
-
80
- private async send(connection: Connection, frame: unknown): Promise<void> {
81
- const session = this.connections.get(connection.id);
82
- try { await this.host.send(connection, JSON.stringify(frame)); }
83
- catch { if (session) this.discard(session); }
84
- }
85
-
86
- private async sendHistory(connection: Connection, messages: UIMessage[]): Promise<void> {
87
- await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages });
88
- }
89
-
90
- private async sendIdleHistory(connection: Connection, probeId?: string): Promise<void> {
91
41
  const source = this.active;
92
- const session = this.connections.get(connection.id);
93
- const messages = await this.host.history.browserMessages();
94
- if (this.connections.get(connection.id) !== session) return;
95
- if (this.active !== source) return this.resume(connection, probeId);
96
- await this.sendHistory(connection, messages);
97
- if (this.connections.get(connection.id) !== session) return;
98
- if (this.active !== source) return this.resume(connection, probeId);
99
- await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, ...(probeId ? { probeId } : {}) });
42
+ if (!source) return this.host.history.browserMessages();
43
+ const watch = await source.runtime.watch(BACKGROUND_CONTEXT);
44
+ try {
45
+ const messages = await this.host.history.browserSnapshot(watch.snapshot, source.submissionId);
46
+ const replay = this.replayMessage(messages, source.submissionId);
47
+ return view === "hydration" && watch.snapshot.run && replay ? messages.filter((message) => message.id !== replay.id) : messages;
48
+ } finally { await watch.stop(); }
100
49
  }
101
-
102
- private async sendChunks(session: ResumeConnection, chunks: readonly UIMessageChunk[], replay = false): Promise<void> {
50
+ private send(connection: Connection, frame: unknown) { return this.host.send(connection, JSON.stringify(frame)); }
51
+ private async chunks(session: ResumeConnection, chunks: readonly UIMessageChunk[], replay = false) {
103
52
  for (const chunk of chunks) {
104
53
  if (this.connections.get(session.connection.id) !== session) return;
105
- await this.send(session.connection, {
106
- type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
107
- id: session.source.requestId,
108
- body: JSON.stringify(chunk), done: false,
109
- ...(replay ? { replay: true, continuation: false } : {}),
110
- });
54
+ await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: session.source.requestId, body: JSON.stringify(chunk), done: false, ...(replay ? { replay: true, continuation: false } : {}) });
111
55
  }
112
56
  }
113
-
114
- async resume(connection: Connection, probeId?: string): Promise<void> {
57
+ /**
58
+ * A socket just opened: offer the live stream if one exists, otherwise only
59
+ * sync messages. Like Cloudflare's AIChatAgent, an idle connect sends no
60
+ * STREAM_RESUME_NONE. The client answers its own resume probe from the
61
+ * reply that carries its probeId; an unsolicited, probe-less "none" arriving
62
+ * first consumes that pending probe without clearing the client's
63
+ * server-streaming state, which leaves a reconnected Chat stuck streaming
64
+ * after a Turn that ended while it was offline.
65
+ */
66
+ connect(connection: Connection): Promise<void> {
67
+ return this.resume(connection, undefined, false);
68
+ }
69
+ /** Answer a client's stream-resume request; `announceIdle` is false only for {@link connect}. */
70
+ async resume(connection: Connection, probeId?: string, announceIdle = true): Promise<void> {
71
+ this.disconnect(connection);
115
72
  const source = this.active;
116
- if (!source) {
117
- return this.sendIdleHistory(connection, probeId);
118
- }
119
- let session = this.connections.get(connection.id);
120
- if (!session || session.source !== source) {
121
- this.disconnect(connection);
122
- session = { connection, source, ready: Promise.resolve(), acknowledging: false };
123
- this.connections.set(connection.id, session);
124
- const current = session;
125
- current.ready = (async () => {
126
- const watch = await source.lane.watch(BACKGROUND_CONTEXT);
127
- if (this.connections.get(connection.id) !== current) { watch.unsubscribe(); return; }
128
- current.watch = watch;
129
- })();
73
+ if (!source) return this.idle(connection, probeId, announceIdle);
74
+ const watch = await source.runtime.watch(BACKGROUND_CONTEXT);
75
+ if (this.active !== source) { await watch.stop(); return this.resume(connection, probeId, announceIdle); }
76
+ if (!watch.snapshot.run) {
77
+ await watch.stop();
78
+ return this.idle(connection, probeId, announceIdle);
130
79
  }
131
- try { await session.ready; }
132
- catch (error) {
133
- if (this.connections.get(connection.id) !== session) return;
134
- this.discard(session);
135
- if (this.active !== source) return this.resume(connection, probeId);
136
- if (error instanceof HarnessClosed) {
137
- await this.sendIdleHistory(connection, probeId);
138
- }
139
- return;
140
- }
141
- if (this.connections.get(connection.id) !== session) return;
142
- if (this.active !== source) {
143
- this.discard(session);
144
- return this.resume(connection, probeId);
145
- }
146
- const snapshot = session.watch?.snapshot;
147
- if (!snapshot?.operation || snapshot.operation.id !== source.submissionId) {
148
- this.discard(session);
149
- return this.sendIdleHistory(connection, probeId);
150
- }
151
- if (!session.acknowledging) {
152
- const messages = await this.host.history.browserSnapshot(snapshot);
153
- if (this.connections.get(connection.id) !== session) return;
154
- if (this.active !== source) { this.discard(session); return this.resume(connection, probeId); }
155
- await this.sendHistory(connection, this.historyBeforeReplay(messages, source.submissionId));
156
- }
157
- if (this.connections.get(connection.id) !== session) return;
158
- if (this.active !== source) { this.discard(session); return this.resume(connection, probeId); }
159
- await this.send(connection, {
160
- type: MessageType.CF_AGENT_STREAM_RESUMING, id: source.requestId,
161
- ...(probeId ? { probeId } : {}),
162
- });
80
+ const messages = await this.host.history.browserSnapshot(watch.snapshot, source.submissionId);
81
+ const replay = this.replayMessage(messages, source.submissionId);
82
+ const session: ResumeConnection = { connection, source, watch, projection: new HarnessStreamProjection({ messageId: source.assistantMessageId, userMessageId: source.userMessageId, startedAt: source.createdAt, turnId: source.submissionId }), delivery: Promise.resolve(), started: false };
83
+ this.connections.set(connection.id, session);
84
+ await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: replay ? messages.filter((message) => message.id !== replay.id) : messages });
85
+ await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUMING, id: source.requestId, ...(probeId ? { probeId } : {}) });
86
+ }
87
+ /**
88
+ * No Turn is running. "None" goes first: while the client still observes the
89
+ * stream it lost, it keeps its own partial answer over any snapshot that has
90
+ * no more parts, so the settled answer must arrive after it stops observing.
91
+ */
92
+ private async idle(connection: Connection, probeId: string | undefined, announceIdle: boolean): Promise<void> {
93
+ if (announceIdle) await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
94
+ await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
163
95
  }
164
-
165
96
  async acknowledge(connection: Connection, requestId: string): Promise<void> {
166
97
  const session = this.connections.get(connection.id);
167
- if (!session || session.source.requestId !== requestId || session.acknowledging) return;
168
- if (this.active && this.active.submissionId !== session.source.submissionId) {
169
- // A queued Submission may already be live when an older offer is ACKed.
170
- // End the old transport, then atomically resume the current lane view.
171
- this.disconnect(connection);
172
- const current = this.resume(connection);
173
- await this.send(connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: requestId, body: "", done: true });
174
- await current;
175
- return;
176
- }
177
- session.acknowledging = true;
178
- session.replay = this.replay(session);
179
- try {
180
- await session.replay;
181
- if (session.terminal) await this.finish(session, session.terminal);
182
- if (session.released) this.discard(session);
183
- } catch { this.discard(session); }
184
- }
185
-
186
- private async replay(session: ResumeConnection): Promise<void> {
187
- const connection = session.connection;
188
- const requestId = session.source.requestId;
189
- await session.ready;
190
- if (this.connections.get(connection.id) !== session) return;
191
- if (!session.watch && this.active?.submissionId === session.source.submissionId) {
192
- session.source = this.active;
193
- session.released = false;
194
- const watch = await this.active.lane.watch(BACKGROUND_CONTEXT);
195
- if (this.connections.get(connection.id) !== session) { watch.unsubscribe(); return; }
196
- session.watch = watch;
197
- }
198
- const watch = session.watch;
199
- const messages = watch ? await this.host.history.browserSnapshot(watch.snapshot) : await this.host.history.browserMessages();
200
- if (this.connections.get(connection.id) !== session) return;
201
- const source = session.source;
202
- const message = this.replayMessage(messages, source.submissionId) ?? {
203
- id: source.assistantMessageId, role: "assistant" as const, parts: [],
204
- metadata: { turnId: source.submissionId, userMessageId: source.userMessageId,
205
- createdAt: source.createdAt, turnStatus: "running" },
206
- };
207
- const projection = new HarnessStreamProjection({
208
- messageId: message.id, startedAt: source.createdAt, turnId: source.submissionId,
209
- userMessageId: source.userMessageId,
210
- });
211
- session.projection = projection;
212
- const prefix = projection.restore(message, Boolean(watch?.snapshot.operation?.streamingMessage));
213
- await this.sendChunks(session, prefix, true);
214
- if (this.connections.get(connection.id) !== session) return;
215
- await this.send(connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
216
- id: requestId, body: "", done: false, replayComplete: true });
217
- if (this.connections.get(connection.id) !== session) return;
218
- watch?.start(async (event) => {
219
- if ("runId" in event && event.runId !== source.submissionId) return;
220
- await this.sendChunks(session, projection.event(event));
221
- });
98
+ if (!session || session.source.requestId !== requestId || session.started) return;
99
+ session.started = true;
100
+ const messages = await this.host.history.browserSnapshot(session.watch.snapshot, session.source.submissionId);
101
+ const replay = this.replayMessage(messages, session.source.submissionId);
102
+ if (replay) await this.chunks(session, session.projection.restore(replay, Boolean(session.watch.snapshot.generation?.message), session.watch.snapshot), true);
103
+ await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: session.source.requestId, body: "", done: false, replayComplete: true });
104
+ session.watch.start((events) => session.delivery = (async () => {
105
+ for (const event of events) {
106
+ if (event.type === "snapshot") {
107
+ await this.resume(session.connection);
108
+ return;
109
+ } else await this.chunks(session, session.projection.event(event));
110
+ }
111
+ })());
222
112
  }
223
-
224
- private async finish(session: ResumeConnection, record: OperationResultRecord): Promise<void> {
225
- if (session.finished || !session.projection) return;
226
- session.finished = true;
227
- await session.watch?.drain();
228
- await this.sendChunks(session, session.projection.finish(record));
229
- if (this.connections.get(session.connection.id) === session) {
230
- await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
231
- id: session.source.requestId, body: "", done: true });
113
+ async settle(outcome: SessionRunOutcome): Promise<void> {
114
+ for (const session of this.connections.values()) {
115
+ await session.watch.stop(); await session.delivery;
116
+ if (session.started) {
117
+ await this.chunks(session, session.projection.finish(outcome));
118
+ }
119
+ await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: session.source.requestId, body: "", done: true });
232
120
  }
121
+ if (this.active) this.broadcastResponse({ id: this.active.requestId, body: "", done: true });
233
122
  }
234
-
235
- /** Called only after the Host has persisted the Submission's terminal result. */
236
- async settle(record: OperationResultRecord): Promise<void> {
237
- await Promise.all([...this.connections.values()].filter((session) =>
238
- session.source.submissionId === record.operationId).map(async (session) => {
239
- session.terminal = record;
240
- try {
241
- if (session.replay) await session.replay;
242
- await this.finish(session, record);
243
- } catch { this.discard(session); }
244
- }));
245
- if (this.active?.submissionId === record.operationId) {
246
- this.broadcastResponse({ id: this.active.requestId, body: "", done: true });
123
+ async release(runtime: SessionHarness): Promise<void> {
124
+ if (this.active?.runtime === runtime) this.active = undefined;
125
+ for (const session of [...this.connections.values()]) {
126
+ if (session.source.runtime !== runtime) continue;
127
+ await session.watch.stop(); await session.delivery; this.connections.delete(session.connection.id);
247
128
  }
248
129
  }
249
-
250
- /** Called after a drive returns, outside Pi event listeners. */
251
- async release(lane: AgentLane): Promise<void> {
252
- if (this.active?.lane === lane) this.active = undefined;
253
- await Promise.all([...this.connections.values()].filter((session) => session.source.lane === lane)
254
- .map(async (session) => {
255
- try {
256
- await session.ready;
257
- if (session.replay) await session.replay;
258
- if (session.projection) {
259
- await session.watch?.drain();
260
- this.discard(session);
261
- } else {
262
- session.watch?.unsubscribe();
263
- session.watch = undefined;
264
- session.released = true;
265
- }
266
- } catch { this.discard(session); }
267
- }));
268
- }
269
-
270
130
  disconnect(connection: Pick<Connection, "id">): void {
271
131
  const session = this.connections.get(connection.id);
272
- if (session) this.discard(session);
132
+ if (session) { this.connections.delete(connection.id); void session.watch.stop(); }
273
133
  }
274
-
275
- private discard(session: ResumeConnection): void {
276
- if (this.connections.get(session.connection.id) !== session) return;
277
- session.watch?.unsubscribe();
278
- this.connections.delete(session.connection.id);
279
- }
280
-
281
134
  broadcastResponse(frame: { id: string; body: string; done: boolean; error?: boolean; continuation?: boolean }): void {
282
- // Approval is a Host fact, so it has no duplicate Pi event for the watch to project.
283
- const isApproval = frame.body && JSON.parse(frame.body).type === "tool-approval-request";
284
- const excluded = isApproval ? [] : [...this.connections.values()]
285
- .filter((session) => session.source.requestId === frame.id).map(({ connection }) => connection.id);
135
+ const approval = frame.body && JSON.parse(frame.body).type === "tool-approval-request";
136
+ const excluded = approval ? [] : [...this.connections.values()].filter((session) => session.source.requestId === frame.id).map((session) => session.connection.id);
286
137
  this.host.broadcast(JSON.stringify({ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, ...frame }), excluded);
287
138
  }
288
139
  }
@@ -1,98 +1,114 @@
1
- import type { AgentEvent, Events, HarnessEvent } from "@earendil-works/pi-agent-core";
2
- import type { OperationResultRecord } from "@earendil-works/pi-agent-core/harness/session";
1
+ import type { AgentEvent as LoopEvent } from "@earendil-works/pi-agent-core";
2
+ import type { AgentEvent, SnapshotEvent } from "@earendil-works/pi-durable";
3
3
  import type { AssistantMessage } from "@earendil-works/pi-ai";
4
4
  import type { UIMessage, UIMessageChunk } from "ai";
5
- import { PiChunkEncoder } from "./message";
5
+ import type { SessionHarness, SessionRunOutcome } from "../harness/session-harness";
6
+ import { PiChunkEncoder, type ToolInputPreparer } from "./message";
6
7
 
7
8
  export interface HarnessStreamOptions {
8
9
  readonly messageId: string;
9
10
  readonly startedAt: number;
10
11
  readonly turnId: string;
11
12
  readonly userMessageId?: string | null;
13
+ readonly prepareToolInput?: ToolInputPreparer;
12
14
  }
13
-
14
- /** Project the final Host decision without rewriting Pi's canonical operation result. */
15
- export function projectSubmissionOutcome(
16
- record: OperationResultRecord,
17
- submission: { status: string; completedAt?: number | null; error?: string | null },
18
- ): OperationResultRecord {
19
- const status = submission.status === "aborted" ? "aborted"
20
- : submission.status === "error" ? "failed"
21
- : submission.status === "skipped" ? "declined" : "completed";
22
- const { error: _previousError, ...result } = record;
23
- return {
24
- ...result, status, endedAt: submission.completedAt ?? record.endedAt,
25
- ...((status === "failed" || status === "aborted") && submission.error
26
- ? { error: { code: status, message: submission.error } } : {}),
27
- };
15
+ export function projectSubmissionOutcome(record: SessionRunOutcome, submission: { status: string; completedAt?: number | null; error?: string | null }): SessionRunOutcome {
16
+ const status = submission.status === "aborted" ? "aborted" : submission.status === "error" ? "failed" : submission.status === "skipped" ? "declined" : "completed";
17
+ return { status, endedAt: submission.completedAt ?? record.endedAt, ...(submission.error ? { error: { code: status, message: submission.error } } : {}) };
28
18
  }
29
19
 
30
- /** One projection for both broadcast events and a resumed Pi watch. */
20
+ /** Translates native committed Pi deltas into the browser's AI SDK wire protocol. */
31
21
  export class HarnessStreamProjection {
32
22
  private readonly encoder: PiChunkEncoder;
33
- private terminal: AssistantMessage | undefined;
34
-
35
- constructor(options: HarnessStreamOptions) {
36
- this.encoder = new PiChunkEncoder(options);
37
- }
38
-
39
- restore(message: UIMessage, streaming: boolean): UIMessageChunk[] {
23
+ private terminal?: AssistantMessage;
24
+ private readonly shown = new Map<number, string>();
25
+ private readonly toolInputStarted = new Set<string>();
26
+ private readonly toolOutput = new Map<string, string>();
27
+ constructor(options: HarnessStreamOptions) { this.encoder = new PiChunkEncoder(options); }
28
+ restore(message: UIMessage, streaming: boolean, snapshot?: SnapshotEvent): UIMessageChunk[] {
29
+ this.shown.clear();
30
+ snapshot?.generation?.message?.content.forEach((block, index) => {
31
+ if (block.type === "text") this.shown.set(index, block.text);
32
+ if (block.type === "thinking") this.shown.set(index, block.thinking);
33
+ });
34
+ for (const tool of snapshot?.tools ?? []) if (tool.output !== undefined) this.toolOutput.set(tool.callId, tool.output);
40
35
  return this.encoder.restore(message, streaming);
41
36
  }
42
-
43
- event(event: HarnessEvent): UIMessageChunk[] {
37
+ private block(block: AssistantMessage["content"][number], contentIndex: number, settled = false): UIMessageChunk[] {
38
+ if (block.type === "toolCall" && (!block.id || !block.name)) return [];
39
+ if (block.type === "toolCall") {
40
+ // The encoder keeps the first input it sees, so only a settled message may publish arguments.
41
+ if (settled) return this.encoder.encode({ type: "tool_execution_start", toolCallId: block.id, toolName: block.name, args: block.arguments } as LoopEvent);
42
+ if (this.toolInputStarted.has(block.id)) return [];
43
+ this.toolInputStarted.add(block.id);
44
+ return [{ type: "tool-input-start", toolCallId: block.id, toolName: block.name, dynamic: true }];
45
+ }
46
+ if (block.type !== "text" && block.type !== "thinking") return [];
47
+ const start = block.type === "thinking"
48
+ ? this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: "thinking_start", contentIndex } } as LoopEvent)
49
+ : [];
50
+ const text = block.type === "text" ? block.text : block.thinking;
51
+ const previous = this.shown.get(contentIndex) ?? "";
52
+ const delta = text.startsWith(previous) ? text.slice(previous.length) : text;
53
+ this.shown.set(contentIndex, text);
54
+ if (!delta) return start;
55
+ return [...start, ...this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: block.type === "text" ? "text_delta" : "thinking_delta", contentIndex, delta } } as LoopEvent)];
56
+ }
57
+ /**
58
+ * Pi emits `run_start` only when the live run changes, so a stream bound to a continued run (after an approval or a
59
+ * slice release) sees tool or message frames first. The encoder's start is idempotent; lead the first frames with it.
60
+ */
61
+ private started(chunks: UIMessageChunk[]): UIMessageChunk[] {
62
+ return chunks.length === 0 ? chunks : [...this.encoder.start(), ...chunks];
63
+ }
64
+ event(event: AgentEvent): UIMessageChunk[] { return this.started(this.frames(event)); }
65
+ private frames(event: AgentEvent): UIMessageChunk[] {
44
66
  switch (event.type) {
45
- case "run_start":
46
- case "run_resume": return this.encoder.start();
47
- case "turn_start":
48
- this.terminal = undefined;
49
- return this.encoder.encode({ type: "turn_start" } as AgentEvent);
50
- case "message_update":
51
- return event.event.type === "error" ? [] : this.encoder.encode({
52
- type: "message_update", message: event.message, assistantMessageEvent: event.event,
53
- } as AgentEvent);
54
- case "tool_start": return this.encoder.encode({
55
- type: "tool_execution_start", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args,
56
- } as AgentEvent);
57
- case "tool_update": return this.encoder.encode({
58
- type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: {}, partialResult: event.partialResult,
59
- } as AgentEvent);
60
- case "tool_end": return this.encoder.encode({
61
- type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError,
62
- } as AgentEvent);
63
- case "message_end":
64
- if (event.message.role !== "assistant") return [];
65
- this.terminal = event.message;
66
- return this.encoder.encode({ type: "message_end", message: event.message } as AgentEvent, { terminal: false });
67
+ case "run_start": return this.encoder.start();
68
+ case "turn_start": this.terminal = undefined; this.shown.clear(); return this.encoder.encode({ type: "turn_start" } as LoopEvent);
69
+ case "message_start": return event.message.role === "assistant" ? event.message.content.flatMap((block, index) => this.block(block, index)) : [];
70
+ case "message_update": return event.changes.flatMap((change) => {
71
+ if (change.type === "message") return change.message.content.flatMap((block, index) => this.block(block, index));
72
+ if (change.type === "text_start" || change.type === "thinking_start" || change.type === "block" || change.type === "toolcall_start") return this.block(change.block, change.contentIndex);
73
+ if (change.type !== "text_delta" && change.type !== "thinking_delta") return [];
74
+ this.shown.set(change.contentIndex, (this.shown.get(change.contentIndex) ?? "") + change.delta);
75
+ return this.encoder.encode({ type: "message_update", assistantMessageEvent: change } as LoopEvent);
76
+ });
77
+ case "tool_execution_start": return this.encoder.encode(event as LoopEvent);
78
+ case "tool_execution_update": {
79
+ let output = this.toolOutput.get(event.toolCallId) ?? "";
80
+ if (event.output) output = "set" in event.output ? event.output.set : output.slice(event.output.trimStart ?? 0) + (event.output.append ?? "");
81
+ this.toolOutput.set(event.toolCallId, output);
82
+ return this.encoder.encode({ type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: {}, partialResult: { content: [{ type: "text", text: output }], details: event.details } } as LoopEvent);
83
+ }
84
+ case "tool_execution_end": {
85
+ const result = event.entry?.model?.[0];
86
+ return this.encoder.encode({ type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: result?.role === "toolResult" ? result : { content: [{ type: "text", text: "Tool execution interrupted" }] }, isError: result?.role !== "toolResult" || result.isError } as LoopEvent);
87
+ }
88
+ case "message_end": {
89
+ const message = event.entry.model?.[0];
90
+ if (message?.role !== "assistant") return [];
91
+ this.terminal = message;
92
+ return [...message.content.flatMap((block, index) => this.block(block, index, true)), ...this.encoder.encode({ type: "message_end", message }, { terminal: false })];
93
+ }
67
94
  default: return [];
68
95
  }
69
96
  }
70
-
71
- approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] {
72
- return this.encoder.approvalRequested(toolCallId, approvalId);
73
- }
74
-
75
- finish(result: Pick<OperationResultRecord, "status" | "endedAt" | "error">): UIMessageChunk[] {
76
- return this.encoder.finish({
77
- stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" :
78
- this.terminal?.stopReason === "length" ? "length" : "stop",
79
- timestamp: result.endedAt,
80
- ...(result.error?.message ? { errorMessage: result.error.message } : {}),
81
- });
97
+ approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] { return this.started(this.encoder.approvalRequested(toolCallId, approvalId)); }
98
+ finish(result: SessionRunOutcome): UIMessageChunk[] {
99
+ return this.started(this.encoder.finish({ stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" : this.terminal?.stopReason === "length" ? "length" : "stop", timestamp: result.endedAt, ...(result.error ? { errorMessage: result.error.message } : {}) }));
82
100
  }
83
101
  }
84
102
 
85
- const STREAM_EVENTS = ["run_start", "run_resume", "turn_start", "message_update", "tool_start", "tool_update", "tool_end", "message_end"] as const;
86
-
87
- export function bindHarnessStream(
88
- events: Events,
89
- options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> },
90
- ) {
103
+ export async function bindHarnessStream(runtime: SessionHarness, options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> }) {
91
104
  const projection = new HarnessStreamProjection(options);
92
- const unsubscribers = STREAM_EVENTS.map((type) => events.on(type, (event) => options.publish(projection.event(event))));
105
+ const watch = await runtime.watch((await import("@earendil-works/chord/context")).BACKGROUND_CONTEXT);
106
+ let delivery: Promise<void> = Promise.resolve();
107
+ watch.start((events) => delivery = (async () => { for (const event of events) await options.publish(projection.event(event)); })());
93
108
  return {
94
109
  publishApprovalRequest: (toolCallId: string, approvalId: string) => options.publish(projection.approvalRequested(toolCallId, approvalId)),
95
- flushTerminal: (result: OperationResultRecord) => options.publish(projection.finish(result)),
96
- unsubscribe: () => { for (const unsubscribe of unsubscribers) unsubscribe(); },
110
+ // The Host follows the terminal frame with the canonical transcript, which completes any frame Pi's stop dropped.
111
+ flushTerminal: async (result: SessionRunOutcome) => { await watch.stop(); await delivery; await options.publish(projection.finish(result)); },
112
+ unsubscribe: async () => { await watch.stop(); await delivery; },
97
113
  };
98
114
  }