@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.
- package/package.json +11 -9
- package/src/adapter/cloudflare/agent.ts +233 -179
- package/src/adapter/cloudflare/define-agent.ts +252 -104
- package/src/adapter/cloudflare/index.ts +18 -0
- package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
- package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
- package/src/adapter/cloudflare/schema.ts +21 -2
- package/src/adapter/cloudflare/subagent/definition.ts +25 -13
- package/src/adapter/cloudflare/subagent/runner.ts +43 -50
- package/src/adapter/cloudflare/subagent/tools.ts +4 -4
- package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
- package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
- package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
- package/src/config/activation.ts +13 -15
- package/src/config/agent-context.ts +1 -1
- package/src/config/assembler.ts +48 -31
- package/src/config/assembly-snapshot.ts +1 -1
- package/src/config/definition.ts +1 -1
- package/src/config/harness-runtime.ts +77 -49
- package/src/config/index.ts +1 -1
- package/src/config/model-guidance.ts +20 -0
- package/src/config/ports.ts +57 -7
- package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
- package/src/config/public-contracts.ts +2 -2
- package/src/config/runtime-config.ts +2 -1
- package/src/context/assembly.ts +1 -1
- package/src/context/extension/assembly.ts +2 -2
- package/src/context/memory/budget/gate.ts +19 -3
- package/src/conversation/harness-resume.ts +89 -238
- package/src/conversation/harness-stream.ts +87 -71
- package/src/conversation/history.ts +154 -152
- package/src/conversation/message/contract.ts +28 -4
- package/src/conversation/message/conversion.ts +46 -5
- package/src/conversation/message/projection.ts +61 -21
- package/src/conversation/session-entry.ts +29 -0
- package/src/conversation/state.ts +23 -2
- package/src/harness/governance.ts +206 -0
- package/src/harness/index.ts +3 -0
- package/src/harness/session-harness.ts +269 -445
- package/src/harness/submission-operation.ts +15 -40
- package/src/index.ts +18 -8
- package/src/lib/artifacts.ts +18 -0
- package/src/lib/execution-level.ts +13 -0
- package/src/lib/prompt.ts +9 -10
- package/src/lib/token-estimate.ts +8 -0
- package/src/model-protocol/openrouter.ts +41 -0
- package/src/models/index.ts +200 -35
- package/src/models/openrouter/messages.ts +13 -8
- package/src/models/openrouter/openai.ts +200 -0
- package/src/models/openrouter/protocol-response.ts +85 -0
- package/src/models/provider-streams.ts +45 -0
- package/src/session/cloudflare/migration.ts +382 -864
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/skills/springbrand-worker-website/index.ts +1 -1
- package/src/submission/admission.ts +20 -30
- package/src/submission/contracts.ts +42 -2
- package/src/submission/delivery.ts +1 -1
- package/src/submission/finalization.ts +36 -15
- package/src/submission/lifecycle.ts +28 -15
- package/src/submission/repository.ts +97 -49
- package/src/submission/steer-delivery.ts +32 -76
- package/src/submission/unattended.ts +40 -0
- package/src/telemetry/contract.ts +0 -1
- package/src/telemetry/coordinator.ts +1 -1
- package/src/telemetry/runtime-telemetry.ts +0 -4
- package/src/telemetry/subagent-lifecycle.ts +1 -1
- package/src/telemetry/turn-events.ts +35 -25
- package/src/tools/README.md +44 -0
- package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
- package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
- package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
- package/src/tools/assistant/ask-user.ts +319 -0
- package/src/tools/assistant/candidate.ts +15 -0
- package/src/tools/assistant/followups.ts +34 -0
- package/src/tools/assistant/index.ts +19 -0
- package/src/tools/assistant/memory.ts +73 -0
- package/src/tools/assistant/plan.ts +98 -0
- package/src/tools/{time.ts → assistant/time.ts} +1 -1
- package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
- package/src/tools/{core.ts → codemode/index.ts} +36 -92
- package/src/tools/codemode/workspace-connector.ts +198 -0
- package/src/tools/extension/index.ts +42 -0
- package/src/tools/index.ts +29 -26
- package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
- package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
- package/src/tools/runtime/activation.ts +40 -0
- package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
- package/src/tools/runtime/compiler.ts +246 -0
- package/src/tools/runtime/contracts.ts +140 -0
- package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
- package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
- package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
- package/src/tools/runtime/result.ts +22 -0
- package/src/tools/runtime/unattended-error.ts +4 -0
- package/src/tools/sandbox/index.ts +228 -0
- package/src/tools/schedule/index.ts +301 -0
- package/src/tools/skill/contracts.ts +20 -0
- package/src/tools/skill/index.ts +99 -0
- package/src/tools/skill/resources.ts +322 -0
- package/src/tools/skill/scripts.ts +168 -0
- package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
- package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
- package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
- package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
- package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
- package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
- package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
- package/src/tools/web/index.ts +2 -0
- package/src/tools/web/search/api.ts +1322 -0
- package/src/tools/web/search/index.ts +2 -0
- package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
- package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
- package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
- package/src/tools/base.ts +0 -534
- package/src/tools/compiler.ts +0 -327
- package/src/tools/schedule.ts +0 -261
- package/src/tools/skill.ts +0 -565
- package/src/tools/web-search/api.ts +0 -1318
- package/src/tools/web-search/index.ts +0 -2
- package/src/tools/workspace-state-connector.ts +0 -146
- package/src/tools/{approval → runtime/approval}/index.ts +0 -0
- package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
- package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
- /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { BACKGROUND_CONTEXT } from "@earendil-works/
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
+
/**
|
|
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
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
this.
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
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.
|
|
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
|
-
|
|
283
|
-
const
|
|
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
|
|
2
|
-
import type {
|
|
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 {
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
/**
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 "
|
|
47
|
-
case "
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
} as
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
return this.encoder.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
}
|