@estebanforge/pi-antigravity-bridge 1.0.0
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/CHANGELOG.md +125 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/docs/ARCHITECTURE.md +48 -0
- package/docs/DEVELOPMENT.md +64 -0
- package/docs/PI-BRIDGE-GAPS.md +186 -0
- package/docs/PI-INVOKETOOL-PATCH.md +227 -0
- package/extensions/index.ts +474 -0
- package/package.json +69 -0
- package/src/ask-tool.ts +579 -0
- package/src/config.ts +119 -0
- package/src/diff-render.ts +190 -0
- package/src/discovery.ts +199 -0
- package/src/mcp-server.ts +443 -0
- package/src/models.ts +261 -0
- package/src/patcher.ts +571 -0
- package/src/poller.ts +202 -0
- package/src/protobuf.ts +184 -0
- package/src/provider.ts +502 -0
- package/src/runner.ts +386 -0
- package/src/sessions.ts +159 -0
package/src/provider.ts
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
// The pi provider: streamSimple(model, context, options) -> AssistantMessageEventStream.
|
|
2
|
+
//
|
|
3
|
+
// For each turn pi calls streamSimple. We:
|
|
4
|
+
// 1. extract the latest user message (agy keeps its own history, so we send
|
|
5
|
+
// only the new prompt, not pi's full transcript)
|
|
6
|
+
// 2. resolve the pi model id to the exact agy model string
|
|
7
|
+
// 3. look up the stored agy conversation id + last streamed step for this
|
|
8
|
+
// pi session (resume) or start fresh
|
|
9
|
+
// 4. spawn agy via runAgyTurn, mapping decoded AgyEvents to pi stream events
|
|
10
|
+
// 5. persist the conversation id + final step idx for the next turn
|
|
11
|
+
//
|
|
12
|
+
// Event mapping (close-on-switch: at most one content block open at a time,
|
|
13
|
+
// matching pi-claude-bridge's lifecycle):
|
|
14
|
+
// agy text -> pi text block (text_start / text_delta / text_end)
|
|
15
|
+
// agy thinking -> pi thinking block
|
|
16
|
+
// agy tool -> pi thinking block, labelled "[agy tool: <name>]"
|
|
17
|
+
// We do NOT emit toolCall blocks: agy runs its OWN closed tool loop, so there
|
|
18
|
+
// is no toolUse stopReason and no tool-result delivery path back to pi.
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
createAssistantMessageEventStream,
|
|
22
|
+
type AssistantMessage,
|
|
23
|
+
type AssistantMessageEventStream,
|
|
24
|
+
type Context,
|
|
25
|
+
type Message,
|
|
26
|
+
type Model,
|
|
27
|
+
type SimpleStreamOptions,
|
|
28
|
+
type Usage,
|
|
29
|
+
} from "@earendil-works/pi-ai";
|
|
30
|
+
import type { Api } from "@earendil-works/pi-ai";
|
|
31
|
+
import { runAgyTurn, type AgyEvent, type AgyRunOptions } from "./runner.js";
|
|
32
|
+
import { resolveAgyString, type AgyModelEntry } from "./models.js";
|
|
33
|
+
import { SessionStore } from "./sessions.js";
|
|
34
|
+
import { loadConfig } from "./config.js";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { TurnDiffContext, createExecGitOps, parseEditToolInput } from "./diff-render.js";
|
|
37
|
+
|
|
38
|
+
const DEFAULT_TIMEOUT_MIN = 10;
|
|
39
|
+
|
|
40
|
+
/** Zero-usage helper. agy doesn't expose token counts; pi's cost math gets
|
|
41
|
+
* zeros (we're not billing through this provider). */
|
|
42
|
+
function zeroUsage(): Usage {
|
|
43
|
+
return {
|
|
44
|
+
input: 0,
|
|
45
|
+
output: 0,
|
|
46
|
+
cacheRead: 0,
|
|
47
|
+
cacheWrite: 0,
|
|
48
|
+
totalTokens: 0,
|
|
49
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Extract the latest user message as a flat prompt string. agy maintains its
|
|
54
|
+
* own conversation history via --conversation, so we collapse pi's structured
|
|
55
|
+
* message to text. Returns null if the last message isn't a user message. */
|
|
56
|
+
function extractUserPrompt(context: Context): string | null {
|
|
57
|
+
const last = context.messages[context.messages.length - 1];
|
|
58
|
+
if (!last || last.role !== "user") return null;
|
|
59
|
+
const content = last.content;
|
|
60
|
+
if (typeof content === "string") return content;
|
|
61
|
+
// Flatten text blocks; drop images (agy CLI prompt is text-only via -p).
|
|
62
|
+
return content
|
|
63
|
+
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
64
|
+
.map((b) => b.text)
|
|
65
|
+
.join("\n")
|
|
66
|
+
.trim() || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- G1: pi-side context digest --------------------------------------------------
|
|
70
|
+
//
|
|
71
|
+
// agy keeps its OWN conversation history (resumed via --conversation), so it
|
|
72
|
+
// already holds every turn it produced. What it lacks is pi-side context it was
|
|
73
|
+
// never spawned for: pi's compaction summaries and turns handled by OTHER
|
|
74
|
+
// providers (or pi's own tools). pi materializes all of that into
|
|
75
|
+
// context.messages every turn (verified: session-manager.js -> convertToLlm),
|
|
76
|
+
// so we build a DELTA digest from those messages and prepend it to the prompt.
|
|
77
|
+
// No pi patch, no new MCP tool. See docs/PI-BRIDGE-GAPS.md (G1).
|
|
78
|
+
|
|
79
|
+
const COMPACTION_MARKER = "compacted into the following summary";
|
|
80
|
+
|
|
81
|
+
const DIGEST_PREAMBLE =
|
|
82
|
+
"[The following is context from the broader pi session that this Antigravity turn was not directly spawned for: compaction summaries and turns handled by other providers or pi's own tools. Your own prior turns are already in your conversation history. Use this for continuity only.]";
|
|
83
|
+
|
|
84
|
+
/** Flatten any message content shape (string or content-block array) to text.
|
|
85
|
+
* Drops images, thinking, and tool-call blocks. */
|
|
86
|
+
function blocksToText(content: unknown): string {
|
|
87
|
+
if (typeof content === "string") return content;
|
|
88
|
+
if (!Array.isArray(content)) return "";
|
|
89
|
+
return content
|
|
90
|
+
.filter(
|
|
91
|
+
(b): b is { type: "text"; text: string } =>
|
|
92
|
+
typeof b === "object" && b !== null && (b as { type?: string }).type === "text",
|
|
93
|
+
)
|
|
94
|
+
.map((b) => b.text)
|
|
95
|
+
.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A compaction summary arrives wrapped in pi's boilerplate prefix/suffix.
|
|
99
|
+
* Return just the summary body. */
|
|
100
|
+
function stripCompactionWrapping(t: string): string {
|
|
101
|
+
const open = t.indexOf("<summary>");
|
|
102
|
+
const close = t.lastIndexOf("</summary>");
|
|
103
|
+
if (open >= 0 && close > open) return t.slice(open + "<summary>".length, close).trim();
|
|
104
|
+
return t.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface DigestOptions {
|
|
108
|
+
/** Provider id whose assistant turns are already in agy's own DB and so
|
|
109
|
+
* must be skipped to avoid double-counting. Default "antigravity". */
|
|
110
|
+
ownProvider?: string;
|
|
111
|
+
/** Soft cap on the digest body (0 = unbounded). Default 8000. */
|
|
112
|
+
maxChars?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build a delta digest of pi-side context agy was not spawned for: the most
|
|
116
|
+
* recent compaction summary plus turns since the watermark that were not
|
|
117
|
+
* produced by this provider. Pure: no I/O. Exported for unit testing.
|
|
118
|
+
*
|
|
119
|
+
* Delta, not replay: skip our own assistant turns (provider === ownProvider)
|
|
120
|
+
* and clamp the window to after any compaction (pre-compaction detail is
|
|
121
|
+
* either already in agy's DB or summarized by the injected summary).
|
|
122
|
+
*
|
|
123
|
+
* Fidelity note: other-provider assistant turns contribute only their text
|
|
124
|
+
* blocks; tool-call and thinking blocks are dropped. The intent (which tool)
|
|
125
|
+
* is lost, but their results still surface separately as toolResult messages. */
|
|
126
|
+
export function buildContextDigest(
|
|
127
|
+
messages: Message[],
|
|
128
|
+
watermark: number,
|
|
129
|
+
opts: DigestOptions = {},
|
|
130
|
+
): string {
|
|
131
|
+
const own = opts.ownProvider ?? "antigravity";
|
|
132
|
+
const maxChars = opts.maxChars ?? 8000;
|
|
133
|
+
if (messages.length === 0) return "";
|
|
134
|
+
|
|
135
|
+
let summaryPart: string | null = null;
|
|
136
|
+
const deltaParts: string[] = [];
|
|
137
|
+
|
|
138
|
+
// 1. Most-recent compaction summary (scan the whole list; it is never in
|
|
139
|
+
// agy's DB, so it is always safe and high-value to inject).
|
|
140
|
+
let lastCompactionIdx = -1;
|
|
141
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
142
|
+
const m = messages[i];
|
|
143
|
+
if (m.role !== "user") continue;
|
|
144
|
+
const t = blocksToText(m.content);
|
|
145
|
+
if (t.includes(COMPACTION_MARKER)) {
|
|
146
|
+
lastCompactionIdx = i;
|
|
147
|
+
summaryPart = `[pi compaction summary]\n${stripCompactionWrapping(t)}`;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2. Delta since the watermark, excluding the trailing current prompt.
|
|
153
|
+
// Clamp start to just after the compaction summary when one is present.
|
|
154
|
+
let start = Math.max(0, Math.floor(watermark));
|
|
155
|
+
if (lastCompactionIdx >= 0) start = Math.max(start, lastCompactionIdx + 1);
|
|
156
|
+
const end = Math.max(0, messages.length - 1);
|
|
157
|
+
for (let i = start; i < end; i++) {
|
|
158
|
+
const m = messages[i];
|
|
159
|
+
if (m.role === "assistant") {
|
|
160
|
+
if (m.provider === own) continue; // our own turn: already in agy's DB
|
|
161
|
+
const t = blocksToText(m.content).trim();
|
|
162
|
+
if (!t) continue;
|
|
163
|
+
deltaParts.push(`[assistant turn from ${m.provider}]\n${t}`);
|
|
164
|
+
} else if (m.role === "user") {
|
|
165
|
+
const t = blocksToText(m.content);
|
|
166
|
+
if (t.includes(COMPACTION_MARKER)) continue; // injected as summaryPart
|
|
167
|
+
if (!t.trim()) continue;
|
|
168
|
+
deltaParts.push(`[earlier user message]\n${t}`);
|
|
169
|
+
} else if (m.role === "toolResult") {
|
|
170
|
+
const t = blocksToText(m.content).trim();
|
|
171
|
+
deltaParts.push(
|
|
172
|
+
`[tool result: ${m.toolName}${m.isError ? " (error)" : ""}]\n${t || "(no text output)"}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Assemble. The compaction summary is always kept intact (it is the
|
|
178
|
+
// canonical compressed history). The DELTA is truncated from the newest end
|
|
179
|
+
// backward when over budget: recent context matters more for continuity
|
|
180
|
+
// than older detail, so drop the oldest delta first. If even the newest
|
|
181
|
+
// single item exceeds the budget, keep its tail slice.
|
|
182
|
+
const SEP = "\n\n";
|
|
183
|
+
const MARKER = "[truncated]";
|
|
184
|
+
let delta = deltaParts.join(SEP);
|
|
185
|
+
if (maxChars > 0) {
|
|
186
|
+
const budget = Math.max(0, maxChars - (summaryPart ? summaryPart.length + SEP.length : 0));
|
|
187
|
+
if (delta.length > budget) {
|
|
188
|
+
const kept: string[] = [];
|
|
189
|
+
let used = 0;
|
|
190
|
+
for (let i = deltaParts.length - 1; i >= 0; i--) {
|
|
191
|
+
const cost = deltaParts[i].length + (kept.length > 0 ? SEP.length : 0);
|
|
192
|
+
if (used + cost > budget) break;
|
|
193
|
+
kept.unshift(deltaParts[i]);
|
|
194
|
+
used += cost;
|
|
195
|
+
}
|
|
196
|
+
if (kept.length > 0) {
|
|
197
|
+
delta = `${MARKER}\n${kept.join(SEP)}`;
|
|
198
|
+
} else {
|
|
199
|
+
const room = Math.max(0, budget - MARKER.length - 1);
|
|
200
|
+
delta = room > 0 ? `${MARKER}\n${deltaParts[deltaParts.length - 1].slice(-room)}` : "";
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return [summaryPart, delta]
|
|
206
|
+
.filter((s): s is string => typeof s === "string" && s.length > 0)
|
|
207
|
+
.join(SEP);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Build a fresh AssistantMessage shell for this turn. Mutated as blocks
|
|
211
|
+
* stream; passed as `partial` with every event. */
|
|
212
|
+
function newAssistant(model: Model<Api>): AssistantMessage {
|
|
213
|
+
return {
|
|
214
|
+
role: "assistant",
|
|
215
|
+
content: [],
|
|
216
|
+
api: model.api,
|
|
217
|
+
provider: model.provider,
|
|
218
|
+
model: model.id,
|
|
219
|
+
usage: zeroUsage(),
|
|
220
|
+
stopReason: "stop",
|
|
221
|
+
timestamp: Date.now(),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Session key: prefer pi's sessionId (stable per conversation), fall back to
|
|
226
|
+
* cwd so a single pi process still resumes correctly when sessionId is absent. */
|
|
227
|
+
function sessionKey(options: SimpleStreamOptions | undefined, cwd: string): string {
|
|
228
|
+
const sid = (options as { sessionId?: string } | undefined)?.sessionId;
|
|
229
|
+
return sid && sid.length > 0 ? `sid:${sid}` : `cwd:${cwd}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Track which content block is currently open so we close-on-switch.
|
|
233
|
+
* At most one of textIdx / thinkingIdx is non-null at a time. */
|
|
234
|
+
interface BlockState {
|
|
235
|
+
partial: AssistantMessage;
|
|
236
|
+
textIdx: number | null;
|
|
237
|
+
thinkingIdx: number | null;
|
|
238
|
+
started: boolean;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface StreamSimpleDeps {
|
|
242
|
+
entries: AgyModelEntry[];
|
|
243
|
+
store: SessionStore;
|
|
244
|
+
/** Override the agy turn runner (tests inject a scripted event source).
|
|
245
|
+
* Defaults to the real runAgyTurn. */
|
|
246
|
+
runAgyTurn?: typeof runAgyTurn;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Build the streamSimple closure. Captures the model catalog + session store
|
|
250
|
+
* resolved at extension load. */
|
|
251
|
+
export function createStreamSimple(
|
|
252
|
+
deps: StreamSimpleDeps,
|
|
253
|
+
): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
|
|
254
|
+
const { entries, store, runAgyTurn: runFn = runAgyTurn } = deps;
|
|
255
|
+
|
|
256
|
+
return function streamSimple(model, context, options) {
|
|
257
|
+
const stream = createAssistantMessageEventStream();
|
|
258
|
+
// Fire the async turn; return the stream synchronously per pi's contract.
|
|
259
|
+
void runTurn(stream, model, context, options, entries, store, runFn);
|
|
260
|
+
return stream;
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function runTurn(
|
|
265
|
+
stream: AssistantMessageEventStream,
|
|
266
|
+
model: Model<Api>,
|
|
267
|
+
context: Context,
|
|
268
|
+
options: SimpleStreamOptions | undefined,
|
|
269
|
+
entries: AgyModelEntry[],
|
|
270
|
+
store: SessionStore,
|
|
271
|
+
runFn: typeof runAgyTurn,
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
const partial = newAssistant(model);
|
|
274
|
+
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
275
|
+
|
|
276
|
+
// Direct emit helpers. agy streams deltas that may not align to line
|
|
277
|
+
// boundaries; pi's TUI renders partial lines fine, so we append and push
|
|
278
|
+
// each delta straight through (no filtering, no buffering).
|
|
279
|
+
const appendText = (delta: string): void => {
|
|
280
|
+
ensureTextOpen(stream, blocks);
|
|
281
|
+
textAt(partial, blocks.textIdx!).text += delta;
|
|
282
|
+
stream.push({ type: "text_delta", contentIndex: blocks.textIdx!, delta, partial });
|
|
283
|
+
};
|
|
284
|
+
const appendThinking = (delta: string): void => {
|
|
285
|
+
ensureThinkingOpen(stream, blocks);
|
|
286
|
+
thinkingAt(partial, blocks.thinkingIdx!).thinking += delta;
|
|
287
|
+
stream.push({ type: "thinking_delta", contentIndex: blocks.thinkingIdx!, delta, partial });
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// Signal the turn has begun IMMEDIATELY. pi's native Working indicator is
|
|
291
|
+
// driven by the stream's start event (isStreaming). Without this, agy's
|
|
292
|
+
// initial thinking seconds (before it emits any step) show nothing and the
|
|
293
|
+
// UI looks frozen. Lazy start (on first content) was the old behavior.
|
|
294
|
+
ensureStarted(stream, blocks);
|
|
295
|
+
|
|
296
|
+
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
297
|
+
const key = sessionKey(options, cwd);
|
|
298
|
+
const existing = store.get(key);
|
|
299
|
+
const messageCount = context.messages.length;
|
|
300
|
+
|
|
301
|
+
const prompt = extractUserPrompt(context);
|
|
302
|
+
if (!prompt) {
|
|
303
|
+
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// G1: inject a delta digest of pi-side context agy was not spawned for
|
|
308
|
+
// (compaction summaries, other-provider turns). agy keeps its own history,
|
|
309
|
+
// so this is a delta, not a replay. See docs/PI-BRIDGE-GAPS.md (G1).
|
|
310
|
+
const watermark = existing?.lastMessageCount ?? 0;
|
|
311
|
+
const digest = buildContextDigest(context.messages, watermark);
|
|
312
|
+
const fullPrompt = digest ? `${DIGEST_PREAMBLE}\n\n${digest}\n\n---\n\n${prompt}` : prompt;
|
|
313
|
+
|
|
314
|
+
// Resolve the pi model id to the exact agy string. Fall through to the id
|
|
315
|
+
// itself on miss - agy will likely reject, but the error reaches the user
|
|
316
|
+
// instead of a silent no-op.
|
|
317
|
+
const agyModel = resolveAgyString(model.id, entries) ?? model.id;
|
|
318
|
+
|
|
319
|
+
// Runtime config (mode, permissions). Loaded fresh each turn so /agy
|
|
320
|
+
// toggles take effect immediately without a reload.
|
|
321
|
+
const config = loadConfig();
|
|
322
|
+
|
|
323
|
+
const runOpts: AgyRunOptions = {
|
|
324
|
+
cwd,
|
|
325
|
+
model: agyModel,
|
|
326
|
+
mode: config.mode,
|
|
327
|
+
skipPermissions: config.skipPermissions,
|
|
328
|
+
prompt: fullPrompt,
|
|
329
|
+
conversationId: existing?.conversationId ?? null,
|
|
330
|
+
baseStepIdx: existing?.lastStepIdx ?? -1,
|
|
331
|
+
timeoutMin: DEFAULT_TIMEOUT_MIN,
|
|
332
|
+
signal: options?.signal,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
// G8: per-turn diff context for agy's file edits (write_to_file et al.).
|
|
336
|
+
// Turn-scoped so concurrent turns never share OLD-content caches.
|
|
337
|
+
const diffCtx = new TurnDiffContext(createExecGitOps());
|
|
338
|
+
|
|
339
|
+
const onEvent = (event: AgyEvent) => {
|
|
340
|
+
switch (event.kind) {
|
|
341
|
+
case "text":
|
|
342
|
+
appendText(event.text);
|
|
343
|
+
break;
|
|
344
|
+
case "thinking":
|
|
345
|
+
appendThinking(event.text);
|
|
346
|
+
break;
|
|
347
|
+
case "tool": {
|
|
348
|
+
// G8: if agy wrote a file, surface a git-sourced diff; else the plain
|
|
349
|
+
// tool label. Always shown (agy's own tool loop, surfaced for visibility).
|
|
350
|
+
const edit = parseEditToolInput(event.inputJson ?? "");
|
|
351
|
+
if (edit) {
|
|
352
|
+
const absFile = path.isAbsolute(edit.file) ? edit.file : path.resolve(cwd, edit.file);
|
|
353
|
+
const outcome = diffCtx.diffEdit(absFile, edit.content);
|
|
354
|
+
const label = edit.description ?? path.basename(absFile);
|
|
355
|
+
appendThinking(`[agy edit: ${label}]\n`);
|
|
356
|
+
if (outcome.text) appendThinking(`${outcome.text}\n`);
|
|
357
|
+
} else {
|
|
358
|
+
appendThinking(`[agy tool: ${event.name}]\n`);
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case "title":
|
|
363
|
+
// Conversation title metadata - not streamed to the user.
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
let result;
|
|
369
|
+
try {
|
|
370
|
+
result = await runFn(runOpts, onEvent);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
373
|
+
finalize(stream, blocks, "error", `agy failed to start: ${msg}`);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Persist for the next turn (resume). Only bind when we actually discovered
|
|
378
|
+
// an id - a discovery miss shouldn't clobber a prior good binding.
|
|
379
|
+
// Persist for the next turn (resume). Only bind when we actually discovered
|
|
380
|
+
// an id - a discovery miss shouldn't clobber a prior good binding. The
|
|
381
|
+
// lastMessageCount watermark advances on a successful bind even if the turn
|
|
382
|
+
// later aborted or timed out: the prompt (digest included) was handed to
|
|
383
|
+
// agy at spawn, so its DB has seen that context. Guarding this on
|
|
384
|
+
// exitCode===0 would re-inject stale deltas after retryable failures.
|
|
385
|
+
if (result.conversationId) {
|
|
386
|
+
store.set(key, {
|
|
387
|
+
conversationId: result.conversationId,
|
|
388
|
+
lastStepIdx: result.lastIdx,
|
|
389
|
+
lastMessageCount: messageCount,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (result.aborted) {
|
|
394
|
+
finalize(stream, blocks, "aborted", "Operation aborted");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (result.timedOut) {
|
|
398
|
+
const note = `agy exceeded the ${runOpts.timeoutMin}m timeout`;
|
|
399
|
+
finalize(stream, blocks, "error", note);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (result.exitCode !== 0) {
|
|
403
|
+
const detail = result.stderr.trim() || `agy exited with status ${result.exitCode}`;
|
|
404
|
+
finalize(stream, blocks, "error", detail);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Discovery miss: agy exited cleanly but we never bound a conversation id
|
|
409
|
+
// this turn (ambiguous snapshot, DB not created in time, or a prior session
|
|
410
|
+
// whose id failed CONV_ID_RE and silently fell through to fresh discovery).
|
|
411
|
+
// Guard on whether we bound THIS turn, not on whether a prior session
|
|
412
|
+
// existed - otherwise a corrupt existing entry re-opens the silent-empty-
|
|
413
|
+
// success hole the first review closed.
|
|
414
|
+
if (!result.conversationId) {
|
|
415
|
+
const detail =
|
|
416
|
+
"agy exited cleanly but its conversation database could not be bound. " +
|
|
417
|
+
"The run may have partially applied edits with no visible output.";
|
|
418
|
+
finalize(stream, blocks, "error", detail);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Success. If no text ever streamed (agy did only tool work, or returned
|
|
423
|
+
// empty), emit an empty text block so pi has a well-formed assistant turn.
|
|
424
|
+
if (blocks.textIdx === null && blocks.thinkingIdx === null) {
|
|
425
|
+
ensureTextOpen(stream, blocks);
|
|
426
|
+
}
|
|
427
|
+
finalize(stream, blocks, "stop");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Signal the start of the assistant turn exactly once. `start` is
|
|
431
|
+
* turn-level (analogous to Anthropic's message_start), not per-block - the
|
|
432
|
+
* per-block signals are text_start / thinking_start. */
|
|
433
|
+
function ensureStarted(stream: AssistantMessageEventStream, b: BlockState): void {
|
|
434
|
+
if (b.started) return;
|
|
435
|
+
b.started = true;
|
|
436
|
+
stream.push({ type: "start", partial: b.partial });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Open the text block, closing the thinking block first if it's open. */
|
|
440
|
+
function ensureTextOpen(stream: AssistantMessageEventStream, b: BlockState): void {
|
|
441
|
+
if (b.textIdx !== null) return;
|
|
442
|
+
closeThinking(stream, b);
|
|
443
|
+
ensureStarted(stream, b);
|
|
444
|
+
b.partial.content.push({ type: "text", text: "" });
|
|
445
|
+
b.textIdx = b.partial.content.length - 1;
|
|
446
|
+
stream.push({ type: "text_start", contentIndex: b.textIdx, partial: b.partial });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Open the thinking block, closing the text block first if it's open. */
|
|
450
|
+
function ensureThinkingOpen(stream: AssistantMessageEventStream, b: BlockState): void {
|
|
451
|
+
if (b.thinkingIdx !== null) return;
|
|
452
|
+
closeText(stream, b);
|
|
453
|
+
ensureStarted(stream, b);
|
|
454
|
+
b.partial.content.push({ type: "thinking", thinking: "" });
|
|
455
|
+
b.thinkingIdx = b.partial.content.length - 1;
|
|
456
|
+
stream.push({ type: "thinking_start", contentIndex: b.thinkingIdx, partial: b.partial });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function closeText(stream: AssistantMessageEventStream, b: BlockState): void {
|
|
460
|
+
if (b.textIdx === null) return;
|
|
461
|
+
const idx = b.textIdx;
|
|
462
|
+
b.textIdx = null;
|
|
463
|
+
stream.push({ type: "text_end", contentIndex: idx, content: textAt(b.partial, idx).text, partial: b.partial });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function closeThinking(stream: AssistantMessageEventStream, b: BlockState): void {
|
|
467
|
+
if (b.thinkingIdx === null) return;
|
|
468
|
+
const idx = b.thinkingIdx;
|
|
469
|
+
b.thinkingIdx = null;
|
|
470
|
+
stream.push({ type: "thinking_end", contentIndex: idx, content: thinkingAt(b.partial, idx).thinking, partial: b.partial });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Typed accessors: AssistantMessage.content is a discriminated union, but we
|
|
474
|
+
// always know which slot holds which block (we just pushed it). The cast is
|
|
475
|
+
// sound and keeps every mutation site free of scattered `as` expressions.
|
|
476
|
+
function textAt(p: AssistantMessage, idx: number): { type: "text"; text: string } {
|
|
477
|
+
return p.content[idx] as { type: "text"; text: string };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function thinkingAt(p: AssistantMessage, idx: number): { type: "thinking"; thinking: string } {
|
|
481
|
+
return p.content[idx] as { type: "thinking"; thinking: string };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Close any open block and push the terminal event. */
|
|
485
|
+
function finalize(
|
|
486
|
+
stream: AssistantMessageEventStream,
|
|
487
|
+
b: BlockState,
|
|
488
|
+
reason: "stop" | "error" | "aborted",
|
|
489
|
+
message?: string,
|
|
490
|
+
): void {
|
|
491
|
+
closeText(stream, b);
|
|
492
|
+
closeThinking(stream, b);
|
|
493
|
+
if (reason === "stop") {
|
|
494
|
+
b.partial.stopReason = "stop";
|
|
495
|
+
stream.push({ type: "done", reason: "stop", message: b.partial });
|
|
496
|
+
} else {
|
|
497
|
+
b.partial.stopReason = reason;
|
|
498
|
+
if (message) b.partial.errorMessage = message;
|
|
499
|
+
stream.push({ type: "error", reason, error: b.partial });
|
|
500
|
+
}
|
|
501
|
+
stream.end();
|
|
502
|
+
}
|