@springbrand/agent-runtime 0.3.0-alpha.15 → 0.3.0-alpha.17

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.
@@ -1,21 +1,20 @@
1
- import type { Context } from "@earendil-works/pi-ai";
1
+ import { getCurrentTools, normalizeContext, type Context, type Tool, type TranscriptContext } from "@earendil-works/pi-ai";
2
2
  import type { PiToolCandidate } from "../tools/runtime/contracts";
3
3
 
4
- /** Only descriptions and guidance may vary inside a pinned Submission. */
5
- export async function refreshModelGuidance(
6
- candidates: readonly PiToolCandidate[],
7
- context: Context,
8
- ): Promise<Context> {
9
- const guidance = new Map(await Promise.all(candidates
10
- .filter(candidate => candidate.modelGuidance && context.tools?.some(tool => tool.name === candidate.tool.name))
11
- .map(async candidate => [candidate.tool.name, await candidate.modelGuidance!()] as const)));
12
- if (!guidance.size) return context;
13
- return {
14
- ...context,
15
- systemPrompt: [context.systemPrompt, ...new Set([...guidance.values()].map(item => item.systemPrompt).filter(Boolean))].filter(Boolean).join("\n\n"),
16
- tools: context.tools?.map(tool => {
17
- const prompt = guidance.get(tool.name);
18
- return prompt ? { ...tool, description: prompt.description } : tool;
4
+ /** Request-only presentation; Pi remains the source of transcript and active Tool selection. */
5
+ export async function refreshModelGuidance(candidates: readonly PiToolCandidate[], context: Context): Promise<TranscriptContext> {
6
+ const transcript = normalizeContext(context);
7
+ const active = new Set(getCurrentTools(transcript.messages).map((tool) => tool.name));
8
+ const byName = new Map(candidates.map((candidate) => [candidate.tool.name, candidate]));
9
+ const guidance = new Map(await Promise.all(candidates.filter((candidate) => active.has(candidate.tool.name) && candidate.modelGuidance)
10
+ .map(async (candidate) => [candidate.tool.name, await candidate.modelGuidance!()] as const)));
11
+ const messages = transcript.messages.map((message) => message.role !== "system" || !message.toolsAdded ? message : {
12
+ ...message,
13
+ toolsAdded: message.toolsAdded.map((tool): Tool => {
14
+ const candidate = byName.get(tool.name), prompt = guidance.get(tool.name);
15
+ return { ...tool, ...(candidate?.deferLoading ? { deferLoading: true as const } : {}), ...(prompt ? { description: prompt.description } : {}) };
19
16
  }),
20
- };
17
+ });
18
+ const sections = [...new Set([...guidance.values()].flatMap((value) => value.systemPrompt ? [value.systemPrompt] : []))];
19
+ return normalizeContext({ messages: sections.length ? [...messages, { role: "system", content: "", sections: { "springbrand.live-services": sections.join("\n\n") }, timestamp: 1 }] : messages });
21
20
  }
@@ -1004,6 +1004,8 @@ export interface RuntimeModelEndpoint {
1004
1004
  >;
1005
1005
  /** OpenRouter upstream provider pins keyed by model ID; pinned requests never fall back. */
1006
1006
  openRouterProviderPins?: Readonly<Record<string, string>>;
1007
+ /** OpenRouter provider ordering; omitted keeps OpenRouter's default routing. */
1008
+ openRouterProviderSort?: "price" | "throughput" | "latency";
1007
1009
  modelMetadata?: Readonly<Record<string, RuntimeModelMetadata>>;
1008
1010
  }
1009
1011
 
@@ -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,106 @@ 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
57
  async resume(connection: Connection, probeId?: string): Promise<void> {
58
+ this.disconnect(connection);
115
59
  const source = this.active;
116
60
  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
- })();
130
- }
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
- }
61
+ await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
62
+ await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
139
63
  return;
140
64
  }
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));
65
+ const watch = await source.runtime.watch(BACKGROUND_CONTEXT);
66
+ if (this.active !== source) { await watch.stop(); return this.resume(connection, probeId); }
67
+ if (!watch.snapshot.run) {
68
+ await watch.stop();
69
+ await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: await this.host.history.browserMessages() });
70
+ await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUME_NONE, reason: STREAM_RESUME_NONE_REASONS.IDLE, ...(probeId ? { probeId } : {}) });
71
+ return;
156
72
  }
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
- });
73
+ const messages = await this.host.history.browserSnapshot(watch.snapshot, source.submissionId);
74
+ const replay = this.replayMessage(messages, source.submissionId);
75
+ 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 };
76
+ this.connections.set(connection.id, session);
77
+ await this.send(connection, { type: MessageType.CF_AGENT_CHAT_MESSAGES, messages: replay ? messages.filter((message) => message.id !== replay.id) : messages });
78
+ await this.send(connection, { type: MessageType.CF_AGENT_STREAM_RESUMING, id: source.requestId, ...(probeId ? { probeId } : {}) });
163
79
  }
164
-
165
80
  async acknowledge(connection: Connection, requestId: string): Promise<void> {
166
81
  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
- });
82
+ if (!session || session.source.requestId !== requestId || session.started) return;
83
+ session.started = true;
84
+ const messages = await this.host.history.browserSnapshot(session.watch.snapshot, session.source.submissionId);
85
+ const replay = this.replayMessage(messages, session.source.submissionId);
86
+ if (replay) await this.chunks(session, session.projection.restore(replay, Boolean(session.watch.snapshot.generation?.message), session.watch.snapshot), true);
87
+ await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: session.source.requestId, body: "", done: false, replayComplete: true });
88
+ session.watch.start((events) => session.delivery = (async () => {
89
+ for (const event of events) {
90
+ if (event.type === "snapshot") {
91
+ await this.resume(session.connection);
92
+ return;
93
+ } else await this.chunks(session, session.projection.event(event));
94
+ }
95
+ })());
222
96
  }
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 });
97
+ async settle(outcome: SessionRunOutcome): Promise<void> {
98
+ for (const session of this.connections.values()) {
99
+ await session.watch.stop(); await session.delivery;
100
+ if (session.started) {
101
+ await this.chunks(session, session.projection.finish(outcome));
102
+ }
103
+ await this.send(session.connection, { type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, id: session.source.requestId, body: "", done: true });
232
104
  }
105
+ if (this.active) this.broadcastResponse({ id: this.active.requestId, body: "", done: true });
233
106
  }
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 });
107
+ async release(runtime: SessionHarness): Promise<void> {
108
+ if (this.active?.runtime === runtime) this.active = undefined;
109
+ for (const session of [...this.connections.values()]) {
110
+ if (session.source.runtime !== runtime) continue;
111
+ await session.watch.stop(); await session.delivery; this.connections.delete(session.connection.id);
247
112
  }
248
113
  }
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
114
  disconnect(connection: Pick<Connection, "id">): void {
271
115
  const session = this.connections.get(connection.id);
272
- if (session) this.discard(session);
116
+ if (session) { this.connections.delete(connection.id); void session.watch.stop(); }
273
117
  }
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
118
  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);
119
+ const approval = frame.body && JSON.parse(frame.body).type === "tool-approval-request";
120
+ const excluded = approval ? [] : [...this.connections.values()].filter((session) => session.source.requestId === frame.id).map((session) => session.connection.id);
286
121
  this.host.broadcast(JSON.stringify({ type: MessageType.CF_AGENT_USE_CHAT_RESPONSE, ...frame }), excluded);
287
122
  }
288
123
  }
@@ -1,7 +1,8 @@
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 type { SessionHarness, SessionRunOutcome } from "../harness/session-harness";
5
6
  import { PiChunkEncoder, type ToolInputPreparer } from "./message";
6
7
 
7
8
  export interface HarnessStreamOptions {
@@ -11,89 +12,87 @@ export interface HarnessStreamOptions {
11
12
  readonly userMessageId?: string | null;
12
13
  readonly prepareToolInput?: ToolInputPreparer;
13
14
  }
14
-
15
- /** Project the final Host decision without rewriting Pi's canonical operation result. */
16
- export function projectSubmissionOutcome(
17
- record: OperationResultRecord,
18
- submission: { status: string; completedAt?: number | null; error?: string | null },
19
- ): OperationResultRecord {
20
- const status = submission.status === "aborted" ? "aborted"
21
- : submission.status === "error" ? "failed"
22
- : submission.status === "skipped" ? "declined" : "completed";
23
- const { error: _previousError, ...result } = record;
24
- return {
25
- ...result, status, endedAt: submission.completedAt ?? record.endedAt,
26
- ...((status === "failed" || status === "aborted") && submission.error
27
- ? { error: { code: status, message: submission.error } } : {}),
28
- };
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 } } : {}) };
29
18
  }
30
19
 
31
- /** 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. */
32
21
  export class HarnessStreamProjection {
33
22
  private readonly encoder: PiChunkEncoder;
34
- private terminal: AssistantMessage | undefined;
35
-
36
- constructor(options: HarnessStreamOptions) {
37
- this.encoder = new PiChunkEncoder(options);
38
- }
39
-
40
- restore(message: UIMessage, streaming: boolean): UIMessageChunk[] {
23
+ private terminal?: AssistantMessage;
24
+ private readonly shown = new Map<number, string>();
25
+ private readonly toolOutput = new Map<string, string>();
26
+ constructor(options: HarnessStreamOptions) { this.encoder = new PiChunkEncoder(options); }
27
+ restore(message: UIMessage, streaming: boolean, snapshot?: SnapshotEvent): UIMessageChunk[] {
28
+ this.shown.clear();
29
+ snapshot?.generation?.message?.content.forEach((block, index) => {
30
+ if (block.type === "text") this.shown.set(index, block.text);
31
+ if (block.type === "thinking") this.shown.set(index, block.thinking);
32
+ });
33
+ for (const tool of snapshot?.tools ?? []) if (tool.output !== undefined) this.toolOutput.set(tool.callId, tool.output);
41
34
  return this.encoder.restore(message, streaming);
42
35
  }
43
-
44
- event(event: HarnessEvent): UIMessageChunk[] {
36
+ private block(block: AssistantMessage["content"][number], contentIndex: number): UIMessageChunk[] {
37
+ if (block.type === "toolCall" && (!block.id || !block.name)) return [];
38
+ if (block.type === "toolCall") return this.encoder.encode({ type: "tool_execution_start", toolCallId: block.id, toolName: block.name, args: block.arguments } as LoopEvent);
39
+ if (block.type !== "text" && block.type !== "thinking") return [];
40
+ const start = block.type === "thinking"
41
+ ? this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: "thinking_start", contentIndex } } as LoopEvent)
42
+ : [];
43
+ const text = block.type === "text" ? block.text : block.thinking;
44
+ const previous = this.shown.get(contentIndex) ?? "";
45
+ const delta = text.startsWith(previous) ? text.slice(previous.length) : text;
46
+ this.shown.set(contentIndex, text);
47
+ if (!delta) return start;
48
+ return [...start, ...this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: block.type === "text" ? "text_delta" : "thinking_delta", contentIndex, delta } } as LoopEvent)];
49
+ }
50
+ event(event: AgentEvent): UIMessageChunk[] {
45
51
  switch (event.type) {
46
- case "run_start":
47
- case "run_resume": return this.encoder.start();
48
- case "turn_start":
49
- this.terminal = undefined;
50
- return this.encoder.encode({ type: "turn_start" } as AgentEvent);
51
- case "message_update":
52
- return event.event.type === "error" ? [] : this.encoder.encode({
53
- type: "message_update", message: event.message, assistantMessageEvent: event.event,
54
- } as AgentEvent);
55
- case "tool_start": return this.encoder.encode({
56
- type: "tool_execution_start", toolCallId: event.toolCallId, toolName: event.toolName, args: event.args,
57
- } as AgentEvent);
58
- case "tool_update": return this.encoder.encode({
59
- type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: {}, partialResult: event.partialResult,
60
- } as AgentEvent);
61
- case "tool_end": return this.encoder.encode({
62
- type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError,
63
- } as AgentEvent);
64
- case "message_end":
65
- if (event.message.role !== "assistant") return [];
66
- this.terminal = event.message;
67
- return this.encoder.encode({ type: "message_end", message: event.message } as AgentEvent, { terminal: false });
52
+ case "run_start": return this.encoder.start();
53
+ case "turn_start": this.terminal = undefined; this.shown.clear(); return this.encoder.encode({ type: "turn_start" } as LoopEvent);
54
+ case "message_start": return event.message.role === "assistant" ? event.message.content.flatMap((block, index) => this.block(block, index)) : [];
55
+ case "message_update": return event.changes.flatMap((change) => {
56
+ if (change.type === "message") return change.message.content.flatMap((block, index) => this.block(block, index));
57
+ if (change.type === "text_start" || change.type === "thinking_start" || change.type === "block" || change.type === "toolcall_start") return this.block(change.block, change.contentIndex);
58
+ if (change.type !== "text_delta" && change.type !== "thinking_delta") return [];
59
+ this.shown.set(change.contentIndex, (this.shown.get(change.contentIndex) ?? "") + change.delta);
60
+ return this.encoder.encode({ type: "message_update", assistantMessageEvent: change } as LoopEvent);
61
+ });
62
+ case "tool_execution_start": return this.encoder.encode(event as LoopEvent);
63
+ case "tool_execution_update": {
64
+ let output = this.toolOutput.get(event.toolCallId) ?? "";
65
+ if (event.output) output = "set" in event.output ? event.output.set : output.slice(event.output.trimStart ?? 0) + (event.output.append ?? "");
66
+ this.toolOutput.set(event.toolCallId, output);
67
+ 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);
68
+ }
69
+ case "tool_execution_end": {
70
+ const result = event.entry?.model?.[0];
71
+ 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);
72
+ }
73
+ case "message_end": {
74
+ const message = event.entry.model?.[0];
75
+ if (message?.role !== "assistant") return [];
76
+ this.terminal = message;
77
+ return [...message.content.flatMap((block, index) => this.block(block, index)), ...this.encoder.encode({ type: "message_end", message }, { terminal: false })];
78
+ }
68
79
  default: return [];
69
80
  }
70
81
  }
71
-
72
- approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] {
73
- return this.encoder.approvalRequested(toolCallId, approvalId);
74
- }
75
-
76
- finish(result: Pick<OperationResultRecord, "status" | "endedAt" | "error">): UIMessageChunk[] {
77
- return this.encoder.finish({
78
- stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" :
79
- this.terminal?.stopReason === "length" ? "length" : "stop",
80
- timestamp: result.endedAt,
81
- ...(result.error?.message ? { errorMessage: result.error.message } : {}),
82
- });
82
+ approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] { return this.encoder.approvalRequested(toolCallId, approvalId); }
83
+ finish(result: SessionRunOutcome): UIMessageChunk[] {
84
+ return 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 } : {}) });
83
85
  }
84
86
  }
85
87
 
86
- const STREAM_EVENTS = ["run_start", "run_resume", "turn_start", "message_update", "tool_start", "tool_update", "tool_end", "message_end"] as const;
87
-
88
- export function bindHarnessStream(
89
- events: Events,
90
- options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> },
91
- ) {
88
+ export async function bindHarnessStream(runtime: SessionHarness, options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> }) {
92
89
  const projection = new HarnessStreamProjection(options);
93
- const unsubscribers = STREAM_EVENTS.map((type) => events.on(type, (event) => options.publish(projection.event(event))));
90
+ const watch = await runtime.watch((await import("@earendil-works/chord/context")).BACKGROUND_CONTEXT);
91
+ let delivery: Promise<void> = Promise.resolve();
92
+ watch.start((events) => delivery = (async () => { for (const event of events) await options.publish(projection.event(event)); })());
94
93
  return {
95
94
  publishApprovalRequest: (toolCallId: string, approvalId: string) => options.publish(projection.approvalRequested(toolCallId, approvalId)),
96
- flushTerminal: (result: OperationResultRecord) => options.publish(projection.finish(result)),
97
- unsubscribe: () => { for (const unsubscribe of unsubscribers) unsubscribe(); },
95
+ flushTerminal: async (result: SessionRunOutcome) => { await watch.stop(); await delivery; await options.publish(projection.finish(result)); },
96
+ unsubscribe: async () => { await watch.stop(); await delivery; },
98
97
  };
99
98
  }