@estebanforge/pi-antigravity-bridge 1.3.2 → 1.4.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 +49 -0
- package/README.md +25 -4
- 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 -134
- package/extensions/index.ts +141 -19
- package/package.json +8 -2
- package/src/acp/connection.ts +395 -0
- package/src/acp/driver.ts +719 -0
- package/src/acp/events.ts +250 -0
- package/src/acp/jsonrpc.ts +185 -0
- package/src/config.ts +50 -0
- package/src/diff-render.ts +15 -0
- package/src/driver-types.ts +144 -0
- package/src/driver.ts +49 -77
- package/src/mcp-server.ts +8 -1
- package/src/models.ts +9 -7
- package/src/provider.ts +181 -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;
|
|
@@ -525,13 +484,26 @@ export class AgyDriver {
|
|
|
525
484
|
#appendAgentText(turn: ActiveTurn, text: string): void {
|
|
526
485
|
// response_text is observed as a delta stream; guard against builds that
|
|
527
486
|
// resend the full text. A cumulative sender's second chunk CONTAINS
|
|
528
|
-
// everything accumulated so far as a prefix.
|
|
487
|
+
// everything accumulated so far as a prefix. Two guards against
|
|
488
|
+
// misflips (round-7 review): short accumulations never flip, and a
|
|
489
|
+
// cumulative frame that no longer extends the accumulator is evidence
|
|
490
|
+
// of a misflip - fall back to append mode.
|
|
529
491
|
if (turn.cumulativeText === undefined) {
|
|
530
492
|
turn.cumulativeText = false;
|
|
531
|
-
} else if (
|
|
493
|
+
} else if (
|
|
494
|
+
!turn.cumulativeText &&
|
|
495
|
+
shouldFlipToCumulative(turn.response, text)
|
|
496
|
+
) {
|
|
532
497
|
turn.cumulativeText = true;
|
|
533
498
|
}
|
|
534
499
|
if (turn.cumulativeText) {
|
|
500
|
+
if (!text.startsWith(turn.response)) {
|
|
501
|
+
// Misflip evidence: back to deltas.
|
|
502
|
+
turn.cumulativeText = false;
|
|
503
|
+
turn.response += text;
|
|
504
|
+
emit(turn, { type: "text", delta: text });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
535
507
|
if (text.length > turn.response.length) {
|
|
536
508
|
const delta = text.slice(turn.response.length);
|
|
537
509
|
turn.response = text;
|
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
|
|
@@ -84,6 +96,43 @@ const COMPACTION_MARKER = "compacted into the following summary";
|
|
|
84
96
|
const DIGEST_PREAMBLE =
|
|
85
97
|
"[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.]";
|
|
86
98
|
|
|
99
|
+
// --- pi system prompt (G10) ----------------------------------------------------
|
|
100
|
+
//
|
|
101
|
+
// pi composes context.systemPrompt every turn: its own operating instructions
|
|
102
|
+
// plus every AGENTS.md/CLAUDE.md it loaded (global agent dir first, then
|
|
103
|
+
// ancestors). The provider used to drop it, so agy models never saw the user's
|
|
104
|
+
// machine-level or project-level instructions. agy has no system-prompt flag
|
|
105
|
+
// (verified against `agy --help`), so the only delivery path is the prompt
|
|
106
|
+
// text. We prepend it as a delimited block on the FIRST prompt of a fresh
|
|
107
|
+
// conversation only: agy keeps its own history, the block stays byte-identical
|
|
108
|
+
// afterwards, and agy's server-side prompt cache keeps hitting.
|
|
109
|
+
|
|
110
|
+
export const SYSTEM_PROMPT_PREAMBLE =
|
|
111
|
+
"[The following is the system prompt of the pi session that spawned this conversation: operating instructions plus project context (AGENTS.md files). Apply it for this whole conversation. Tool guidance may reference pi-side tools; use your own tools or the pi tool bridge for those actions.]";
|
|
112
|
+
|
|
113
|
+
export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
|
|
114
|
+
|
|
115
|
+
/** Assemble the full agy prompt: system prompt block, pi-side digest, user
|
|
116
|
+
* prompt. Empty parts are dropped. Pure; exported for unit testing.
|
|
117
|
+
* Pass systemPrompt only on a fresh conversation (see runTurnDriver). */
|
|
118
|
+
export function buildFullPrompt(
|
|
119
|
+
systemPrompt: string | undefined,
|
|
120
|
+
digest: string,
|
|
121
|
+
prompt: string,
|
|
122
|
+
): string {
|
|
123
|
+
const parts: string[] = [];
|
|
124
|
+
if (systemPrompt) {
|
|
125
|
+
parts.push(`${SYSTEM_PROMPT_PREAMBLE}\n\n${systemPrompt}\n\n${SYSTEM_PROMPT_END}`);
|
|
126
|
+
}
|
|
127
|
+
if (digest) {
|
|
128
|
+
parts.push(`${DIGEST_PREAMBLE}\n\n${digest}`);
|
|
129
|
+
}
|
|
130
|
+
if (prompt) {
|
|
131
|
+
parts.push(prompt);
|
|
132
|
+
}
|
|
133
|
+
return parts.join("\n\n---\n\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
87
136
|
/** Flatten any message content shape (string or content-block array) to text.
|
|
88
137
|
* Drops images, thinking, and tool-call blocks. */
|
|
89
138
|
function blocksToText(content: unknown): string {
|
|
@@ -227,9 +276,17 @@ function newAssistant(model: Model<Api>): AssistantMessage {
|
|
|
227
276
|
|
|
228
277
|
/** Session key: prefer pi's sessionId (stable per conversation), fall back to
|
|
229
278
|
* cwd so a single pi process still resumes correctly when sessionId is absent. */
|
|
230
|
-
function sessionKey(
|
|
279
|
+
function sessionKey(
|
|
280
|
+
options: SimpleStreamOptions | undefined,
|
|
281
|
+
cwd: string,
|
|
282
|
+
engine: "stream-json" | "acp",
|
|
283
|
+
): string {
|
|
231
284
|
const sid = (options as { sessionId?: string } | undefined)?.sessionId;
|
|
232
|
-
|
|
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" : "");
|
|
233
290
|
}
|
|
234
291
|
|
|
235
292
|
/** Track which content block is currently open so we close-on-switch.
|
|
@@ -244,9 +301,13 @@ export interface BlockState {
|
|
|
244
301
|
export interface StreamSimpleDeps {
|
|
245
302
|
entries: AgyModelEntry[];
|
|
246
303
|
store: SessionStore;
|
|
247
|
-
/**
|
|
248
|
-
* calls park as toolUse round-trips. Required with
|
|
249
|
-
|
|
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;
|
|
250
311
|
roundTrips?: ToolRoundTrips;
|
|
251
312
|
/** Replay store for the display-only antigravity wrapper tool. Required
|
|
252
313
|
* for native re-exec and wrapper cards; without it tool steps render as
|
|
@@ -255,6 +316,10 @@ export interface StreamSimpleDeps {
|
|
|
255
316
|
/** Whether a pi tool is active in the session; native re-exec toolCalls
|
|
256
317
|
* are only emitted for active builtins (else the wrapper). */
|
|
257
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";
|
|
258
323
|
}
|
|
259
324
|
|
|
260
325
|
/** pi thinking-effort order mirrors agy's, for clamping. */
|
|
@@ -353,11 +418,13 @@ export class WrapperReplay {
|
|
|
353
418
|
|
|
354
419
|
export class ToolRoundTrips {
|
|
355
420
|
#pending = new Map<string, PendingRoundTrip>();
|
|
356
|
-
#
|
|
421
|
+
#getDriver: () => TurnDriver;
|
|
357
422
|
#log: (s: string, d?: unknown) => void;
|
|
358
423
|
|
|
359
|
-
|
|
360
|
-
|
|
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;
|
|
361
428
|
this.#log = log ?? (() => {});
|
|
362
429
|
}
|
|
363
430
|
|
|
@@ -382,7 +449,7 @@ export class ToolRoundTrips {
|
|
|
382
449
|
clearTimeout(entry.timer);
|
|
383
450
|
if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
|
|
384
451
|
entry.reject!(new Error(reason));
|
|
385
|
-
this.#
|
|
452
|
+
this.#getDriver().kickIdle();
|
|
386
453
|
this.#log("round-trip-fail", { callId, name: entry.name, reason });
|
|
387
454
|
}
|
|
388
455
|
|
|
@@ -394,7 +461,7 @@ export class ToolRoundTrips {
|
|
|
394
461
|
args: Record<string, unknown>,
|
|
395
462
|
signal: AbortSignal,
|
|
396
463
|
): Promise<BridgeCallResultShape> => {
|
|
397
|
-
const handle = this.#
|
|
464
|
+
const handle = this.#getDriver().activeHandle;
|
|
398
465
|
if (!handle) {
|
|
399
466
|
return Promise.reject(
|
|
400
467
|
new Error(
|
|
@@ -432,7 +499,7 @@ export class ToolRoundTrips {
|
|
|
432
499
|
return true;
|
|
433
500
|
}
|
|
434
501
|
entry.resolve!({ content: [{ type: "text", text }], isError });
|
|
435
|
-
this.#
|
|
502
|
+
this.#getDriver().kickIdle();
|
|
436
503
|
this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
|
|
437
504
|
return true;
|
|
438
505
|
}
|
|
@@ -458,10 +525,12 @@ export function collectToolResults(
|
|
|
458
525
|
// --- stream-json engine -------------------------------------------------------
|
|
459
526
|
|
|
460
527
|
export interface DriverDeps {
|
|
461
|
-
driver:
|
|
528
|
+
driver: TurnDriver;
|
|
462
529
|
roundTrips: ToolRoundTrips;
|
|
463
530
|
replay?: WrapperReplay;
|
|
464
531
|
nativeActive?: (name: string) => boolean;
|
|
532
|
+
/** Active engine (config), for engine-scoped session keys. */
|
|
533
|
+
engine: "stream-json" | "acp";
|
|
465
534
|
}
|
|
466
535
|
|
|
467
536
|
/** Map one DriverActivity onto the open pi stream. Returns "parked" when the
|
|
@@ -470,6 +539,7 @@ export interface ActivityFeatures {
|
|
|
470
539
|
replay?: WrapperReplay;
|
|
471
540
|
nativeActive?: (name: string) => boolean;
|
|
472
541
|
roundTrips?: ToolRoundTrips;
|
|
542
|
+
engine?: "stream-json" | "acp";
|
|
473
543
|
}
|
|
474
544
|
|
|
475
545
|
/** Process-wide counter: round-trip ids must never repeat across turns in
|
|
@@ -500,6 +570,12 @@ function emitToolUse(
|
|
|
500
570
|
stream.end();
|
|
501
571
|
}
|
|
502
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
|
+
|
|
503
579
|
export function consumeActivity(
|
|
504
580
|
stream: AssistantMessageEventStream,
|
|
505
581
|
blocks: BlockState,
|
|
@@ -514,7 +590,11 @@ export function consumeActivity(
|
|
|
514
590
|
appendText(stream, blocks, activity.delta);
|
|
515
591
|
return "continue";
|
|
516
592
|
case "thought":
|
|
517
|
-
//
|
|
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
|
+
}
|
|
518
598
|
return "continue";
|
|
519
599
|
case "usage":
|
|
520
600
|
toPiUsage(activity.usage, partial.usage);
|
|
@@ -522,8 +602,51 @@ export function consumeActivity(
|
|
|
522
602
|
case "tool_start":
|
|
523
603
|
// Rendering happens on completion (output/diff available).
|
|
524
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
|
+
|
|
525
615
|
case "tool_done": {
|
|
526
|
-
//
|
|
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.
|
|
527
650
|
let inputJson: string | undefined;
|
|
528
651
|
try {
|
|
529
652
|
inputJson = JSON.stringify(activity.args);
|
|
@@ -592,7 +715,7 @@ async function runTurnDriver(
|
|
|
592
715
|
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
593
716
|
|
|
594
717
|
const cwd = (options as { cwd?: string } | undefined)?.cwd ?? process.cwd();
|
|
595
|
-
const key = sessionKey(options, cwd);
|
|
718
|
+
const key = sessionKey(options, cwd, deps.engine);
|
|
596
719
|
const existing = store.get(key);
|
|
597
720
|
const messageCount = context.messages.length;
|
|
598
721
|
const config = loadConfig();
|
|
@@ -614,7 +737,10 @@ async function runTurnDriver(
|
|
|
614
737
|
handle = active;
|
|
615
738
|
} else {
|
|
616
739
|
const prompt = extractUserPrompt(context);
|
|
617
|
-
|
|
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) {
|
|
618
744
|
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
619
745
|
return;
|
|
620
746
|
}
|
|
@@ -623,7 +749,20 @@ async function runTurnDriver(
|
|
|
623
749
|
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
624
750
|
const watermark = existing?.lastMessageCount ?? 0;
|
|
625
751
|
const digest = config.digest ? buildContextDigest(context.messages, watermark) : "";
|
|
626
|
-
|
|
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;
|
|
761
|
+
// Fresh conversation only: agy stores the block in its own history, so
|
|
762
|
+
// re-sending it every turn would bloat each prompt and bust the cache.
|
|
763
|
+
const sysPrompt =
|
|
764
|
+
config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
|
|
765
|
+
const fullPrompt = buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
|
|
627
766
|
try {
|
|
628
767
|
handle = await deps.driver.run({
|
|
629
768
|
cwd,
|
|
@@ -633,6 +772,13 @@ async function runTurnDriver(
|
|
|
633
772
|
skipPermissions: config.skipPermissions,
|
|
634
773
|
conversationId: existing?.conversationId ?? null,
|
|
635
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,
|
|
636
782
|
signal: options?.signal,
|
|
637
783
|
});
|
|
638
784
|
} catch (err) {
|
|
@@ -648,6 +794,7 @@ async function runTurnDriver(
|
|
|
648
794
|
replay: deps.replay,
|
|
649
795
|
nativeActive: deps.nativeActive,
|
|
650
796
|
roundTrips: deps.roundTrips,
|
|
797
|
+
engine: deps.engine,
|
|
651
798
|
};
|
|
652
799
|
|
|
653
800
|
for (;;) {
|
|
@@ -688,17 +835,28 @@ async function runTurnDriver(
|
|
|
688
835
|
export function createStreamSimple(
|
|
689
836
|
deps: StreamSimpleDeps,
|
|
690
837
|
): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
|
|
691
|
-
const { entries, store,
|
|
838
|
+
const { entries, store, roundTrips } = deps;
|
|
692
839
|
|
|
693
840
|
return function streamSimple(model, context, options) {
|
|
694
841
|
const stream = createAssistantMessageEventStream();
|
|
695
842
|
// Fire the async turn; return the stream synchronously per pi's contract.
|
|
696
|
-
|
|
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
|
+
if (selected && roundTrips) {
|
|
697
850
|
void runTurnDriver(stream, model, context, options, entries, store, {
|
|
698
|
-
driver,
|
|
851
|
+
driver: selected,
|
|
699
852
|
roundTrips,
|
|
700
853
|
replay: deps.replay,
|
|
701
854
|
nativeActive: deps.nativeActive,
|
|
855
|
+
// Record the engine of the driver that will ACTUALLY run: if the
|
|
856
|
+
// ACP driver is absent, the config switch falls back to legacy,
|
|
857
|
+
// and keying the session as @acp would store a legacy
|
|
858
|
+
// conversationId under the wrong engine scope.
|
|
859
|
+
engine: selected === deps.acpDriver ? "acp" : "stream-json",
|
|
702
860
|
});
|
|
703
861
|
} else {
|
|
704
862
|
// Miswired extension: no driver means no engine. Fail the turn visibly
|