@estebanforge/pi-antigravity-bridge 1.4.7 → 1.4.8
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 +19 -0
- package/README.md +3 -1
- package/docs/ACP-PROTOCOL-REFERENCE.md +1 -0
- package/extensions/index.ts +37 -2
- package/package.json +1 -1
- package/src/mcp-server.ts +8 -0
- package/src/provider.ts +275 -15
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.8] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Early-ack + poll for long bridge calls. agy's MCP client abandons a `tools/call` request at a flat ~180s (observed twice at exactly 180.000s), so any pi tool that ran longer died with "agy disconnected before the tool result arrived": the 225.7s `AskClaude` peer review that exposed it never reached agy, and agy salvaged its turn without the result. Now a call still running after ~20 seconds settles the HTTP request with a `STILL RUNNING` answer carrying a `callId`, and the new bridge-local `bridge_poll_result` tool returns the result when it lands (or "still running" on the way). pi keeps executing the whole time; fast calls never see any of this. An escalated park re-arms its own timeout to 30 minutes, so human-gated tools (commit previews, permission dialogs) can take as long as the human takes.
|
|
10
|
+
- Late tool-result delivery as a backstop: a park that does fail (abort, timeout, recycle) leaves a bounded tombstone, and when the toolResult arrives anyway the provider re-routes it to agy as a new prompt in the same conversation ("Late tool delivery: ...") instead of erroring the turn. A late result that lands while another park still anchors the pass is deferred to the next pass (`late-result-deferred`), never dropped.
|
|
11
|
+
- A `progress-token` probe in the bridge server: if agy's requests ever carry `_meta.progressToken`, MCP progress notifications become a testable zero-UX fix for the deadline. The exact-180s signature says it likely never fires; one log line settles it.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- A toolResult whose park already died no longer misclassifies the turn as "No user message to send to agy." That was the second half of the incident, and it turned a recoverable late delivery into a hard error.
|
|
16
|
+
- `failAll` (fired on every turn end, OK turns included) no longer marks escalated calls failed: an escalated call outlives its agy turn by design, and the poll handle must not lie about a still-running tool (peer-review blocker).
|
|
17
|
+
- `EscalationRegistry` eviction can no longer strand a running call: only settled entries evict, so a saturated cap grows instead of losing an in-flight result.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- The tool-priority note now also teaches the poll pattern: long bridge calls answer `STILL RUNNING` + `bridge_poll_result`, and work that is known-long should use `exec_command`'s session output or background agents.
|
|
22
|
+
- New daily-log events: `call-tool-escalated`, `poll-tool`, `late-result` (with a `freshConversation` flag), `late-result-deferred`, `progress-token`. Docs: README bridge section, ACP-PROTOCOL-REFERENCE timing table.
|
|
23
|
+
|
|
5
24
|
## [1.4.7] - 2026-09-05
|
|
6
25
|
|
|
7
26
|
### Added
|
package/README.md
CHANGED
|
@@ -68,6 +68,8 @@ The bridge starts a localhost MCP server inside pi's process. `tools/list` retur
|
|
|
68
68
|
|
|
69
69
|
**No patch required.** Bridge calls park in the provider's round-trip store; the provider ends the pi assistant message with a `toolUse` stop reason for the real pi tool, pi executes it in its own loop (native cards, permissions, hooks), and the toolResult completes the parked MCP response on the next stream call. This is the same mechanism tianzuo/pi-antigravity uses; upstream pi APIs only.
|
|
70
70
|
|
|
71
|
+
**Long calls don't die.** agy's MCP client abandons a `tools/call` request at a flat ~180s, which used to kill any pi tool that ran longer (a long peer review, a build, a commit preview waiting for you). A call still running after ~20 seconds now settles its HTTP request with a `STILL RUNNING` answer carrying a `callId` while pi keeps executing; agy fetches the result through the bridge-local `bridge_poll_result` tool and polls until it lands. Escalated calls get their own 30-minute budget, so human-gated tools can take as long as the human takes. Fast calls stay fully synchronous and never see any of this. If a park does fail (abort, timeout, recycle), the late result is re-routed to agy as a follow-up prompt in the same conversation instead of being lost.
|
|
72
|
+
|
|
71
73
|
**Recursion safety.** Only the provider's agy receives the extra `--add-dir`. The `AskAntigravity` tool spawns its own agy with just the workspace, so that inner agy starts plain (no pi tools) and cannot re-enter. `AskAntigravity` is also filtered from the exposed tool list. Standalone agy is unaffected because nothing is written to its global config.
|
|
72
74
|
|
|
73
75
|
**Cost / fan-out.** Every registered pi tool except builtins (and `AskAntigravity`) is exposed, including other delegation tools like `AskClaude`/`AskCodex`. agy can therefore chain into other models via the bridge, which is a new cost/time fan-out vector that did not exist before this feature.
|
|
@@ -196,7 +198,7 @@ The extension keeps a daily log on your machine, sorted by day:
|
|
|
196
198
|
~/.pi/extensions-data/estebanforge/pi-antigravity-bridge/logs/<YYYY-MM-DD>.ndjson
|
|
197
199
|
```
|
|
198
200
|
|
|
199
|
-
One JSON record per line. Two verbosity tiers keep the disk cost negligible for regular users: by default only `info`/`warn`/`error` records land on disk, which is the useful skeleton: turn starts and outcomes with error text (both engines), bridge tool calls and round-trip failures, `AskAntigravity` runs, `/agy` commands, ACP setup/self-heal, auth URLs, and every driver failure (stall, abort, timeout, nonzero exit). Set `AGY_DEBUG=1` before reproducing a problem for the full trail: per-event driver lifecycle (spawn, exit, session load/new, unparks), list-tools traffic, recycle causes, and the raw bridge chatter. `/agy doctor` prints the log directory.
|
|
201
|
+
One JSON record per line. Two verbosity tiers keep the disk cost negligible for regular users: by default only `info`/`warn`/`error` records land on disk, which is the useful skeleton: turn starts and outcomes with error text (both engines), bridge tool calls, escalations and poll traffic, late deliveries, and round-trip failures, `AskAntigravity` runs, `/agy` commands, ACP setup/self-heal, auth URLs, and every driver failure (stall, abort, timeout, nonzero exit). Set `AGY_DEBUG=1` before reproducing a problem for the full trail: per-event driver lifecycle (spawn, exit, session load/new, unparks), list-tools traffic, recycle causes, and the raw bridge chatter. `/agy doctor` prints the log directory.
|
|
200
202
|
|
|
201
203
|
Notes:
|
|
202
204
|
|
|
@@ -349,6 +349,7 @@ or `terminal/*` delegation occurred with capabilities off.
|
|
|
349
349
|
| set_config_option response | < 1 s |
|
|
350
350
|
| Prompt first chunk | ~1-2 s (Flash, low effort) |
|
|
351
351
|
| OAuth onboarding window | minutes-scale; one timeout observed at ~8.5 min |
|
|
352
|
+
| Bridge `tools/call` HTTP request | agy's MCP client abandons the request at ~180s (observed twice at exactly 180.000s on 2026-09-05: AskClaude at 225.7s, then a follow-up exec_command). Mitigations: the bridge early-acks any call still running after ~20s with a poll handle (`bridge_poll_result`, `call-tool-escalated` log event) so the request never reaches the deadline; escalated parks re-arm at 30 min for human-gated latency; a result that outlives polling is re-delivered as a new same-conversation prompt (late delivery, `late-result` log event). `mcp-server` logs `progress-token` when a request carries `_meta.progressToken`: if agy ever sends one, progress notifications become a testable zero-UX fix |
|
|
352
353
|
| Steady RSS | ~327 MB (5 min mixed load; VSZ ~5.3 GB is TCMalloc reservation) |
|
|
353
354
|
|
|
354
355
|
## Run 6 findings (2026-09-03, post-restart session; raw traffic
|
package/extensions/index.ts
CHANGED
|
@@ -37,7 +37,14 @@ import {
|
|
|
37
37
|
type AgyModelEntry,
|
|
38
38
|
} from "../src/models.js";
|
|
39
39
|
import { SessionStore } from "../src/sessions.js";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
POLL_TOOL_NAME,
|
|
42
|
+
ToolRoundTrips,
|
|
43
|
+
WrapperReplay,
|
|
44
|
+
createStreamSimple,
|
|
45
|
+
formatEscalatedAck,
|
|
46
|
+
formatPollAnswer,
|
|
47
|
+
} from "../src/provider.js";
|
|
41
48
|
import { AgyDriver } from "../src/driver.js";
|
|
42
49
|
import { AcpDriver } from "../src/acp/driver.js";
|
|
43
50
|
import { runAcpAuth } from "../src/acp/auth.js";
|
|
@@ -459,6 +466,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
459
466
|
inputSchema: activateSkillSchema(skills) as object,
|
|
460
467
|
});
|
|
461
468
|
}
|
|
469
|
+
// Bridge-local, like activate_skill: answered from the escalation
|
|
470
|
+
// registry without a pi round-trip. Pairs with the STILL RUNNING
|
|
471
|
+
// early-ack that keeps slow calls under agy's ~180s request deadline.
|
|
472
|
+
tools.push({
|
|
473
|
+
name: POLL_TOOL_NAME,
|
|
474
|
+
description:
|
|
475
|
+
"Fetch the result of a long-running bridge tool call that answered STILL RUNNING with a callId. Poll again if it still reports running; the result or an error arrives here.",
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: "object",
|
|
478
|
+
properties: { callId: { type: "string", description: "callId from the STILL RUNNING answer" } },
|
|
479
|
+
required: ["callId"],
|
|
480
|
+
},
|
|
481
|
+
});
|
|
462
482
|
return tools;
|
|
463
483
|
};
|
|
464
484
|
// activate_skill never round-trips through pi: the bridge answers it
|
|
@@ -469,7 +489,22 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
469
489
|
args: Record<string, unknown>,
|
|
470
490
|
signal: AbortSignal,
|
|
471
491
|
) => {
|
|
472
|
-
|
|
492
|
+
// Bridge-local, like activate_skill: answered from the escalation
|
|
493
|
+
// registry, never parked into pi.
|
|
494
|
+
if (name === POLL_TOOL_NAME) {
|
|
495
|
+
const wanted = typeof args.callId === "string" ? args.callId : "";
|
|
496
|
+
mcpLog("poll-tool", { callId: wanted });
|
|
497
|
+
return Promise.resolve(formatPollAnswer(wanted, roundTrips.poll(wanted)));
|
|
498
|
+
}
|
|
499
|
+
if (name !== ACTIVATE_SKILL_TOOL_NAME) {
|
|
500
|
+
return roundTrips.onToolCall(callId, name, args, signal).then((r) => {
|
|
501
|
+
// Early-ack: answer the HTTP request before agy's ~180s client
|
|
502
|
+
// deadline with a poll handle; pi keeps executing meanwhile.
|
|
503
|
+
if (!("escalated" in r)) return r;
|
|
504
|
+
mcpLog("call-tool-escalated", { name, callId: r.callId });
|
|
505
|
+
return formatEscalatedAck(r);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
473
508
|
const wanted = typeof args.name === "string" ? args.name : "";
|
|
474
509
|
const skill = findSkillByName(skills, wanted);
|
|
475
510
|
const body = skill ? readSkillBody(skill) : `unknown skill: ${wanted || "(none given)"}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.8",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/mcp-server.ts
CHANGED
|
@@ -256,6 +256,14 @@ export async function startMcpServer(
|
|
|
256
256
|
|
|
257
257
|
const callHandler = async (request: { params: { name: string; arguments?: unknown } }, signal?: AbortSignal) => {
|
|
258
258
|
const { name, arguments: args } = request.params;
|
|
259
|
+
// Progress probe: agy's MCP client killed long bridge calls at exactly
|
|
260
|
+
// ~180s (see ACP-PROTOCOL-REFERENCE). If its requests ever carry a
|
|
261
|
+
// progressToken, MCP progress notifications become a testable zero-UX
|
|
262
|
+
// fix for that deadline; log presence to find out.
|
|
263
|
+
const meta = (request.params as { _meta?: { progressToken?: unknown } })._meta;
|
|
264
|
+
if (meta && meta.progressToken !== undefined) {
|
|
265
|
+
log("progress-token", { name, token: String(meta.progressToken) });
|
|
266
|
+
}
|
|
259
267
|
const callId = crypto.randomUUID();
|
|
260
268
|
log("call-tool", { name, callId });
|
|
261
269
|
try {
|
package/src/provider.ts
CHANGED
|
@@ -119,7 +119,7 @@ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
|
|
|
119
119
|
* and the question never displayed. Rides the systemPrompt gate: the note
|
|
120
120
|
* ships only when the system prompt ships. */
|
|
121
121
|
export const TOOL_PRIORITY_NOTE =
|
|
122
|
-
"[Tool priority: this conversation runs inside pi, not as a standalone agy session; the user only sees what surfaces in pi. Native interactive tools, for example ask_question, never reach the user. When a Pi Bridge tool covers the same purpose, always use the Pi Bridge tool; for user questions use ask_user_question.]";
|
|
122
|
+
"[Tool priority: this conversation runs inside pi, not as a standalone agy session; the user only sees what surfaces in pi. Native interactive tools, for example ask_question, never reach the user. When a Pi Bridge tool covers the same purpose, always use the Pi Bridge tool; for user questions use ask_user_question. Long-running bridge calls do not fail: after ~20 seconds the bridge answers STILL RUNNING with a callId; fetch the result with bridge_poll_result and poll until it lands. For work you already know is long, prefer exec_command's session-output pattern or background agents so you keep working while it runs.]";
|
|
123
123
|
|
|
124
124
|
/** Assemble the full agy prompt: system prompt block, pi-side digest, user
|
|
125
125
|
* prompt. Empty parts are dropped. Pure; exported for unit testing.
|
|
@@ -385,21 +385,138 @@ export function toAgyEffort(
|
|
|
385
385
|
// agy continues its still-running turn. No pi patch, no privileged API.
|
|
386
386
|
|
|
387
387
|
const BRIDGE_TIMEOUT_MS = 480_000;
|
|
388
|
+
/** Bounded memory of failed bridge parks (late-delivery tombstones). */
|
|
389
|
+
const MAX_PARK_TOMBSTONES = 64;
|
|
388
390
|
|
|
389
391
|
export interface BridgeCallResultShape {
|
|
390
392
|
content: Array<{ type: string; text?: string }>;
|
|
391
393
|
isError: boolean;
|
|
392
394
|
}
|
|
393
395
|
|
|
396
|
+
/** Early-ack sentinel: onToolCall settles with this when the pi tool is still
|
|
397
|
+
* running after escalateAfterMs (~20s). agy's MCP client abandons a
|
|
398
|
+
* tools/call HTTP request at ~180s (observed; see ACP-PROTOCOL-REFERENCE), so
|
|
399
|
+
* slow calls must not hold the request. The bridge answers with
|
|
400
|
+
* formatEscalatedAck and the real result arrives via bridge_poll_result (or,
|
|
401
|
+
* if agy never polls, the late-delivery path). */
|
|
402
|
+
export interface BridgeEscalation {
|
|
403
|
+
escalated: true;
|
|
404
|
+
callId: string;
|
|
405
|
+
name: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export const POLL_TOOL_NAME = "bridge_poll_result";
|
|
409
|
+
|
|
410
|
+
/** Default quiet period before a park escalates to a poll handle. Well under
|
|
411
|
+
* agy's ~180s request deadline; fast tools never see it. */
|
|
412
|
+
export const ESCALATE_AFTER_MS = 20_000;
|
|
413
|
+
/** Escalated parks carry a longer TTL: human-gated tools (commit previews,
|
|
414
|
+
* permission dialogs) legitimately block for many minutes. */
|
|
415
|
+
export const ESCALATED_TIMEOUT_MS = 1_800_000;
|
|
416
|
+
|
|
417
|
+
export interface PollView {
|
|
418
|
+
state: "running" | "done" | "failed";
|
|
419
|
+
name: string;
|
|
420
|
+
text?: string;
|
|
421
|
+
isError?: boolean;
|
|
422
|
+
reason?: string;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Escalated bridge calls. Bounded: past the cap, oldest settled entries
|
|
426
|
+
* evict first (a running call is never evicted while a newer one is). */
|
|
427
|
+
export class EscalationRegistry {
|
|
428
|
+
#calls = new Map<string, PollView>();
|
|
429
|
+
#trim(): void {
|
|
430
|
+
// Soft cap: only settled entries evict. Evicting a RUNNING call would
|
|
431
|
+
// strand its result (settle becomes a no-op, poll reports unknown), so
|
|
432
|
+
// saturating the cap with in-flight calls grows the map instead.
|
|
433
|
+
while (this.#calls.size > MAX_PARK_TOMBSTONES) {
|
|
434
|
+
const victim = [...this.#calls.entries()].find(([, e]) => e.state !== "running")?.[0];
|
|
435
|
+
if (victim === undefined) break;
|
|
436
|
+
this.#calls.delete(victim);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
escalate(callId: string, name: string): void {
|
|
440
|
+
this.#calls.set(callId, { name, state: "running" });
|
|
441
|
+
this.#trim();
|
|
442
|
+
}
|
|
443
|
+
settleDone(callId: string, text: string, isError: boolean): void {
|
|
444
|
+
const e = this.#calls.get(callId);
|
|
445
|
+
if (!e) return;
|
|
446
|
+
e.state = "done";
|
|
447
|
+
e.text = text;
|
|
448
|
+
e.isError = isError;
|
|
449
|
+
this.#trim();
|
|
450
|
+
}
|
|
451
|
+
settleFailed(callId: string, reason: string): void {
|
|
452
|
+
const e = this.#calls.get(callId);
|
|
453
|
+
if (!e) return;
|
|
454
|
+
e.state = "failed";
|
|
455
|
+
e.reason = reason;
|
|
456
|
+
this.#trim();
|
|
457
|
+
}
|
|
458
|
+
poll(callId: string): PollView | undefined {
|
|
459
|
+
const e = this.#calls.get(callId);
|
|
460
|
+
return e ? { ...e } : undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
|
|
465
|
+
return {
|
|
466
|
+
content: [
|
|
467
|
+
{
|
|
468
|
+
type: "text",
|
|
469
|
+
text: [
|
|
470
|
+
`STILL RUNNING: the pi tool "${e.name}" has not finished yet.`,
|
|
471
|
+
`Call ${POLL_TOOL_NAME} with callId "${e.callId}" to get the result. Poll again if it still reports running; you may do other work between polls.`,
|
|
472
|
+
"This is not an error and nothing is lost: if you stop polling, the bridge re-delivers the result in a later turn.",
|
|
473
|
+
].join("\n"),
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
isError: false,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function formatPollAnswer(callId: string, view: PollView | undefined): BridgeCallResultShape {
|
|
481
|
+
if (!view) {
|
|
482
|
+
return {
|
|
483
|
+
content: [
|
|
484
|
+
{
|
|
485
|
+
type: "text",
|
|
486
|
+
text: `Error: no escalated bridge call "${callId}". It either finished within the first seconds (its result is in your original tool result) or the callId is wrong.`,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
isError: true,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (view.state === "running") {
|
|
493
|
+
return {
|
|
494
|
+
content: [{ type: "text", text: `STILL RUNNING: "${view.name}" (callId ${callId}) has not finished. Poll again later.` }],
|
|
495
|
+
isError: false,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (view.state === "failed") {
|
|
499
|
+
return {
|
|
500
|
+
content: [{ type: "text", text: `Error: bridge call "${view.name}" (callId ${callId}) failed: ${view.reason}` }],
|
|
501
|
+
isError: true,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
return { content: [{ type: "text", text: view.text || "(no output)" }], isError: view.isError ?? false };
|
|
505
|
+
}
|
|
506
|
+
|
|
394
507
|
interface PendingRoundTrip {
|
|
395
508
|
/** "bridge": parked MCP HTTP call; resolve() completes it.
|
|
396
509
|
* "rt": native re-exec / wrapper round-trip; pi already executed, the
|
|
397
510
|
* toolResult only confirms continuation, nothing remote to settle. */
|
|
398
511
|
kind: "bridge" | "rt";
|
|
399
512
|
name: string;
|
|
400
|
-
resolve?: (r: BridgeCallResultShape) => void;
|
|
513
|
+
resolve?: (r: BridgeCallResultShape | BridgeEscalation) => void;
|
|
401
514
|
reject?: (e: Error) => void;
|
|
402
515
|
timer?: NodeJS.Timeout;
|
|
516
|
+
/** Set when the early-ack fired: the HTTP request was answered with a poll
|
|
517
|
+
* handle, so the settling value must go to the registry, not the socket. */
|
|
518
|
+
escalated?: boolean;
|
|
519
|
+
escalateTimer?: NodeJS.Timeout;
|
|
403
520
|
onAbort?: () => void;
|
|
404
521
|
signal?: AbortSignal;
|
|
405
522
|
}
|
|
@@ -430,23 +547,61 @@ export class WrapperReplay {
|
|
|
430
547
|
|
|
431
548
|
export class ToolRoundTrips {
|
|
432
549
|
#pending = new Map<string, PendingRoundTrip>();
|
|
550
|
+
/** Failed bridge parks: the pi tool keeps running and its toolResult will
|
|
551
|
+
* arrive with the park already gone. Bounded; consumed by the
|
|
552
|
+
* late-delivery path (see buildLateResultPrompt). */
|
|
553
|
+
#dead = new Map<string, { name: string; reason: string }>();
|
|
554
|
+
#escalations = new EscalationRegistry();
|
|
555
|
+
#escalateAfterMs: number;
|
|
433
556
|
#getDriver: () => TurnDriver;
|
|
434
557
|
#log: (s: string, d?: unknown) => void;
|
|
435
558
|
|
|
436
559
|
/** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
|
|
437
560
|
* is resolved at call time from config (plan §9.5). */
|
|
438
|
-
constructor(
|
|
561
|
+
constructor(
|
|
562
|
+
driver: TurnDriver | (() => TurnDriver),
|
|
563
|
+
log?: (s: string, d?: unknown) => void,
|
|
564
|
+
opts: { escalateAfterMs?: number } = {},
|
|
565
|
+
) {
|
|
439
566
|
this.#getDriver = typeof driver === "function" ? driver : () => driver;
|
|
440
567
|
this.#log = log ?? (() => {});
|
|
568
|
+
this.#escalateAfterMs = opts.escalateAfterMs ?? ESCALATE_AFTER_MS;
|
|
441
569
|
}
|
|
442
570
|
|
|
443
571
|
get pendingIds(): string[] {
|
|
444
572
|
return [...this.#pending.keys()];
|
|
445
573
|
}
|
|
446
574
|
|
|
447
|
-
/**
|
|
575
|
+
/** Call ids whose park already failed (tombstones). */
|
|
576
|
+
get deadIds(): string[] {
|
|
577
|
+
return [...this.#dead.keys()];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Take and clear the tombstone for a failed park, if any. */
|
|
581
|
+
consumeDead(toolCallId: string): { name: string; reason: string } | undefined {
|
|
582
|
+
const dead = this.#dead.get(toolCallId);
|
|
583
|
+
if (!dead) return undefined;
|
|
584
|
+
this.#dead.delete(toolCallId);
|
|
585
|
+
return dead;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Poll view for an escalated call (undefined when the id never escalated:
|
|
589
|
+
* fast calls settle synchronously and need no handle). */
|
|
590
|
+
poll(callId: string): PollView | undefined {
|
|
591
|
+
return this.#escalations.poll(callId);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Fail all pending calls (driver recycle/shutdown path). Escalated bridge
|
|
595
|
+
* calls are skipped: their HTTP request was already answered with a poll
|
|
596
|
+
* handle, and the agy turn ending does NOT make the still-running pi tool
|
|
597
|
+
* a failure. They settle through resolve(), their own 30m timer, or an
|
|
598
|
+
* abort signal on the pi tool call. */
|
|
448
599
|
failAll(reason: string): void {
|
|
449
|
-
for (const id of [...this.#pending.keys()])
|
|
600
|
+
for (const id of [...this.#pending.keys()]) {
|
|
601
|
+
const entry = this.#pending.get(id);
|
|
602
|
+
if (entry?.kind === "bridge" && entry.escalated) continue;
|
|
603
|
+
this.#fail(id, reason);
|
|
604
|
+
}
|
|
450
605
|
}
|
|
451
606
|
|
|
452
607
|
#fail(callId: string, reason: string): void {
|
|
@@ -459,20 +614,33 @@ export class ToolRoundTrips {
|
|
|
459
614
|
}
|
|
460
615
|
this.#pending.delete(callId);
|
|
461
616
|
clearTimeout(entry.timer);
|
|
617
|
+
if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
|
|
462
618
|
if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
|
|
619
|
+
this.#dead.set(callId, { name: entry.name, reason });
|
|
620
|
+
while (this.#dead.size > MAX_PARK_TOMBSTONES) {
|
|
621
|
+
const oldest = this.#dead.keys().next().value;
|
|
622
|
+
if (oldest === undefined) break;
|
|
623
|
+
this.#dead.delete(oldest);
|
|
624
|
+
}
|
|
625
|
+
if (entry.escalated) this.#escalations.settleFailed(callId, reason);
|
|
463
626
|
entry.reject!(new Error(reason));
|
|
464
627
|
this.#getDriver().kickIdle();
|
|
465
628
|
this.#log("round-trip-fail", { callId, name: entry.name, reason });
|
|
466
629
|
}
|
|
467
630
|
|
|
468
|
-
/** Park the MCP call: inject into the live agy turn
|
|
469
|
-
*
|
|
631
|
+
/** Park the MCP call: inject into the live agy turn. Fast calls settle
|
|
632
|
+
* with the real BridgeCallResultShape. Calls still running after
|
|
633
|
+
* escalateAfterMs settle with a BridgeEscalation sentinel instead: the
|
|
634
|
+
* bridge answers the HTTP request with a poll handle while pi keeps
|
|
635
|
+
* executing, so agy's ~180s request deadline is never hit. The real
|
|
636
|
+
* result reaches agy via bridge_poll_result, or via the late-delivery
|
|
637
|
+
* path if agy never polls. Fail-closed: timeout/abort still reject. */
|
|
470
638
|
onToolCall = (
|
|
471
639
|
callId: string,
|
|
472
640
|
name: string,
|
|
473
641
|
args: Record<string, unknown>,
|
|
474
642
|
signal: AbortSignal,
|
|
475
|
-
): Promise<BridgeCallResultShape> => {
|
|
643
|
+
): Promise<BridgeCallResultShape | BridgeEscalation> => {
|
|
476
644
|
const handle = this.#getDriver().activeHandle;
|
|
477
645
|
if (!handle) {
|
|
478
646
|
return Promise.reject(
|
|
@@ -481,13 +649,32 @@ export class ToolRoundTrips {
|
|
|
481
649
|
),
|
|
482
650
|
);
|
|
483
651
|
}
|
|
484
|
-
return new Promise<BridgeCallResultShape>((resolve, reject) => {
|
|
652
|
+
return new Promise<BridgeCallResultShape | BridgeEscalation>((resolve, reject) => {
|
|
485
653
|
const timer = setTimeout(() => {
|
|
486
654
|
this.#fail(callId, `pi tool round-trip timed out after ${BRIDGE_TIMEOUT_MS / 1000}s`);
|
|
487
655
|
}, BRIDGE_TIMEOUT_MS);
|
|
488
656
|
const onAbort = () => this.#fail(callId, "agy disconnected before the tool result arrived");
|
|
489
657
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
490
|
-
|
|
658
|
+
const entry: PendingRoundTrip = { kind: "bridge", name, resolve, reject, timer, onAbort, signal };
|
|
659
|
+
if (this.#escalateAfterMs > 0) {
|
|
660
|
+
entry.escalateTimer = setTimeout(() => {
|
|
661
|
+
const e = this.#pending.get(callId);
|
|
662
|
+
// Resolved (or failed) between arm and fire: nothing to escalate.
|
|
663
|
+
if (!e || e.kind !== "bridge") return;
|
|
664
|
+
e.escalated = true;
|
|
665
|
+
// Human-gated calls (commit previews, permission dialogs) can
|
|
666
|
+
// block far longer than the standard park TTL; re-arm generously.
|
|
667
|
+
if (e.timer) {
|
|
668
|
+
clearTimeout(e.timer);
|
|
669
|
+
e.timer = setTimeout(() => {
|
|
670
|
+
this.#fail(callId, `escalated bridge call timed out after ${ESCALATED_TIMEOUT_MS / 60_000} minutes`);
|
|
671
|
+
}, ESCALATED_TIMEOUT_MS);
|
|
672
|
+
}
|
|
673
|
+
this.#escalations.escalate(callId, e.name);
|
|
674
|
+
resolve({ escalated: true, callId, name: e.name });
|
|
675
|
+
}, this.#escalateAfterMs);
|
|
676
|
+
}
|
|
677
|
+
this.#pending.set(callId, entry);
|
|
491
678
|
handle.pushExternal({ type: "bridge_call", callId, name, args });
|
|
492
679
|
});
|
|
493
680
|
};
|
|
@@ -505,12 +692,21 @@ export class ToolRoundTrips {
|
|
|
505
692
|
if (!entry) return false;
|
|
506
693
|
this.#pending.delete(toolCallId);
|
|
507
694
|
clearTimeout(entry.timer);
|
|
695
|
+
if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
|
|
508
696
|
if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
|
|
509
697
|
if (entry.kind === "rt") {
|
|
510
698
|
this.#log("round-trip-rt-done", { callId: toolCallId, name: entry.name, isError });
|
|
511
699
|
return true;
|
|
512
700
|
}
|
|
513
|
-
|
|
701
|
+
// Escalated call: the HTTP response already carried the poll handle, so
|
|
702
|
+
// the result lands in the registry for the next bridge_poll_result. The
|
|
703
|
+
// original promise settled with the sentinel; re-resolving is a silent
|
|
704
|
+
// no-op, so gate it to keep that explicit.
|
|
705
|
+
if (entry.escalated) {
|
|
706
|
+
this.#escalations.settleDone(toolCallId, text, isError);
|
|
707
|
+
} else {
|
|
708
|
+
entry.resolve!({ content: [{ type: "text", text }], isError });
|
|
709
|
+
}
|
|
514
710
|
this.#getDriver().kickIdle();
|
|
515
711
|
this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
|
|
516
712
|
return true;
|
|
@@ -534,6 +730,29 @@ export function collectToolResults(
|
|
|
534
730
|
return out;
|
|
535
731
|
}
|
|
536
732
|
|
|
733
|
+
export interface LateToolResult {
|
|
734
|
+
name: string;
|
|
735
|
+
reason: string;
|
|
736
|
+
text: string;
|
|
737
|
+
isError: boolean;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Frame late tool results so agy treats them as the results its bridge calls
|
|
741
|
+
* never received (the round-trip died while the pi tool was still running,
|
|
742
|
+
* e.g. agy's ~180s MCP client timeout on tools/call). */
|
|
743
|
+
export function buildLateResultPrompt(late: LateToolResult[], userPrompt?: string): string {
|
|
744
|
+
const blocks = late.map((r) =>
|
|
745
|
+
[
|
|
746
|
+
`pi tool "${r.name}": the bridge round-trip expired before this result reached you (${r.reason}).`,
|
|
747
|
+
r.isError ? "The tool reported an error:" : "Result:",
|
|
748
|
+
r.text.trim() || "(no output)",
|
|
749
|
+
].join("\n"),
|
|
750
|
+
);
|
|
751
|
+
const header = "Late tool delivery: treat the following as the results of your earlier tool calls.";
|
|
752
|
+
const body = [header, ...blocks].join("\n\n");
|
|
753
|
+
return userPrompt ? `${body}\n\n${userPrompt}` : body;
|
|
754
|
+
}
|
|
755
|
+
|
|
537
756
|
// --- stream-json engine -------------------------------------------------------
|
|
538
757
|
|
|
539
758
|
export interface DriverDeps {
|
|
@@ -739,12 +958,47 @@ async function runTurnDriver(
|
|
|
739
958
|
// agy receives the result via the bridge's MCP HTTP response.
|
|
740
959
|
const results = collectToolResults(context.messages, deps.roundTrips.pendingIds);
|
|
741
960
|
const isContinuation = results.length > 0;
|
|
961
|
+
// Escalated calls answer through bridge_poll_result, not through an agy
|
|
962
|
+
// turn waiting on the park, so note them before resolving.
|
|
963
|
+
const escalatedNames = results
|
|
964
|
+
.map((r) => deps.roundTrips.poll(r.toolCallId)?.name)
|
|
965
|
+
.filter((n): n is string => Boolean(n));
|
|
742
966
|
for (const r of results) deps.roundTrips.resolve(r.toolCallId, r.text, r.isError);
|
|
743
967
|
|
|
968
|
+
// Late delivery: a toolResult whose park already failed (the abort/timeout
|
|
969
|
+
// path failed the park while the pi tool kept running). The work is done,
|
|
970
|
+
// so re-route the result to agy as a new prompt in the same conversation
|
|
971
|
+
// instead of dropping it. Both drivers serialize run(), so delivery queues
|
|
972
|
+
// behind agy's own salvaged turn when one is still active.
|
|
973
|
+
// A pass that anchors a still-pending park (isContinuation) has nowhere to
|
|
974
|
+
// put a late result: it can neither ride the pending call's HTTP response
|
|
975
|
+
// nor start a new prompt. Leave the tombstone for the next fresh pass
|
|
976
|
+
// instead of consuming it blind.
|
|
977
|
+
const late: LateToolResult[] = [];
|
|
978
|
+
if (!isContinuation) {
|
|
979
|
+
for (const r of collectToolResults(context.messages, deps.roundTrips.deadIds)) {
|
|
980
|
+
const dead = deps.roundTrips.consumeDead(r.toolCallId);
|
|
981
|
+
if (dead) late.push({ name: dead.name, reason: dead.reason, text: r.text, isError: r.isError });
|
|
982
|
+
}
|
|
983
|
+
if (late.length > 0) {
|
|
984
|
+
deps.log?.("late-result", { tools: late.map((l) => l.name), freshConversation: !existing?.conversationId }, "info");
|
|
985
|
+
}
|
|
986
|
+
} else if (deps.roundTrips.deadIds.length > 0) {
|
|
987
|
+
deps.log?.("late-result-deferred", { count: deps.roundTrips.deadIds.length }, "info");
|
|
988
|
+
}
|
|
989
|
+
|
|
744
990
|
let handle: TurnHandle;
|
|
745
991
|
if (isContinuation) {
|
|
746
992
|
const active = deps.driver.reentry();
|
|
747
993
|
if (!active) {
|
|
994
|
+
// Escalated calls have no turn to re-enter BY DESIGN: agy already
|
|
995
|
+
// got the poll handle and the result lives in the registry. Settle
|
|
996
|
+
// quietly instead of erroring the turn.
|
|
997
|
+
if (escalatedNames.length > 0) {
|
|
998
|
+
appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished; the result is available via ${POLL_TOOL_NAME}.`);
|
|
999
|
+
finalize(stream, blocks, "stop");
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
748
1002
|
deps.log?.("turn-error", { reason: "tool-result-no-active-turn" }, "warn");
|
|
749
1003
|
finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
|
|
750
1004
|
return;
|
|
@@ -754,8 +1008,9 @@ async function runTurnDriver(
|
|
|
754
1008
|
const prompt = extractUserPrompt(context);
|
|
755
1009
|
const images = extractImages(context);
|
|
756
1010
|
// An image-only message (no text) is valid on the ACP engine; only fail
|
|
757
|
-
// when there is nothing at all to send
|
|
758
|
-
|
|
1011
|
+
// when there is nothing at all to send (no text, no images, no late
|
|
1012
|
+
// tool results to deliver).
|
|
1013
|
+
if (!prompt && images.length === 0 && late.length === 0) {
|
|
759
1014
|
deps.log?.("turn-error", { reason: "no-user-message" }, "debug");
|
|
760
1015
|
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
761
1016
|
return;
|
|
@@ -764,7 +1019,9 @@ async function runTurnDriver(
|
|
|
764
1019
|
const agyModel = entry?.full ?? model.id;
|
|
765
1020
|
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
766
1021
|
const watermark = existing?.lastMessageCount ?? 0;
|
|
767
|
-
|
|
1022
|
+
// Late turns re-open the conversation with a synthetic prompt; the digest
|
|
1023
|
+
// would re-send context agy already holds, so skip it.
|
|
1024
|
+
const digest = config.digest && late.length === 0 ? buildContextDigest(context.messages, watermark) : "";
|
|
768
1025
|
// G1 delivery per engine. stream-json: digest rides inline in the prompt
|
|
769
1026
|
// (the CLI has no context channel). ACP: the server advertises
|
|
770
1027
|
// `embeddedContext`, so the digest ships as a native resource block
|
|
@@ -778,7 +1035,10 @@ async function runTurnDriver(
|
|
|
778
1035
|
// re-sending it every turn would bloat each prompt and bust the cache.
|
|
779
1036
|
const sysPrompt =
|
|
780
1037
|
config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
|
|
781
|
-
const fullPrompt =
|
|
1038
|
+
const fullPrompt =
|
|
1039
|
+
late.length > 0
|
|
1040
|
+
? buildLateResultPrompt(late, prompt || undefined)
|
|
1041
|
+
: buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
|
|
782
1042
|
try {
|
|
783
1043
|
handle = await deps.driver.run({
|
|
784
1044
|
cwd,
|