@estebanforge/pi-antigravity-bridge 1.3.3 → 1.4.1
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 +57 -0
- package/README.md +22 -3
- package/docs/ACP-ADOPTION-PLAN.md +875 -0
- package/docs/ACP-PROTOCOL-REFERENCE.md +460 -0
- package/docs/ANTIGRAVITY-INTEGRATIONS.md +50 -551
- package/docs/ARCHITECTURE.md +50 -3
- package/docs/DEVELOPMENT.md +33 -0
- package/docs/PI-BRIDGE-GAPS.md +41 -140
- package/extensions/index.ts +235 -39
- package/package.json +12 -5
- package/src/acp/connection.ts +409 -0
- package/src/acp/driver.ts +723 -0
- package/src/acp/events.ts +250 -0
- package/src/acp/jsonrpc.ts +185 -0
- package/src/acp/setup.ts +355 -0
- package/src/config.ts +33 -0
- package/src/diff-render.ts +15 -0
- package/src/driver-types.ts +144 -0
- package/src/driver.ts +69 -78
- package/src/mcp-server.ts +8 -1
- package/src/models.ts +9 -7
- package/src/provider.ts +148 -23
package/src/driver.ts
CHANGED
|
@@ -16,81 +16,29 @@
|
|
|
16
16
|
|
|
17
17
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
18
|
import { randomUUID } from "node:crypto";
|
|
19
|
-
import { parseAgyLine
|
|
19
|
+
import { parseAgyLine } from "./stream-events.js";
|
|
20
20
|
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export type DriverActivity =
|
|
44
|
-
| { type: "text"; delta: string }
|
|
45
|
-
| { type: "thought"; tokens: number }
|
|
46
|
-
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
|
|
47
|
-
| {
|
|
48
|
-
type: "tool_done";
|
|
49
|
-
stepId?: number;
|
|
50
|
-
name: string;
|
|
51
|
-
args: Record<string, unknown>;
|
|
52
|
-
output?: string;
|
|
53
|
-
durationSeconds?: number;
|
|
54
|
-
}
|
|
55
|
-
| { type: "tool_error"; stepId?: number; name: string; message: string }
|
|
56
|
-
| { type: "usage"; usage: AgyUsage }
|
|
57
|
-
/** Synthetic: injected by the provider when the MCP bridge receives a call. */
|
|
58
|
-
| { type: "bridge_call"; callId: string; name: string; args: Record<string, unknown> };
|
|
59
|
-
|
|
60
|
-
export interface TurnOutcome {
|
|
61
|
-
conversationId?: string;
|
|
62
|
-
status: "OK" | "ERROR" | "UNKNOWN";
|
|
63
|
-
response: string;
|
|
64
|
-
error?: string;
|
|
65
|
-
usage?: AgyUsage;
|
|
66
|
-
finished: boolean;
|
|
67
|
-
aborted: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface TurnHandle {
|
|
71
|
-
id: string;
|
|
72
|
-
/** Resolves when the turn settles (result event, exit, abort, recycle). */
|
|
73
|
-
outcome: Promise<TurnOutcome>;
|
|
74
|
-
/** Pull the next activity. Resolves null once the activity stream closes. */
|
|
75
|
-
next(): Promise<DriverActivity | null>;
|
|
76
|
-
/** Inject a synthetic activity (bridge inbox). No-op after settle. */
|
|
77
|
-
pushExternal(activity: DriverActivity): void;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface DriverSnapshot {
|
|
81
|
-
state: DriverState;
|
|
82
|
-
pid?: number;
|
|
83
|
-
conversationId?: string;
|
|
84
|
-
stats: {
|
|
85
|
-
spawns: number;
|
|
86
|
-
turns: number;
|
|
87
|
-
reused: number;
|
|
88
|
-
recycles: number;
|
|
89
|
-
lastRecycleReason?: string;
|
|
90
|
-
recycleReasons: Record<string, number>;
|
|
91
|
-
};
|
|
92
|
-
lifecycle: string[];
|
|
93
|
-
}
|
|
21
|
+
import type {
|
|
22
|
+
AgyUsage,
|
|
23
|
+
DriverActivity,
|
|
24
|
+
DriverProfile,
|
|
25
|
+
DriverSnapshot,
|
|
26
|
+
DriverState,
|
|
27
|
+
DriverTurnRequest,
|
|
28
|
+
TurnDriver,
|
|
29
|
+
TurnHandle,
|
|
30
|
+
TurnOutcome,
|
|
31
|
+
} from "./driver-types.js";
|
|
32
|
+
|
|
33
|
+
export type {
|
|
34
|
+
DriverActivity,
|
|
35
|
+
DriverProfile,
|
|
36
|
+
DriverSnapshot,
|
|
37
|
+
DriverState,
|
|
38
|
+
DriverTurnRequest,
|
|
39
|
+
TurnHandle,
|
|
40
|
+
TurnOutcome,
|
|
41
|
+
} from "./driver-types.js";
|
|
94
42
|
|
|
95
43
|
const LIFECYCLE_LIMIT = 24;
|
|
96
44
|
const THINKING_TOKEN_FLOOR = 64;
|
|
@@ -163,7 +111,18 @@ export function isCumulativeResend(accumulated: string, next: string): boolean {
|
|
|
163
111
|
return accumulated.length > 0 && next.length > accumulated.length && next.startsWith(accumulated);
|
|
164
112
|
}
|
|
165
113
|
|
|
166
|
-
|
|
114
|
+
/** Flip threshold for the same guard: short accumulations ("**", "#", "\n")
|
|
115
|
+
* are trivially extended by ordinary markdown deltas, and flipping on them
|
|
116
|
+
* corrupts every remaining frame of the turn. Require a respectable
|
|
117
|
+
* accumulation before believing a resend. Exported for tests. */
|
|
118
|
+
export const CUMULATIVE_FLIP_MIN_CHARS = 32;
|
|
119
|
+
|
|
120
|
+
/** Mode decision for the text-dedupe guard. Extracted for tests. */
|
|
121
|
+
export function shouldFlipToCumulative(accumulated: string, next: string): boolean {
|
|
122
|
+
return accumulated.length >= CUMULATIVE_FLIP_MIN_CHARS && isCumulativeResend(accumulated, next);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class AgyDriver implements TurnDriver {
|
|
167
126
|
#state: DriverState = "idle";
|
|
168
127
|
#child: ChildProcess | undefined;
|
|
169
128
|
#generation = 0;
|
|
@@ -173,6 +132,10 @@ export class AgyDriver {
|
|
|
173
132
|
#queueTail: Promise<void> = Promise.resolve();
|
|
174
133
|
#shutdown = false;
|
|
175
134
|
#stderrTail = "";
|
|
135
|
+
// Frames can split across pipe chunks; the trailing partial line lives here
|
|
136
|
+
// until its newline arrives (same scheme as JsonRpcSession.feed). Dropping
|
|
137
|
+
// it ate large tool frames and could hide the result frame of a turn.
|
|
138
|
+
#stdoutBuf = "";
|
|
176
139
|
#lifecycle: string[] = [];
|
|
177
140
|
#onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
|
|
178
141
|
#stats = {
|
|
@@ -373,7 +336,16 @@ export class AgyDriver {
|
|
|
373
336
|
windowsHide: true,
|
|
374
337
|
});
|
|
375
338
|
this.#child = child;
|
|
339
|
+
this.#stdoutBuf = "";
|
|
376
340
|
this.#stats.spawns += 1;
|
|
341
|
+
// Pipe write failures surface asynchronously as stream 'error' events;
|
|
342
|
+
// the sync try/catch around stdin.write cannot see them. Without this
|
|
343
|
+
// listener an EPIPE (agy died mid-write) is uncaught and kills pi.
|
|
344
|
+
child.stdin!.on("error", (err) => {
|
|
345
|
+
if (generation !== this.#generation) return;
|
|
346
|
+
const turn = this.#active;
|
|
347
|
+
if (turn && !turn.closed) this.#failTurn(turn, `agy stdin write failed: ${err.message}`);
|
|
348
|
+
});
|
|
377
349
|
this.#log(`spawn:${child.pid ?? "?"}:${request.conversationId ? "resume" : "fresh"}`);
|
|
378
350
|
this.#state = "ready";
|
|
379
351
|
|
|
@@ -420,10 +392,15 @@ export class AgyDriver {
|
|
|
420
392
|
}
|
|
421
393
|
|
|
422
394
|
#onStdout(chunk: string): void {
|
|
395
|
+
// Buffer the trailing partial line: a frame split across pipe chunks is
|
|
396
|
+
// reassembled when its newline arrives (mirrors JsonRpcSession.feed).
|
|
397
|
+
this.#stdoutBuf += chunk;
|
|
398
|
+
const lines = this.#stdoutBuf.split("\n");
|
|
399
|
+
this.#stdoutBuf = lines.pop() ?? "";
|
|
423
400
|
const turn = this.#active;
|
|
424
401
|
if (!turn || turn.closed) return;
|
|
425
402
|
if (turn.idleTimer) turn.idleTimer.refresh();
|
|
426
|
-
for (const line of
|
|
403
|
+
for (const line of lines) {
|
|
427
404
|
if (!line.trim()) continue;
|
|
428
405
|
this.#applyParsed(turn, parseAgyLine(line));
|
|
429
406
|
if (turn.closed) return;
|
|
@@ -525,13 +502,26 @@ export class AgyDriver {
|
|
|
525
502
|
#appendAgentText(turn: ActiveTurn, text: string): void {
|
|
526
503
|
// response_text is observed as a delta stream; guard against builds that
|
|
527
504
|
// resend the full text. A cumulative sender's second chunk CONTAINS
|
|
528
|
-
// everything accumulated so far as a prefix.
|
|
505
|
+
// everything accumulated so far as a prefix. Two guards against
|
|
506
|
+
// misflips (round-7 review): short accumulations never flip, and a
|
|
507
|
+
// cumulative frame that no longer extends the accumulator is evidence
|
|
508
|
+
// of a misflip - fall back to append mode.
|
|
529
509
|
if (turn.cumulativeText === undefined) {
|
|
530
510
|
turn.cumulativeText = false;
|
|
531
|
-
} else if (
|
|
511
|
+
} else if (
|
|
512
|
+
!turn.cumulativeText &&
|
|
513
|
+
shouldFlipToCumulative(turn.response, text)
|
|
514
|
+
) {
|
|
532
515
|
turn.cumulativeText = true;
|
|
533
516
|
}
|
|
534
517
|
if (turn.cumulativeText) {
|
|
518
|
+
if (!text.startsWith(turn.response)) {
|
|
519
|
+
// Misflip evidence: back to deltas.
|
|
520
|
+
turn.cumulativeText = false;
|
|
521
|
+
turn.response += text;
|
|
522
|
+
emit(turn, { type: "text", delta: text });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
535
525
|
if (text.length > turn.response.length) {
|
|
536
526
|
const delta = text.slice(turn.response.length);
|
|
537
527
|
turn.response = text;
|
|
@@ -613,6 +603,7 @@ export class AgyDriver {
|
|
|
613
603
|
if (!child) return;
|
|
614
604
|
this.#child = undefined;
|
|
615
605
|
this.#generation += 1;
|
|
606
|
+
this.#stdoutBuf = "";
|
|
616
607
|
try {
|
|
617
608
|
child.stdout?.removeAllListeners();
|
|
618
609
|
child.stderr?.removeAllListeners();
|
package/src/mcp-server.ts
CHANGED
|
@@ -37,11 +37,17 @@ import {
|
|
|
37
37
|
const SKIP_CIRCULAR = new Set(["AskAntigravity"]);
|
|
38
38
|
|
|
39
39
|
const BRIDGE_MCP_KEY = "pi-antigravity-bridge";
|
|
40
|
-
|
|
40
|
+
/** Shared-secret header every bridge request must carry. Exported: the ACP
|
|
41
|
+
* engine's mcpServers registration needs the same header name (the legacy
|
|
42
|
+
* engine gets it via .agents/mcp_config.json; ACP gets it via headers[]). */
|
|
43
|
+
export const TOKEN_HEADER = "x-bridge-token";
|
|
41
44
|
const MAX_BODY_BYTES = 1_000_000;
|
|
42
45
|
|
|
43
46
|
export interface McpServerHandle {
|
|
44
47
|
port: number;
|
|
48
|
+
/** Shared secret for TOKEN_HEADER. Callers that register the bridge with
|
|
49
|
+
* an engine other than the legacy stream-json discovery file need it. */
|
|
50
|
+
token: string;
|
|
45
51
|
close: () => Promise<void>;
|
|
46
52
|
}
|
|
47
53
|
|
|
@@ -409,6 +415,7 @@ export async function startMcpServer(
|
|
|
409
415
|
port,
|
|
410
416
|
handle: {
|
|
411
417
|
port,
|
|
418
|
+
token,
|
|
412
419
|
close: async () => {
|
|
413
420
|
await new Promise<void>((r) => httpServer.close(() => r()));
|
|
414
421
|
removeBridgeMcpConfig();
|
package/src/models.ts
CHANGED
|
@@ -273,8 +273,11 @@ function thinkingLevelMapFor(efforts: readonly AgyEffort[]): ThinkingLevelMap {
|
|
|
273
273
|
return map as ThinkingLevelMap;
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
/** Project an agy entry to pi's Model shape.
|
|
277
|
-
|
|
276
|
+
/** Project an agy entry to pi's Model shape. `input` advertises accepted
|
|
277
|
+
* inputs: text-only (default) or text+image. The ACP engine forwards image
|
|
278
|
+
* blocks natively (probe 2026-09-03); the legacy CLI prompt is text-only, so
|
|
279
|
+
* the extension decides by engine at load time. */
|
|
280
|
+
export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> = ["text"]): Model<Api> {
|
|
278
281
|
const effortDriven = !!entry.efforts && entry.efforts.length > 0;
|
|
279
282
|
return {
|
|
280
283
|
id: entry.id,
|
|
@@ -290,11 +293,10 @@ export function toPiModel(entry: AgyModelEntry): Model<Api> {
|
|
|
290
293
|
// changed and agy rejects --effort for them.
|
|
291
294
|
reasoning: effortDriven,
|
|
292
295
|
...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
input: ["text"],
|
|
296
|
+
// Input advertising comes from the caller (engine-dependent): the ACP
|
|
297
|
+
// engine forwards image blocks; advertising images on the legacy engine
|
|
298
|
+
// would let pi offer image attach only for them to be dropped.
|
|
299
|
+
input,
|
|
298
300
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
299
301
|
// Gemini long context. agy doesn't expose the real per-model window in
|
|
300
302
|
// `agy models`; 1M is the documented Gemini ceiling.
|
package/src/provider.ts
CHANGED
|
@@ -28,15 +28,16 @@ import {
|
|
|
28
28
|
type ThinkingLevel,
|
|
29
29
|
type Usage,
|
|
30
30
|
} from "@earendil-works/pi-ai";
|
|
31
|
+
import fs from "node:fs";
|
|
31
32
|
import type { Api } from "@earendil-works/pi-ai";
|
|
32
|
-
import {
|
|
33
|
+
import type { DriverActivity, TurnDriver, TurnHandle } from "./driver-types.js";
|
|
33
34
|
import { toPiUsage } from "./stream-events.js";
|
|
34
35
|
import { mapAgyToolToNative } from "./native-tools.js";
|
|
35
36
|
import { type AgyEffort, type AgyModelEntry } from "./models.js";
|
|
36
37
|
import { SessionStore } from "./sessions.js";
|
|
37
38
|
import { loadConfig } from "./config.js";
|
|
38
39
|
import path from "node:path";
|
|
39
|
-
import { TurnDiffContext, createExecGitOps, parseEditToolInput } from "./diff-render.js";
|
|
40
|
+
import { TurnDiffContext, createExecGitOps, formatInlineDiff, parseEditToolInput } from "./diff-render.js";
|
|
40
41
|
|
|
41
42
|
const DEFAULT_TIMEOUT_MIN = 10;
|
|
42
43
|
|
|
@@ -61,7 +62,7 @@ function extractUserPrompt(context: Context): string | null {
|
|
|
61
62
|
if (!last || last.role !== "user") return null;
|
|
62
63
|
const content = last.content;
|
|
63
64
|
if (typeof content === "string") return content;
|
|
64
|
-
// Flatten text blocks;
|
|
65
|
+
// Flatten text blocks; images ride separately via extractImages (ACP only).
|
|
65
66
|
return content
|
|
66
67
|
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
67
68
|
.map((b) => b.text)
|
|
@@ -69,6 +70,17 @@ function extractUserPrompt(context: Context): string | null {
|
|
|
69
70
|
.trim() || null;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** Image blocks of the latest user message (pi-ai ImageContent: base64 data
|
|
74
|
+
* + mimeType). The ACP engine forwards them as typed content blocks; the
|
|
75
|
+
* legacy CLI prompt is text-only, so its driver simply ignores these. */
|
|
76
|
+
function extractImages(context: Context): Array<{ data: string; mimeType: string }> {
|
|
77
|
+
const last = context.messages[context.messages.length - 1];
|
|
78
|
+
if (!last || last.role !== "user" || typeof last.content === "string") return [];
|
|
79
|
+
return last.content
|
|
80
|
+
.filter((b): b is { type: "image"; data: string; mimeType: string } => b.type === "image")
|
|
81
|
+
.map((b) => ({ data: b.data, mimeType: b.mimeType }));
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
// --- G1: pi-side context digest --------------------------------------------------
|
|
73
85
|
//
|
|
74
86
|
// agy keeps its OWN conversation history (resumed via --conversation), so it
|
|
@@ -264,9 +276,17 @@ function newAssistant(model: Model<Api>): AssistantMessage {
|
|
|
264
276
|
|
|
265
277
|
/** Session key: prefer pi's sessionId (stable per conversation), fall back to
|
|
266
278
|
* cwd so a single pi process still resumes correctly when sessionId is absent. */
|
|
267
|
-
function sessionKey(
|
|
279
|
+
function sessionKey(
|
|
280
|
+
options: SimpleStreamOptions | undefined,
|
|
281
|
+
cwd: string,
|
|
282
|
+
engine: "stream-json" | "acp",
|
|
283
|
+
): string {
|
|
268
284
|
const sid = (options as { sessionId?: string } | undefined)?.sessionId;
|
|
269
|
-
|
|
285
|
+
const base = sid && sid.length > 0 ? `sid:${sid}` : `cwd:${cwd}`;
|
|
286
|
+
// Engine-scoped keys (plan 9.4): one ACP turn must never touch the legacy
|
|
287
|
+
// binding and vice versa. Un-suffixed keys = legacy, byte-compatible with
|
|
288
|
+
// every store that predates the ACP engine.
|
|
289
|
+
return base + (engine === "acp" ? "@acp" : "");
|
|
270
290
|
}
|
|
271
291
|
|
|
272
292
|
/** Track which content block is currently open so we close-on-switch.
|
|
@@ -281,9 +301,13 @@ export interface BlockState {
|
|
|
281
301
|
export interface StreamSimpleDeps {
|
|
282
302
|
entries: AgyModelEntry[];
|
|
283
303
|
store: SessionStore;
|
|
284
|
-
/**
|
|
285
|
-
* calls park as toolUse round-trips. Required with
|
|
286
|
-
|
|
304
|
+
/** Legacy stream-json driver (the tested default engine). Turns run on the
|
|
305
|
+
* driver and bridge calls park as toolUse round-trips. Required with
|
|
306
|
+
* roundTrips. */
|
|
307
|
+
driver?: TurnDriver;
|
|
308
|
+
/** Official-server ACP engine. Opt-in via config.engine = "acp"; when
|
|
309
|
+
* absent the config switch falls back to the legacy driver. */
|
|
310
|
+
acpDriver?: TurnDriver;
|
|
287
311
|
roundTrips?: ToolRoundTrips;
|
|
288
312
|
/** Replay store for the display-only antigravity wrapper tool. Required
|
|
289
313
|
* for native re-exec and wrapper cards; without it tool steps render as
|
|
@@ -292,6 +316,10 @@ export interface StreamSimpleDeps {
|
|
|
292
316
|
/** Whether a pi tool is active in the session; native re-exec toolCalls
|
|
293
317
|
* are only emitted for active builtins (else the wrapper). */
|
|
294
318
|
nativeActive?: (name: string) => boolean;
|
|
319
|
+
/** Engine latched at extension load. When omitted, the per-call config
|
|
320
|
+
* read decides (tests); production wiring always passes it so a
|
|
321
|
+
* mid-session config flip cannot move one side of a parked turn. */
|
|
322
|
+
engine?: "stream-json" | "acp";
|
|
295
323
|
}
|
|
296
324
|
|
|
297
325
|
/** pi thinking-effort order mirrors agy's, for clamping. */
|
|
@@ -390,11 +418,13 @@ export class WrapperReplay {
|
|
|
390
418
|
|
|
391
419
|
export class ToolRoundTrips {
|
|
392
420
|
#pending = new Map<string, PendingRoundTrip>();
|
|
393
|
-
#
|
|
421
|
+
#getDriver: () => TurnDriver;
|
|
394
422
|
#log: (s: string, d?: unknown) => void;
|
|
395
423
|
|
|
396
|
-
|
|
397
|
-
|
|
424
|
+
/** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
|
|
425
|
+
* is resolved at call time from config (plan §9.5). */
|
|
426
|
+
constructor(driver: TurnDriver | (() => TurnDriver), log?: (s: string, d?: unknown) => void) {
|
|
427
|
+
this.#getDriver = typeof driver === "function" ? driver : () => driver;
|
|
398
428
|
this.#log = log ?? (() => {});
|
|
399
429
|
}
|
|
400
430
|
|
|
@@ -419,7 +449,7 @@ export class ToolRoundTrips {
|
|
|
419
449
|
clearTimeout(entry.timer);
|
|
420
450
|
if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
|
|
421
451
|
entry.reject!(new Error(reason));
|
|
422
|
-
this.#
|
|
452
|
+
this.#getDriver().kickIdle();
|
|
423
453
|
this.#log("round-trip-fail", { callId, name: entry.name, reason });
|
|
424
454
|
}
|
|
425
455
|
|
|
@@ -431,7 +461,7 @@ export class ToolRoundTrips {
|
|
|
431
461
|
args: Record<string, unknown>,
|
|
432
462
|
signal: AbortSignal,
|
|
433
463
|
): Promise<BridgeCallResultShape> => {
|
|
434
|
-
const handle = this.#
|
|
464
|
+
const handle = this.#getDriver().activeHandle;
|
|
435
465
|
if (!handle) {
|
|
436
466
|
return Promise.reject(
|
|
437
467
|
new Error(
|
|
@@ -469,7 +499,7 @@ export class ToolRoundTrips {
|
|
|
469
499
|
return true;
|
|
470
500
|
}
|
|
471
501
|
entry.resolve!({ content: [{ type: "text", text }], isError });
|
|
472
|
-
this.#
|
|
502
|
+
this.#getDriver().kickIdle();
|
|
473
503
|
this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
|
|
474
504
|
return true;
|
|
475
505
|
}
|
|
@@ -495,10 +525,12 @@ export function collectToolResults(
|
|
|
495
525
|
// --- stream-json engine -------------------------------------------------------
|
|
496
526
|
|
|
497
527
|
export interface DriverDeps {
|
|
498
|
-
driver:
|
|
528
|
+
driver: TurnDriver;
|
|
499
529
|
roundTrips: ToolRoundTrips;
|
|
500
530
|
replay?: WrapperReplay;
|
|
501
531
|
nativeActive?: (name: string) => boolean;
|
|
532
|
+
/** Active engine (config), for engine-scoped session keys. */
|
|
533
|
+
engine: "stream-json" | "acp";
|
|
502
534
|
}
|
|
503
535
|
|
|
504
536
|
/** Map one DriverActivity onto the open pi stream. Returns "parked" when the
|
|
@@ -507,6 +539,7 @@ export interface ActivityFeatures {
|
|
|
507
539
|
replay?: WrapperReplay;
|
|
508
540
|
nativeActive?: (name: string) => boolean;
|
|
509
541
|
roundTrips?: ToolRoundTrips;
|
|
542
|
+
engine?: "stream-json" | "acp";
|
|
510
543
|
}
|
|
511
544
|
|
|
512
545
|
/** Process-wide counter: round-trip ids must never repeat across turns in
|
|
@@ -537,6 +570,12 @@ function emitToolUse(
|
|
|
537
570
|
stream.end();
|
|
538
571
|
}
|
|
539
572
|
|
|
573
|
+
/** ACP edit-class tool names that carry a file-path arg and land the file on
|
|
574
|
+
* disk (observed live: edit_file, create_file). Read/execute tools also
|
|
575
|
+
* match the arg-shape heuristic but produce no diff (unchanged content), so
|
|
576
|
+
* the gate avoids mislabeling them as edits. */
|
|
577
|
+
const ACP_EDIT_TOOLS = new Set(["edit_file", "create_file", "write_to_file"]);
|
|
578
|
+
|
|
540
579
|
export function consumeActivity(
|
|
541
580
|
stream: AssistantMessageEventStream,
|
|
542
581
|
blocks: BlockState,
|
|
@@ -551,7 +590,11 @@ export function consumeActivity(
|
|
|
551
590
|
appendText(stream, blocks, activity.delta);
|
|
552
591
|
return "continue";
|
|
553
592
|
case "thought":
|
|
554
|
-
//
|
|
593
|
+
// Legacy: token count only (no body). ACP: thought TEXT deltas —
|
|
594
|
+
// rendered through the same thinking block pipeline (9.2).
|
|
595
|
+
if (typeof activity.delta === "string" && activity.delta.length > 0) {
|
|
596
|
+
appendThinking(stream, blocks, activity.delta);
|
|
597
|
+
}
|
|
555
598
|
return "continue";
|
|
556
599
|
case "usage":
|
|
557
600
|
toPiUsage(activity.usage, partial.usage);
|
|
@@ -559,8 +602,51 @@ export function consumeActivity(
|
|
|
559
602
|
case "tool_start":
|
|
560
603
|
// Rendering happens on completion (output/diff available).
|
|
561
604
|
return "continue";
|
|
605
|
+
/** File-path argument of an ACP edit tool (observed: `file_path`); other
|
|
606
|
+
* tool kinds (execute, read) don't carry one. Returns undefined for
|
|
607
|
+
* non-edit tools. */
|
|
608
|
+
function acpEditFileArg(args: Record<string, unknown>): string | undefined {
|
|
609
|
+
for (const [k, v] of Object.entries(args)) {
|
|
610
|
+
if (typeof v === "string" && v.trim() && /file|path/i.test(k)) return v;
|
|
611
|
+
}
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
|
|
562
615
|
case "tool_done": {
|
|
563
|
-
//
|
|
616
|
+
// Gate C (ACP): the server already executed the tool; nothing parks.
|
|
617
|
+
// Edit display has TWO paths on ACP:
|
|
618
|
+
// a) native diff in tool_call content[] (future builds / the
|
|
619
|
+
// permission-request flow carries it; phase-2 probe shape);
|
|
620
|
+
// b) RC01 with the auto policy sends NO content: the file simply
|
|
621
|
+
// lands on disk. Read it and diff against git HEAD - the same
|
|
622
|
+
// output the stream-json engine produces, one readFileSync.
|
|
623
|
+
if (feats.engine === "acp") {
|
|
624
|
+
const d = activity.diff;
|
|
625
|
+
if (d) {
|
|
626
|
+
appendThinking(stream, blocks, `[agy edit: ${path.basename(d.path)}]\n`);
|
|
627
|
+
const diffText = formatInlineDiff(d.oldText ?? "", d.newText);
|
|
628
|
+
if (diffText) appendThinking(stream, blocks, `${diffText}\n`);
|
|
629
|
+
return "continue";
|
|
630
|
+
}
|
|
631
|
+
const editFile = ACP_EDIT_TOOLS.has(activity.name) ? acpEditFileArg(activity.args) : undefined;
|
|
632
|
+
if (editFile) {
|
|
633
|
+
const absFile = path.isAbsolute(editFile) ? editFile : path.resolve(cwd, editFile);
|
|
634
|
+
appendThinking(stream, blocks, `[agy edit: ${path.basename(absFile)}]\n`);
|
|
635
|
+
let disk = "";
|
|
636
|
+
try {
|
|
637
|
+
disk = fs.readFileSync(absFile, "utf8");
|
|
638
|
+
} catch {
|
|
639
|
+
/* deleted or unreadable: diffEdit degrades to a summary */
|
|
640
|
+
}
|
|
641
|
+
const outcome = diffCtx.diffEdit(absFile, disk);
|
|
642
|
+
if (outcome.text) appendThinking(stream, blocks, `${outcome.text}\n`);
|
|
643
|
+
return "continue";
|
|
644
|
+
}
|
|
645
|
+
appendThinking(stream, blocks, `[agy tool: ${activity.name}]\n`);
|
|
646
|
+
return "continue";
|
|
647
|
+
}
|
|
648
|
+
// G8 (stream-json): agy file edits surface a git-sourced diff in a
|
|
649
|
+
// thinking block. The server sends no diff here, so OLD comes from git.
|
|
564
650
|
let inputJson: string | undefined;
|
|
565
651
|
try {
|
|
566
652
|
inputJson = JSON.stringify(activity.args);
|
|
@@ -629,7 +715,7 @@ async function runTurnDriver(
|
|
|
629
715
|
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
630
716
|
|
|
631
717
|
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
632
|
-
const key = sessionKey(options, cwd);
|
|
718
|
+
const key = sessionKey(options, cwd, deps.engine);
|
|
633
719
|
const existing = store.get(key);
|
|
634
720
|
const messageCount = context.messages.length;
|
|
635
721
|
const config = loadConfig();
|
|
@@ -651,7 +737,10 @@ async function runTurnDriver(
|
|
|
651
737
|
handle = active;
|
|
652
738
|
} else {
|
|
653
739
|
const prompt = extractUserPrompt(context);
|
|
654
|
-
|
|
740
|
+
const images = extractImages(context);
|
|
741
|
+
// An image-only message (no text) is valid on the ACP engine; only fail
|
|
742
|
+
// when there is nothing at all to send.
|
|
743
|
+
if (!prompt && images.length === 0) {
|
|
655
744
|
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
656
745
|
return;
|
|
657
746
|
}
|
|
@@ -660,11 +749,20 @@ async function runTurnDriver(
|
|
|
660
749
|
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
661
750
|
const watermark = existing?.lastMessageCount ?? 0;
|
|
662
751
|
const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
|
|
752
|
+
// G1 delivery per engine. stream-json: digest rides inline in the prompt
|
|
753
|
+
// (the CLI has no context channel). ACP: the server advertises
|
|
754
|
+
// `embeddedContext`, so the digest ships as a native resource block
|
|
755
|
+
// instead of prompt text (plan phase 3). The preamble framing goes INTO
|
|
756
|
+
// the block: an unlabeled blob of other-agent turns is a mild injection
|
|
757
|
+
// surface, and the model needs the use-for-continuity-only instruction.
|
|
758
|
+
// The uri is suffixed per turn so a deduping server cannot serve stale
|
|
759
|
+
// content on turn 2+.
|
|
760
|
+
const embeddedDigest = deps.engine === "acp" && digest ? digest : undefined;
|
|
663
761
|
// Fresh conversation only: agy stores the block in its own history, so
|
|
664
762
|
// re-sending it every turn would bloat each prompt and bust the cache.
|
|
665
763
|
const sysPrompt =
|
|
666
764
|
config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
|
|
667
|
-
const fullPrompt = buildFullPrompt(sysPrompt, digest, prompt);
|
|
765
|
+
const fullPrompt = buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
|
|
668
766
|
try {
|
|
669
767
|
handle = await deps.driver.run({
|
|
670
768
|
cwd,
|
|
@@ -674,6 +772,13 @@ async function runTurnDriver(
|
|
|
674
772
|
skipPermissions: config.skipPermissions,
|
|
675
773
|
conversationId: existing?.conversationId ?? null,
|
|
676
774
|
prompt: fullPrompt,
|
|
775
|
+
images: images.length > 0 ? images : undefined,
|
|
776
|
+
contextBlock: embeddedDigest
|
|
777
|
+
? {
|
|
778
|
+
uri: `urn:pi-bridge:context-digest/${messageCount}`,
|
|
779
|
+
text: `${DIGEST_PREAMBLE}\n\n${embeddedDigest}`,
|
|
780
|
+
}
|
|
781
|
+
: undefined,
|
|
677
782
|
signal: options?.signal,
|
|
678
783
|
});
|
|
679
784
|
} catch (err) {
|
|
@@ -689,6 +794,7 @@ async function runTurnDriver(
|
|
|
689
794
|
replay: deps.replay,
|
|
690
795
|
nativeActive: deps.nativeActive,
|
|
691
796
|
roundTrips: deps.roundTrips,
|
|
797
|
+
engine: deps.engine,
|
|
692
798
|
};
|
|
693
799
|
|
|
694
800
|
for (;;) {
|
|
@@ -729,17 +835,36 @@ async function runTurnDriver(
|
|
|
729
835
|
export function createStreamSimple(
|
|
730
836
|
deps: StreamSimpleDeps,
|
|
731
837
|
): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
|
|
732
|
-
const { entries, store,
|
|
838
|
+
const { entries, store, roundTrips } = deps;
|
|
733
839
|
|
|
734
840
|
return function streamSimple(model, context, options) {
|
|
735
841
|
const stream = createAssistantMessageEventStream();
|
|
736
842
|
// Fire the async turn; return the stream synchronously per pi's contract.
|
|
737
|
-
|
|
843
|
+
// Engine selection: the latched load-time engine when the extension
|
|
844
|
+
// provides one, else the per-call config read (tests). One loadConfig()
|
|
845
|
+
// read per turn, shared with the session key below.
|
|
846
|
+
const config = loadConfig();
|
|
847
|
+
const engine = deps.engine ?? config.engine;
|
|
848
|
+
const selected = engine === "acp" && deps.acpDriver ? deps.acpDriver : deps.driver;
|
|
849
|
+
// RC01: ACP modes are permission modes, there is no review-only plan.
|
|
850
|
+
// A plan turn on ACP would silently run non-plan; fail visibly instead.
|
|
851
|
+
if (selected === deps.acpDriver && config.mode === "plan") {
|
|
852
|
+
const partial = newAssistant(model);
|
|
853
|
+
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
854
|
+
finalize(stream, blocks, "error", "ACP engine has no plan mode. /agy mode accept-edits, or /agy engine stream-json.");
|
|
855
|
+
return stream;
|
|
856
|
+
}
|
|
857
|
+
if (selected && roundTrips) {
|
|
738
858
|
void runTurnDriver(stream, model, context, options, entries, store, {
|
|
739
|
-
driver,
|
|
859
|
+
driver: selected,
|
|
740
860
|
roundTrips,
|
|
741
861
|
replay: deps.replay,
|
|
742
862
|
nativeActive: deps.nativeActive,
|
|
863
|
+
// Record the engine of the driver that will ACTUALLY run: if the
|
|
864
|
+
// ACP driver is absent, the config switch falls back to legacy,
|
|
865
|
+
// and keying the session as @acp would store a legacy
|
|
866
|
+
// conversationId under the wrong engine scope.
|
|
867
|
+
engine: selected === deps.acpDriver ? "acp" : "stream-json",
|
|
743
868
|
});
|
|
744
869
|
} else {
|
|
745
870
|
// Miswired extension: no driver means no engine. Fail the turn visibly
|