@estebanforge/pi-antigravity-bridge 1.4.7 → 1.4.9
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 +36 -0
- package/README.md +8 -6
- package/docs/ACP-PROTOCOL-REFERENCE.md +1 -0
- package/extensions/index.ts +89 -10
- package/package.json +1 -1
- package/src/acp/connection.ts +12 -0
- package/src/acp/driver.ts +8 -2
- package/src/acp/events.ts +17 -0
- package/src/daily-log.ts +11 -10
- package/src/driver-types.ts +4 -0
- package/src/mcp-server.ts +8 -0
- package/src/provider.ts +281 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.9] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Warn toasts in the pi UI: bridge tool failures, stalls, timeouts, process exits, and other warnings surface as native warning notifications the moment they happen, so users see them and can report them. Over SSH or without a UI the same text falls back to stderr. Deliberate aborts, connection exits (the turn's own error block carries real crashes), and `call-tool-fail` (same-instant duplicate of `round-trip-fail`) stay silent.
|
|
10
|
+
- Gate B watch in `/agy doctor`: the ACP connection latches the first `session/update` payload that carries usage/token fields, and doctor prints one "acp tokens: AVAILABLE" line when that happens. Silent until then; the day the line appears, wiring real token counts becomes a small mapping job. Auth-style string `"token"` keys inside tool frames cannot trip the latch.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Esc-abort on the ACP engine no longer dumps the dying server's raw stderr tail (a google3 stack trace) into the transcript. Teardown exits (abort kill, shutdown, idle recycle) are marked expected and never render; a genuine mid-turn crash surfaces as the turn's own clean error block instead. Raw tails still land in the file log for post-mortems.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The ACP engine is now beta. Parity is verified live against RC01 (text streaming, multi-turn resume, bridge tools, effort switching, serialization, abort recovery - see `scripts/parity-live.mjs`), so it graduates from opt-in-experimental to a supported alternative engine. It stays behind `config.engine` for now; the two known RC01 gaps (no usage fields, kill+reload abort) are documented in the README.
|
|
19
|
+
- Daily log volume: the default tier now writes ONLY errors, so a regular session costs the disk nothing. Warns live in the UI (see above); `AGY_DEBUG=1` restores the full debug/info/warn/error trail for reproducing a problem. Docs: README debug-logs section, `/agy doctor` hint.
|
|
20
|
+
- The tool-priority note now steers agy's native `view_file` (artifact-sandboxed on RC01) to the Pi Bridge file tools (`read`, `ls`, `grep`, `find`, `edit`, `execute`) for any real filesystem path. Observed live: repeated `invalid_args` rejections on `operator/pkg/tmux/client.go` before the model fell back to `edit`.
|
|
21
|
+
|
|
22
|
+
## [1.4.8] - 2026-09-05
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- 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.
|
|
33
|
+
- `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).
|
|
34
|
+
- `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.
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- 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.
|
|
39
|
+
- 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.
|
|
40
|
+
|
|
5
41
|
## [1.4.7] - 2026-09-05
|
|
6
42
|
|
|
7
43
|
### Added
|
package/README.md
CHANGED
|
@@ -21,11 +21,11 @@ Multi-turn works. The provider binds a pi session to an agy conversation id (per
|
|
|
21
21
|
Turns run through one of two engines behind the same provider surface (`config.engine`, default `stream-json`):
|
|
22
22
|
|
|
23
23
|
- **stream-json** (default): the persistent `agy` CLI process. The tested default; live token usage; conversation resume via `--conversation`.
|
|
24
|
-
- **acp
|
|
24
|
+
- **acp** (beta): Google's official ACP server (`agy_acp_server.par`), JSON-RPC 2.0 over stdio. Beta: parity-verified live against the current build (RC01) - text streaming, multi-turn resume via `session/load`, bridge tools, effort switching, serialization, abort recovery (see `scripts/parity-live.mjs`). Two known RC01 gaps remain: no usage fields (token display shows zero) and no cancel (abort tears the server down and reloads it next turn).
|
|
25
25
|
|
|
26
26
|
Engine-dependent features: pi image attachments ride natively only on the ACP engine (the picker offers image attach automatically when `config.engine` is `acp`; the stream-json CLI prompt is text-only). With the optional G1 digest enabled, its delivery also differs: ACP ships it as a native `embeddedContext` resource block, stream-json prepends it to the prompt text. The `AskAntigravity` delegation tool is unaffected by `config.engine` and runs the `stream-json` CLI (`agy -p`) across both configurations.
|
|
27
27
|
|
|
28
|
-
| Capability | `stream-json` (default) | `acp` |
|
|
28
|
+
| Capability | `stream-json` (default) | `acp` (beta) |
|
|
29
29
|
| --- | --- | --- |
|
|
30
30
|
| Show thinking text | No (token count only, floor 64, no text body) | Yes (streams thought text via `agent_thought_chunk`; sparse on RC01 where reasoning often arrives in message text) |
|
|
31
31
|
| Live token usage | Yes (live metrics from CLI step events) | No (absent in RC01, displays zero tokens) |
|
|
@@ -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.
|
|
@@ -155,7 +157,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
|
|
|
155
157
|
/agy system-prompt on|off send pi's system prompt + AGENTS.md + the Pi Bridge tool-priority note to new agy conversations (default on)
|
|
156
158
|
/agy bridge all|mcp|none which pi tools the MCP bridge exposes to agy (default all; none = bridge off)
|
|
157
159
|
/agy acp-bin <path|auto> point the ACP engine at a specific server binary (auto = setup installs, or AGY_ACP_BIN; applies on the next ACP turn)
|
|
158
|
-
/agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp runs self-service setup: binary install + auth bootstrap)
|
|
160
|
+
/agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp is beta and runs self-service setup: binary install + auth bootstrap)
|
|
159
161
|
/agy auth-manual manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
|
|
160
162
|
/agy patch-cleanup restore the original pi files if an older version patched them
|
|
161
163
|
/agy clear drop all session bindings (force fresh conversations)
|
|
@@ -186,7 +188,7 @@ For isolation when running any agent that executes commands without a confirmati
|
|
|
186
188
|
| `AGY_SKIP_PERMISSIONS` | `1`/`true` (default) to pass `--dangerously-skip-permissions` so commands don't hang on an unanswerable prompt in `-p` mode. `0`/`false` to prompt (hangs any `run_command` non-interactively). Wins over the config file. |
|
|
187
189
|
| `AGY_DEFAULT_MODEL` | Default model alias for the `AskAntigravity` tool (`flash`/`pro`/`gemini`, or a tier/version qualifier). Wins over the config file. |
|
|
188
190
|
| `AGY_DEFAULT_THINKING` | Default thinking tier for the `AskAntigravity` tool: `low`/`medium`/`high`. Anything else falls back to `medium`. Wins over the config file. |
|
|
189
|
-
| `AGY_DEBUG` | `1`/`true`/`on` writes
|
|
191
|
+
| `AGY_DEBUG` | `1`/`true`/`on` writes the full trail to the daily log (driver lifecycle, raw bridge traffic). Default off: **only `error` records land on disk** - routine disk writes are zero for regular users, and warnings surface as UI toasts instead. |
|
|
190
192
|
|
|
191
193
|
## Debug logs
|
|
192
194
|
|
|
@@ -196,14 +198,14 @@ 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
|
|
201
|
+
One JSON record per line. Two verbosity tiers keep the disk cost at zero for regular users: by default only `error` records land on disk - everything routine (turn starts, tool calls, escalations, commands, driver failures) is withheld. Warnings are not lost: the extension toasts each one in the pi UI (bridge tool failures, stalls, timeouts, process exits), so users see them and can report them; deliberate aborts and failures pi already renders as the turn's own error block stay silent. Set `AGY_DEBUG=1` before reproducing a problem for the full trail: per-event driver lifecycle (spawn, exit, session load/new, unparks), turn starts and outcomes, bridge tool calls, escalations and poll traffic, late deliveries, list-tools traffic, recycle causes, and the raw bridge chatter. `/agy doctor` prints the log directory.
|
|
200
202
|
|
|
201
203
|
Notes:
|
|
202
204
|
|
|
203
205
|
- Retention: 14 days. Older files are pruned automatically.
|
|
204
206
|
- Privacy: prompt text, tool arguments, and tool output never land in the log. Secret-shaped values (tokens, API keys, credentials, header blocks) are redacted, and long strings are truncated. The `auth-url` record strips the login URL's query string.
|
|
205
207
|
- Logging never throws: an unwritable directory is skipped silently and retried on the next record.
|
|
206
|
-
- SSD wear: default volume is
|
|
208
|
+
- SSD wear: default volume is effectively zero until something actually errors. Verbose mode (`AGY_DEBUG=1`) writes more; turn it off after reproducing.
|
|
207
209
|
|
|
208
210
|
When you report an issue, attach the last day or two of files from that directory. For anything that needs reproduction, run with `AGY_DEBUG=1` once and attach that day's file. They usually contain the exact failure sequence (engine, session, bridge call, error) with no need to guess.
|
|
209
211
|
|
|
@@ -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";
|
|
@@ -109,6 +116,36 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
109
116
|
// src/daily-log.ts). Fire-and-forget, secrets redacted, old days pruned.
|
|
110
117
|
// Support flow: "attach the last days' files from that dir".
|
|
111
118
|
const fileLog = createDailyLogger({ dir: logsDir() });
|
|
119
|
+
// Warn tier = user-facing: the default file log keeps ONLY errors (zero
|
|
120
|
+
// routine disk traffic), so a warn that never reaches the UI is lost.
|
|
121
|
+
// Every warn toasts here instead; AGY_DEBUG=1 restores the full file
|
|
122
|
+
// trail. Silent exceptions: deliberate aborts (pi already shows
|
|
123
|
+
// "Operation aborted"), connection exits (the turn's own error block
|
|
124
|
+
// carries real crashes), and call-tool-fail (same-instant duplicate of
|
|
125
|
+
// round-trip-fail). No UI (headless): warn text falls back to stderr.
|
|
126
|
+
const rawFileLog = fileLog.log.bind(fileLog);
|
|
127
|
+
fileLog.log = (event, data, level) => {
|
|
128
|
+
rawFileLog(event, data, level);
|
|
129
|
+
if (level !== "warn") return;
|
|
130
|
+
if (event.startsWith("abort:") || event === "connection-exited" || event === "call-tool-fail") return;
|
|
131
|
+
const d = (data ?? {}) as Record<string, unknown>;
|
|
132
|
+
let text: string;
|
|
133
|
+
if (event === "round-trip-fail") {
|
|
134
|
+
text = `Bridge tool call failed: ${String(d.name ?? "tool")} (${String(d.reason ?? "unknown")})`;
|
|
135
|
+
} else if (event.startsWith("stall:")) {
|
|
136
|
+
text = "Antigravity stalled with no output; the turn was stopped";
|
|
137
|
+
} else if (event.startsWith("timeout:")) {
|
|
138
|
+
text = "Antigravity turn timed out";
|
|
139
|
+
} else if (event.startsWith("exit:") && event !== "exit:0") {
|
|
140
|
+
text = "Antigravity process exited unexpectedly";
|
|
141
|
+
} else if (event === "turn-error") {
|
|
142
|
+
text = `Antigravity turn issue: ${String(d.reason ?? "unknown")}`;
|
|
143
|
+
} else {
|
|
144
|
+
text = `Antigravity warning: ${event}`;
|
|
145
|
+
}
|
|
146
|
+
if (activeUi) activeUi.notify(text, "warning");
|
|
147
|
+
else console.error(`[antigravity-bridge] ${text}`);
|
|
148
|
+
};
|
|
112
149
|
fileLog.log(
|
|
113
150
|
"extension-load",
|
|
114
151
|
{ engine, models: models.length, fallback: usingFallback, bridge: loadConfig().bridgeTools, askTool: loadConfig().askTool },
|
|
@@ -176,12 +213,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
176
213
|
: data;
|
|
177
214
|
// Failures warn; turn-start + auth-url are the always-on skeleton;
|
|
178
215
|
// routine per-event lifecycle (spawn, session-load, unparked, ...) is
|
|
179
|
-
// verbose-only.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
216
|
+
// verbose-only. Deliberate teardown exits (Esc abort kill, shutdown,
|
|
217
|
+
// idle recycle) demote to debug: routine, and the warn tier stays the
|
|
218
|
+
// "what broke" grep.
|
|
219
|
+
const expectedExit =
|
|
220
|
+
msg === "connection-exited" &&
|
|
221
|
+
(data as { expected?: boolean } | undefined)?.expected === true;
|
|
222
|
+
const level = expectedExit
|
|
223
|
+
? "debug"
|
|
224
|
+
: acpFailures.has(msg)
|
|
225
|
+
? "warn"
|
|
226
|
+
: msg === "turn-start" || msg === "auth-url"
|
|
227
|
+
? "info"
|
|
228
|
+
: "debug";
|
|
185
229
|
fileLog.log(msg, fileData, level);
|
|
186
230
|
if (msg === "auth-url") {
|
|
187
231
|
const { url, port } = (data ?? {}) as { url?: string; port?: number | null };
|
|
@@ -202,7 +246,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
202
246
|
return;
|
|
203
247
|
}
|
|
204
248
|
if (!acpFailures.has(msg)) return;
|
|
205
|
-
|
|
249
|
+
// Console noise is gone: warns toast through the fileLog wrapper (with
|
|
250
|
+
// a stderr fallback when no UI exists), and raw tails stay in the file
|
|
251
|
+
// log behind AGY_DEBUG.
|
|
206
252
|
};
|
|
207
253
|
const acpDriver = new AcpDriver({
|
|
208
254
|
// Resolved per connection: the setup flow can install the binary and
|
|
@@ -459,6 +505,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
459
505
|
inputSchema: activateSkillSchema(skills) as object,
|
|
460
506
|
});
|
|
461
507
|
}
|
|
508
|
+
// Bridge-local, like activate_skill: answered from the escalation
|
|
509
|
+
// registry without a pi round-trip. Pairs with the STILL RUNNING
|
|
510
|
+
// early-ack that keeps slow calls under agy's ~180s request deadline.
|
|
511
|
+
tools.push({
|
|
512
|
+
name: POLL_TOOL_NAME,
|
|
513
|
+
description:
|
|
514
|
+
"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.",
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: "object",
|
|
517
|
+
properties: { callId: { type: "string", description: "callId from the STILL RUNNING answer" } },
|
|
518
|
+
required: ["callId"],
|
|
519
|
+
},
|
|
520
|
+
});
|
|
462
521
|
return tools;
|
|
463
522
|
};
|
|
464
523
|
// activate_skill never round-trips through pi: the bridge answers it
|
|
@@ -469,7 +528,22 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
469
528
|
args: Record<string, unknown>,
|
|
470
529
|
signal: AbortSignal,
|
|
471
530
|
) => {
|
|
472
|
-
|
|
531
|
+
// Bridge-local, like activate_skill: answered from the escalation
|
|
532
|
+
// registry, never parked into pi.
|
|
533
|
+
if (name === POLL_TOOL_NAME) {
|
|
534
|
+
const wanted = typeof args.callId === "string" ? args.callId : "";
|
|
535
|
+
mcpLog("poll-tool", { callId: wanted });
|
|
536
|
+
return Promise.resolve(formatPollAnswer(wanted, roundTrips.poll(wanted)));
|
|
537
|
+
}
|
|
538
|
+
if (name !== ACTIVATE_SKILL_TOOL_NAME) {
|
|
539
|
+
return roundTrips.onToolCall(callId, name, args, signal).then((r) => {
|
|
540
|
+
// Early-ack: answer the HTTP request before agy's ~180s client
|
|
541
|
+
// deadline with a poll handle; pi keeps executing meanwhile.
|
|
542
|
+
if (!("escalated" in r)) return r;
|
|
543
|
+
mcpLog("call-tool-escalated", { name, callId: r.callId });
|
|
544
|
+
return formatEscalatedAck(r);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
473
547
|
const wanted = typeof args.name === "string" ? args.name : "";
|
|
474
548
|
const skill = findSkillByName(skills, wanted);
|
|
475
549
|
const body = skill ? readSkillBody(skill) : `unknown skill: ${wanted || "(none given)"}`;
|
|
@@ -758,7 +832,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
758
832
|
` sessions: ${ctx.store.size} bound`,
|
|
759
833
|
` models: ${ctx.entries.length} ${ctx.usingFallback ? "FALLBACK (agy models failed)" : "discovered"}`,
|
|
760
834
|
` config: ${CONFIG_PATH}`,
|
|
761
|
-
` logs: ${logsDir()} (attach recent days' files when reporting issues)`,
|
|
835
|
+
` logs: ${logsDir()} (attach recent days' files when reporting issues; set AGY_DEBUG=1 to capture details)`,
|
|
762
836
|
];
|
|
763
837
|
if (snap.engine === "acp" && snap.acp) {
|
|
764
838
|
lines.push(
|
|
@@ -766,6 +840,11 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
766
840
|
` acp server: ${snap.acp.serverVersion ?? "unknown"}${snap.acp.agentTitle ? ` (${snap.acp.agentTitle})` : ""}`,
|
|
767
841
|
` acp stats: prompts=${snap.acp.prompts} created=${snap.acp.sessionsCreated} loaded=${snap.acp.sessionsLoaded} kills=${snap.acp.kills} reconnects=${snap.acp.reconnects} cancel=${snap.acp.cancelSupported === null ? "unprobed" : snap.acp.cancelSupported ? "supported" : "unsupported (kill+reload)"}`,
|
|
768
842
|
);
|
|
843
|
+
// Gate B watch: silent while the server offers no token counts; one
|
|
844
|
+
// line the day it starts (then real usage mapping is worth wiring).
|
|
845
|
+
if (snap.acp.usageSeen) {
|
|
846
|
+
lines.push(" acp tokens: AVAILABLE in server payloads (wire real usage mapping next)");
|
|
847
|
+
}
|
|
769
848
|
}
|
|
770
849
|
if (snap.lifecycle.length > 0) {
|
|
771
850
|
lines.push(" lifecycle (last 5):");
|
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.9",
|
|
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/acp/connection.ts
CHANGED
|
@@ -24,6 +24,7 @@ import fs from "node:fs";
|
|
|
24
24
|
import os from "node:os";
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { parseAuthPort, readLastUrl } from "./browser-capture.js";
|
|
27
|
+
import { frameCarriesUsage } from "./events.js";
|
|
27
28
|
import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
|
|
28
29
|
|
|
29
30
|
export interface AcpMcpServer {
|
|
@@ -80,6 +81,7 @@ export class AcpConnection {
|
|
|
80
81
|
#child: ChildProcess | undefined;
|
|
81
82
|
#rpc: JsonRpcSession | undefined;
|
|
82
83
|
#stderrTail = "";
|
|
84
|
+
#usageSeen = false;
|
|
83
85
|
#exited = false;
|
|
84
86
|
#killed = false;
|
|
85
87
|
#suppressUpdates = false;
|
|
@@ -106,6 +108,13 @@ export class AcpConnection {
|
|
|
106
108
|
return this.#stderrTail;
|
|
107
109
|
}
|
|
108
110
|
|
|
111
|
+
/** Gate B watch: latched true once any session/update frame carried
|
|
112
|
+
* usage/token fields. /agy doctor surfaces it the day upstream starts
|
|
113
|
+
* sending token counts; silent until then. */
|
|
114
|
+
get usageSeen(): boolean {
|
|
115
|
+
return this.#usageSeen;
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
/** While true, session/update notifications are dropped: they are the
|
|
110
119
|
* full-text history replay that precedes a session/load response, never
|
|
111
120
|
* live generation (run 6). */
|
|
@@ -299,6 +308,9 @@ export class AcpConnection {
|
|
|
299
308
|
|
|
300
309
|
#onNotification(method: string, params: unknown): void {
|
|
301
310
|
if (method === "session/update") {
|
|
311
|
+
// Latched before the suppression gate: history-replay frames are the
|
|
312
|
+
// server's payloads too, and they are the cheapest signal source.
|
|
313
|
+
if (!this.#usageSeen && frameCarriesUsage(params)) this.#usageSeen = true;
|
|
302
314
|
if (this.#suppressUpdates) return; // load replay: history, not live text
|
|
303
315
|
const p = typeof params === "object" && params !== null ? (params as Record<string, unknown>) : {};
|
|
304
316
|
const sessionId = typeof p.sessionId === "string" ? p.sessionId : this.#updateSessionId;
|
package/src/acp/driver.ts
CHANGED
|
@@ -409,7 +409,12 @@ export class AcpDriver implements TurnDriver {
|
|
|
409
409
|
const turn = this.#active;
|
|
410
410
|
this.#conn = undefined;
|
|
411
411
|
this.#state = "dead";
|
|
412
|
-
|
|
412
|
+
// Teardown exits (abort kill, shutdown, idle recycle) are deliberate:
|
|
413
|
+
// their stderr tail must never reach the UI. Only a live turn dying
|
|
414
|
+
// mid-flight is user-reportable, and it surfaces as a normal turn
|
|
415
|
+
// error, not as raw console noise.
|
|
416
|
+
const turnActive = Boolean(turn && !turn.closed && !turn.aborted && !turn.sawResult);
|
|
417
|
+
this.#log("connection-exited", { expected: !turnActive, tail: info.stderrTail.slice(-200) });
|
|
413
418
|
if (!turn || turn.closed) return;
|
|
414
419
|
if (turn.aborted || turn.sawResult) {
|
|
415
420
|
this.#settle(turn, {
|
|
@@ -421,7 +426,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
421
426
|
});
|
|
422
427
|
return;
|
|
423
428
|
}
|
|
424
|
-
this.#failTurn(turn,
|
|
429
|
+
this.#failTurn(turn, "Antigravity session exited unexpectedly; it restarts on the next turn");
|
|
425
430
|
}
|
|
426
431
|
|
|
427
432
|
#ensureConnection(request: DriverTurnRequest): Promise<AcpConnection> {
|
|
@@ -721,6 +726,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
721
726
|
reconnects: Math.max(0, this.#stats.spawns - 1),
|
|
722
727
|
agentName: this.#agentInfo?.name,
|
|
723
728
|
agentTitle: this.#agentInfo?.title,
|
|
729
|
+
usageSeen: this.#conn?.usageSeen ?? false,
|
|
724
730
|
},
|
|
725
731
|
};
|
|
726
732
|
}
|
package/src/acp/events.ts
CHANGED
|
@@ -248,3 +248,20 @@ export class TextAccumulator {
|
|
|
248
248
|
return this.#acc;
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
const USAGE_KEY = /usage|tokens?/i;
|
|
253
|
+
|
|
254
|
+
/** Gate B watch: true when a server payload carries usage/token fields.
|
|
255
|
+
* Key-name based so future shapes (usage blocks, _meta.tokenCount, top-level
|
|
256
|
+
* token totals) all trip it. String values are exempt so auth-style "token"
|
|
257
|
+
* args inside tool-call frames cannot false-positive the latch; depth-capped
|
|
258
|
+
* so the walk stays cheap on every frame until it latches. */
|
|
259
|
+
export function frameCarriesUsage(value: unknown, depth = 0): boolean {
|
|
260
|
+
if (depth > 8 || typeof value !== "object" || value === null) return false;
|
|
261
|
+
if (Array.isArray(value)) return value.some((v) => frameCarriesUsage(v, depth + 1));
|
|
262
|
+
for (const [key, v] of Object.entries(value)) {
|
|
263
|
+
if (USAGE_KEY.test(key) && (typeof v === "number" || typeof v === "object")) return true;
|
|
264
|
+
if (frameCarriesUsage(v, depth + 1)) return true;
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
package/src/daily-log.ts
CHANGED
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
// redacted and long strings are truncated before they reach disk.
|
|
16
16
|
// - Retention: files older than `retentionDays` are pruned once per
|
|
17
17
|
// process, so the dir cannot grow unbounded.
|
|
18
|
-
// -
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// - Volume tiers: default installs write ONLY errors — routine logging
|
|
19
|
+
// costs the disk nothing. Warns surface as UI toasts instead (the
|
|
20
|
+
// extension wraps this logger); AGY_DEBUG=1 restores the full trail
|
|
21
|
+
// (debug/info/warn/error).
|
|
22
22
|
|
|
23
23
|
import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
24
24
|
import path from "node:path";
|
|
@@ -30,9 +30,9 @@ export interface DailyLoggerOptions {
|
|
|
30
30
|
dir: string;
|
|
31
31
|
/** Files older than this many days are pruned once per process. Default 14. */
|
|
32
32
|
retentionDays?: number;
|
|
33
|
-
/** Verbose gate. When false (default),
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
/** Verbose gate. When false (default), only error-level records land on
|
|
34
|
+
* disk; debug/info/warn are dropped so regular users write nothing
|
|
35
|
+
* routine. AGY_DEBUG=1 (or this option) restores the full trail. */
|
|
36
36
|
debug?: boolean;
|
|
37
37
|
/** Injectable clock for tests. */
|
|
38
38
|
now?: () => Date;
|
|
@@ -166,9 +166,10 @@ export function createDailyLogger(opts: DailyLoggerOptions): DailyLogger {
|
|
|
166
166
|
|
|
167
167
|
return {
|
|
168
168
|
log(event, data, level = "debug") {
|
|
169
|
-
// Volume gate:
|
|
170
|
-
//
|
|
171
|
-
|
|
169
|
+
// Volume gate: default installs write only errors so routine disk
|
|
170
|
+
// traffic is zero for regular users; the extension toasts warns
|
|
171
|
+
// instead. AGY_DEBUG=1 restores the full trail.
|
|
172
|
+
if (!verbose && level !== "error") return;
|
|
172
173
|
write(level, event, data);
|
|
173
174
|
},
|
|
174
175
|
flush() {
|
package/src/driver-types.ts
CHANGED
|
@@ -125,6 +125,10 @@ export interface DriverSnapshot {
|
|
|
125
125
|
/** From the initialize handshake agentInfo block. */
|
|
126
126
|
agentName?: string;
|
|
127
127
|
agentTitle?: string;
|
|
128
|
+
/** Gate B watch: true once this server process sent usage/token fields
|
|
129
|
+
* in any session/update frame. /agy doctor surfaces it when true and
|
|
130
|
+
* stays silent otherwise. */
|
|
131
|
+
usageSeen: boolean;
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
|
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
|
@@ -114,12 +114,14 @@ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
|
|
|
114
114
|
|
|
115
115
|
/** Brief tool-priority note appended inside every system prompt block: agy
|
|
116
116
|
* runs embedded in pi, so its native interactive tools never reach the
|
|
117
|
-
* user. Equivalent Pi Bridge tools must win. Concrete
|
|
118
|
-
* agy picked its native ask_question over the bridge's
|
|
119
|
-
* and the question never displayed
|
|
120
|
-
*
|
|
117
|
+
* user. Equivalent Pi Bridge tools must win. Concrete clashes observed
|
|
118
|
+
* live: agy picked its native ask_question over the bridge's
|
|
119
|
+
* ask_user_question and the question never displayed; on ACP its native
|
|
120
|
+
* view_file rejects real filesystem paths (artifact sandbox), so reads of
|
|
121
|
+
* the user's machine must go through the bridge. Rides the systemPrompt
|
|
122
|
+
* gate: the note ships only when the system prompt ships. */
|
|
121
123
|
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.]";
|
|
124
|
+
"[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. Your native file tools such as view_file only read brain artifacts and reject real filesystem paths; for any path on the user's machine use the Pi Bridge tools (read, ls, grep, find, edit, execute). 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
125
|
|
|
124
126
|
/** Assemble the full agy prompt: system prompt block, pi-side digest, user
|
|
125
127
|
* prompt. Empty parts are dropped. Pure; exported for unit testing.
|
|
@@ -385,21 +387,138 @@ export function toAgyEffort(
|
|
|
385
387
|
// agy continues its still-running turn. No pi patch, no privileged API.
|
|
386
388
|
|
|
387
389
|
const BRIDGE_TIMEOUT_MS = 480_000;
|
|
390
|
+
/** Bounded memory of failed bridge parks (late-delivery tombstones). */
|
|
391
|
+
const MAX_PARK_TOMBSTONES = 64;
|
|
388
392
|
|
|
389
393
|
export interface BridgeCallResultShape {
|
|
390
394
|
content: Array<{ type: string; text?: string }>;
|
|
391
395
|
isError: boolean;
|
|
392
396
|
}
|
|
393
397
|
|
|
398
|
+
/** Early-ack sentinel: onToolCall settles with this when the pi tool is still
|
|
399
|
+
* running after escalateAfterMs (~20s). agy's MCP client abandons a
|
|
400
|
+
* tools/call HTTP request at ~180s (observed; see ACP-PROTOCOL-REFERENCE), so
|
|
401
|
+
* slow calls must not hold the request. The bridge answers with
|
|
402
|
+
* formatEscalatedAck and the real result arrives via bridge_poll_result (or,
|
|
403
|
+
* if agy never polls, the late-delivery path). */
|
|
404
|
+
export interface BridgeEscalation {
|
|
405
|
+
escalated: true;
|
|
406
|
+
callId: string;
|
|
407
|
+
name: string;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export const POLL_TOOL_NAME = "bridge_poll_result";
|
|
411
|
+
|
|
412
|
+
/** Default quiet period before a park escalates to a poll handle. Well under
|
|
413
|
+
* agy's ~180s request deadline; fast tools never see it. */
|
|
414
|
+
export const ESCALATE_AFTER_MS = 20_000;
|
|
415
|
+
/** Escalated parks carry a longer TTL: human-gated tools (commit previews,
|
|
416
|
+
* permission dialogs) legitimately block for many minutes. */
|
|
417
|
+
export const ESCALATED_TIMEOUT_MS = 1_800_000;
|
|
418
|
+
|
|
419
|
+
export interface PollView {
|
|
420
|
+
state: "running" | "done" | "failed";
|
|
421
|
+
name: string;
|
|
422
|
+
text?: string;
|
|
423
|
+
isError?: boolean;
|
|
424
|
+
reason?: string;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Escalated bridge calls. Bounded: past the cap, oldest settled entries
|
|
428
|
+
* evict first (a running call is never evicted while a newer one is). */
|
|
429
|
+
export class EscalationRegistry {
|
|
430
|
+
#calls = new Map<string, PollView>();
|
|
431
|
+
#trim(): void {
|
|
432
|
+
// Soft cap: only settled entries evict. Evicting a RUNNING call would
|
|
433
|
+
// strand its result (settle becomes a no-op, poll reports unknown), so
|
|
434
|
+
// saturating the cap with in-flight calls grows the map instead.
|
|
435
|
+
while (this.#calls.size > MAX_PARK_TOMBSTONES) {
|
|
436
|
+
const victim = [...this.#calls.entries()].find(([, e]) => e.state !== "running")?.[0];
|
|
437
|
+
if (victim === undefined) break;
|
|
438
|
+
this.#calls.delete(victim);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
escalate(callId: string, name: string): void {
|
|
442
|
+
this.#calls.set(callId, { name, state: "running" });
|
|
443
|
+
this.#trim();
|
|
444
|
+
}
|
|
445
|
+
settleDone(callId: string, text: string, isError: boolean): void {
|
|
446
|
+
const e = this.#calls.get(callId);
|
|
447
|
+
if (!e) return;
|
|
448
|
+
e.state = "done";
|
|
449
|
+
e.text = text;
|
|
450
|
+
e.isError = isError;
|
|
451
|
+
this.#trim();
|
|
452
|
+
}
|
|
453
|
+
settleFailed(callId: string, reason: string): void {
|
|
454
|
+
const e = this.#calls.get(callId);
|
|
455
|
+
if (!e) return;
|
|
456
|
+
e.state = "failed";
|
|
457
|
+
e.reason = reason;
|
|
458
|
+
this.#trim();
|
|
459
|
+
}
|
|
460
|
+
poll(callId: string): PollView | undefined {
|
|
461
|
+
const e = this.#calls.get(callId);
|
|
462
|
+
return e ? { ...e } : undefined;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
|
|
467
|
+
return {
|
|
468
|
+
content: [
|
|
469
|
+
{
|
|
470
|
+
type: "text",
|
|
471
|
+
text: [
|
|
472
|
+
`STILL RUNNING: the pi tool "${e.name}" has not finished yet.`,
|
|
473
|
+
`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.`,
|
|
474
|
+
"This is not an error and nothing is lost: if you stop polling, the bridge re-delivers the result in a later turn.",
|
|
475
|
+
].join("\n"),
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
isError: false,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function formatPollAnswer(callId: string, view: PollView | undefined): BridgeCallResultShape {
|
|
483
|
+
if (!view) {
|
|
484
|
+
return {
|
|
485
|
+
content: [
|
|
486
|
+
{
|
|
487
|
+
type: "text",
|
|
488
|
+
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.`,
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
isError: true,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (view.state === "running") {
|
|
495
|
+
return {
|
|
496
|
+
content: [{ type: "text", text: `STILL RUNNING: "${view.name}" (callId ${callId}) has not finished. Poll again later.` }],
|
|
497
|
+
isError: false,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (view.state === "failed") {
|
|
501
|
+
return {
|
|
502
|
+
content: [{ type: "text", text: `Error: bridge call "${view.name}" (callId ${callId}) failed: ${view.reason}` }],
|
|
503
|
+
isError: true,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
return { content: [{ type: "text", text: view.text || "(no output)" }], isError: view.isError ?? false };
|
|
507
|
+
}
|
|
508
|
+
|
|
394
509
|
interface PendingRoundTrip {
|
|
395
510
|
/** "bridge": parked MCP HTTP call; resolve() completes it.
|
|
396
511
|
* "rt": native re-exec / wrapper round-trip; pi already executed, the
|
|
397
512
|
* toolResult only confirms continuation, nothing remote to settle. */
|
|
398
513
|
kind: "bridge" | "rt";
|
|
399
514
|
name: string;
|
|
400
|
-
resolve?: (r: BridgeCallResultShape) => void;
|
|
515
|
+
resolve?: (r: BridgeCallResultShape | BridgeEscalation) => void;
|
|
401
516
|
reject?: (e: Error) => void;
|
|
402
517
|
timer?: NodeJS.Timeout;
|
|
518
|
+
/** Set when the early-ack fired: the HTTP request was answered with a poll
|
|
519
|
+
* handle, so the settling value must go to the registry, not the socket. */
|
|
520
|
+
escalated?: boolean;
|
|
521
|
+
escalateTimer?: NodeJS.Timeout;
|
|
403
522
|
onAbort?: () => void;
|
|
404
523
|
signal?: AbortSignal;
|
|
405
524
|
}
|
|
@@ -430,23 +549,61 @@ export class WrapperReplay {
|
|
|
430
549
|
|
|
431
550
|
export class ToolRoundTrips {
|
|
432
551
|
#pending = new Map<string, PendingRoundTrip>();
|
|
552
|
+
/** Failed bridge parks: the pi tool keeps running and its toolResult will
|
|
553
|
+
* arrive with the park already gone. Bounded; consumed by the
|
|
554
|
+
* late-delivery path (see buildLateResultPrompt). */
|
|
555
|
+
#dead = new Map<string, { name: string; reason: string }>();
|
|
556
|
+
#escalations = new EscalationRegistry();
|
|
557
|
+
#escalateAfterMs: number;
|
|
433
558
|
#getDriver: () => TurnDriver;
|
|
434
559
|
#log: (s: string, d?: unknown) => void;
|
|
435
560
|
|
|
436
561
|
/** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
|
|
437
562
|
* is resolved at call time from config (plan §9.5). */
|
|
438
|
-
constructor(
|
|
563
|
+
constructor(
|
|
564
|
+
driver: TurnDriver | (() => TurnDriver),
|
|
565
|
+
log?: (s: string, d?: unknown) => void,
|
|
566
|
+
opts: { escalateAfterMs?: number } = {},
|
|
567
|
+
) {
|
|
439
568
|
this.#getDriver = typeof driver === "function" ? driver : () => driver;
|
|
440
569
|
this.#log = log ?? (() => {});
|
|
570
|
+
this.#escalateAfterMs = opts.escalateAfterMs ?? ESCALATE_AFTER_MS;
|
|
441
571
|
}
|
|
442
572
|
|
|
443
573
|
get pendingIds(): string[] {
|
|
444
574
|
return [...this.#pending.keys()];
|
|
445
575
|
}
|
|
446
576
|
|
|
447
|
-
/**
|
|
577
|
+
/** Call ids whose park already failed (tombstones). */
|
|
578
|
+
get deadIds(): string[] {
|
|
579
|
+
return [...this.#dead.keys()];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Take and clear the tombstone for a failed park, if any. */
|
|
583
|
+
consumeDead(toolCallId: string): { name: string; reason: string } | undefined {
|
|
584
|
+
const dead = this.#dead.get(toolCallId);
|
|
585
|
+
if (!dead) return undefined;
|
|
586
|
+
this.#dead.delete(toolCallId);
|
|
587
|
+
return dead;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Poll view for an escalated call (undefined when the id never escalated:
|
|
591
|
+
* fast calls settle synchronously and need no handle). */
|
|
592
|
+
poll(callId: string): PollView | undefined {
|
|
593
|
+
return this.#escalations.poll(callId);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Fail all pending calls (driver recycle/shutdown path). Escalated bridge
|
|
597
|
+
* calls are skipped: their HTTP request was already answered with a poll
|
|
598
|
+
* handle, and the agy turn ending does NOT make the still-running pi tool
|
|
599
|
+
* a failure. They settle through resolve(), their own 30m timer, or an
|
|
600
|
+
* abort signal on the pi tool call. */
|
|
448
601
|
failAll(reason: string): void {
|
|
449
|
-
for (const id of [...this.#pending.keys()])
|
|
602
|
+
for (const id of [...this.#pending.keys()]) {
|
|
603
|
+
const entry = this.#pending.get(id);
|
|
604
|
+
if (entry?.kind === "bridge" && entry.escalated) continue;
|
|
605
|
+
this.#fail(id, reason);
|
|
606
|
+
}
|
|
450
607
|
}
|
|
451
608
|
|
|
452
609
|
#fail(callId: string, reason: string): void {
|
|
@@ -459,20 +616,33 @@ export class ToolRoundTrips {
|
|
|
459
616
|
}
|
|
460
617
|
this.#pending.delete(callId);
|
|
461
618
|
clearTimeout(entry.timer);
|
|
619
|
+
if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
|
|
462
620
|
if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
|
|
621
|
+
this.#dead.set(callId, { name: entry.name, reason });
|
|
622
|
+
while (this.#dead.size > MAX_PARK_TOMBSTONES) {
|
|
623
|
+
const oldest = this.#dead.keys().next().value;
|
|
624
|
+
if (oldest === undefined) break;
|
|
625
|
+
this.#dead.delete(oldest);
|
|
626
|
+
}
|
|
627
|
+
if (entry.escalated) this.#escalations.settleFailed(callId, reason);
|
|
463
628
|
entry.reject!(new Error(reason));
|
|
464
629
|
this.#getDriver().kickIdle();
|
|
465
630
|
this.#log("round-trip-fail", { callId, name: entry.name, reason });
|
|
466
631
|
}
|
|
467
632
|
|
|
468
|
-
/** Park the MCP call: inject into the live agy turn
|
|
469
|
-
*
|
|
633
|
+
/** Park the MCP call: inject into the live agy turn. Fast calls settle
|
|
634
|
+
* with the real BridgeCallResultShape. Calls still running after
|
|
635
|
+
* escalateAfterMs settle with a BridgeEscalation sentinel instead: the
|
|
636
|
+
* bridge answers the HTTP request with a poll handle while pi keeps
|
|
637
|
+
* executing, so agy's ~180s request deadline is never hit. The real
|
|
638
|
+
* result reaches agy via bridge_poll_result, or via the late-delivery
|
|
639
|
+
* path if agy never polls. Fail-closed: timeout/abort still reject. */
|
|
470
640
|
onToolCall = (
|
|
471
641
|
callId: string,
|
|
472
642
|
name: string,
|
|
473
643
|
args: Record<string, unknown>,
|
|
474
644
|
signal: AbortSignal,
|
|
475
|
-
): Promise<BridgeCallResultShape> => {
|
|
645
|
+
): Promise<BridgeCallResultShape | BridgeEscalation> => {
|
|
476
646
|
const handle = this.#getDriver().activeHandle;
|
|
477
647
|
if (!handle) {
|
|
478
648
|
return Promise.reject(
|
|
@@ -481,13 +651,32 @@ export class ToolRoundTrips {
|
|
|
481
651
|
),
|
|
482
652
|
);
|
|
483
653
|
}
|
|
484
|
-
return new Promise<BridgeCallResultShape>((resolve, reject) => {
|
|
654
|
+
return new Promise<BridgeCallResultShape | BridgeEscalation>((resolve, reject) => {
|
|
485
655
|
const timer = setTimeout(() => {
|
|
486
656
|
this.#fail(callId, `pi tool round-trip timed out after ${BRIDGE_TIMEOUT_MS / 1000}s`);
|
|
487
657
|
}, BRIDGE_TIMEOUT_MS);
|
|
488
658
|
const onAbort = () => this.#fail(callId, "agy disconnected before the tool result arrived");
|
|
489
659
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
490
|
-
|
|
660
|
+
const entry: PendingRoundTrip = { kind: "bridge", name, resolve, reject, timer, onAbort, signal };
|
|
661
|
+
if (this.#escalateAfterMs > 0) {
|
|
662
|
+
entry.escalateTimer = setTimeout(() => {
|
|
663
|
+
const e = this.#pending.get(callId);
|
|
664
|
+
// Resolved (or failed) between arm and fire: nothing to escalate.
|
|
665
|
+
if (!e || e.kind !== "bridge") return;
|
|
666
|
+
e.escalated = true;
|
|
667
|
+
// Human-gated calls (commit previews, permission dialogs) can
|
|
668
|
+
// block far longer than the standard park TTL; re-arm generously.
|
|
669
|
+
if (e.timer) {
|
|
670
|
+
clearTimeout(e.timer);
|
|
671
|
+
e.timer = setTimeout(() => {
|
|
672
|
+
this.#fail(callId, `escalated bridge call timed out after ${ESCALATED_TIMEOUT_MS / 60_000} minutes`);
|
|
673
|
+
}, ESCALATED_TIMEOUT_MS);
|
|
674
|
+
}
|
|
675
|
+
this.#escalations.escalate(callId, e.name);
|
|
676
|
+
resolve({ escalated: true, callId, name: e.name });
|
|
677
|
+
}, this.#escalateAfterMs);
|
|
678
|
+
}
|
|
679
|
+
this.#pending.set(callId, entry);
|
|
491
680
|
handle.pushExternal({ type: "bridge_call", callId, name, args });
|
|
492
681
|
});
|
|
493
682
|
};
|
|
@@ -505,12 +694,21 @@ export class ToolRoundTrips {
|
|
|
505
694
|
if (!entry) return false;
|
|
506
695
|
this.#pending.delete(toolCallId);
|
|
507
696
|
clearTimeout(entry.timer);
|
|
697
|
+
if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
|
|
508
698
|
if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
|
|
509
699
|
if (entry.kind === "rt") {
|
|
510
700
|
this.#log("round-trip-rt-done", { callId: toolCallId, name: entry.name, isError });
|
|
511
701
|
return true;
|
|
512
702
|
}
|
|
513
|
-
|
|
703
|
+
// Escalated call: the HTTP response already carried the poll handle, so
|
|
704
|
+
// the result lands in the registry for the next bridge_poll_result. The
|
|
705
|
+
// original promise settled with the sentinel; re-resolving is a silent
|
|
706
|
+
// no-op, so gate it to keep that explicit.
|
|
707
|
+
if (entry.escalated) {
|
|
708
|
+
this.#escalations.settleDone(toolCallId, text, isError);
|
|
709
|
+
} else {
|
|
710
|
+
entry.resolve!({ content: [{ type: "text", text }], isError });
|
|
711
|
+
}
|
|
514
712
|
this.#getDriver().kickIdle();
|
|
515
713
|
this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
|
|
516
714
|
return true;
|
|
@@ -534,6 +732,29 @@ export function collectToolResults(
|
|
|
534
732
|
return out;
|
|
535
733
|
}
|
|
536
734
|
|
|
735
|
+
export interface LateToolResult {
|
|
736
|
+
name: string;
|
|
737
|
+
reason: string;
|
|
738
|
+
text: string;
|
|
739
|
+
isError: boolean;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Frame late tool results so agy treats them as the results its bridge calls
|
|
743
|
+
* never received (the round-trip died while the pi tool was still running,
|
|
744
|
+
* e.g. agy's ~180s MCP client timeout on tools/call). */
|
|
745
|
+
export function buildLateResultPrompt(late: LateToolResult[], userPrompt?: string): string {
|
|
746
|
+
const blocks = late.map((r) =>
|
|
747
|
+
[
|
|
748
|
+
`pi tool "${r.name}": the bridge round-trip expired before this result reached you (${r.reason}).`,
|
|
749
|
+
r.isError ? "The tool reported an error:" : "Result:",
|
|
750
|
+
r.text.trim() || "(no output)",
|
|
751
|
+
].join("\n"),
|
|
752
|
+
);
|
|
753
|
+
const header = "Late tool delivery: treat the following as the results of your earlier tool calls.";
|
|
754
|
+
const body = [header, ...blocks].join("\n\n");
|
|
755
|
+
return userPrompt ? `${body}\n\n${userPrompt}` : body;
|
|
756
|
+
}
|
|
757
|
+
|
|
537
758
|
// --- stream-json engine -------------------------------------------------------
|
|
538
759
|
|
|
539
760
|
export interface DriverDeps {
|
|
@@ -739,12 +960,47 @@ async function runTurnDriver(
|
|
|
739
960
|
// agy receives the result via the bridge's MCP HTTP response.
|
|
740
961
|
const results = collectToolResults(context.messages, deps.roundTrips.pendingIds);
|
|
741
962
|
const isContinuation = results.length > 0;
|
|
963
|
+
// Escalated calls answer through bridge_poll_result, not through an agy
|
|
964
|
+
// turn waiting on the park, so note them before resolving.
|
|
965
|
+
const escalatedNames = results
|
|
966
|
+
.map((r) => deps.roundTrips.poll(r.toolCallId)?.name)
|
|
967
|
+
.filter((n): n is string => Boolean(n));
|
|
742
968
|
for (const r of results) deps.roundTrips.resolve(r.toolCallId, r.text, r.isError);
|
|
743
969
|
|
|
970
|
+
// Late delivery: a toolResult whose park already failed (the abort/timeout
|
|
971
|
+
// path failed the park while the pi tool kept running). The work is done,
|
|
972
|
+
// so re-route the result to agy as a new prompt in the same conversation
|
|
973
|
+
// instead of dropping it. Both drivers serialize run(), so delivery queues
|
|
974
|
+
// behind agy's own salvaged turn when one is still active.
|
|
975
|
+
// A pass that anchors a still-pending park (isContinuation) has nowhere to
|
|
976
|
+
// put a late result: it can neither ride the pending call's HTTP response
|
|
977
|
+
// nor start a new prompt. Leave the tombstone for the next fresh pass
|
|
978
|
+
// instead of consuming it blind.
|
|
979
|
+
const late: LateToolResult[] = [];
|
|
980
|
+
if (!isContinuation) {
|
|
981
|
+
for (const r of collectToolResults(context.messages, deps.roundTrips.deadIds)) {
|
|
982
|
+
const dead = deps.roundTrips.consumeDead(r.toolCallId);
|
|
983
|
+
if (dead) late.push({ name: dead.name, reason: dead.reason, text: r.text, isError: r.isError });
|
|
984
|
+
}
|
|
985
|
+
if (late.length > 0) {
|
|
986
|
+
deps.log?.("late-result", { tools: late.map((l) => l.name), freshConversation: !existing?.conversationId }, "info");
|
|
987
|
+
}
|
|
988
|
+
} else if (deps.roundTrips.deadIds.length > 0) {
|
|
989
|
+
deps.log?.("late-result-deferred", { count: deps.roundTrips.deadIds.length }, "info");
|
|
990
|
+
}
|
|
991
|
+
|
|
744
992
|
let handle: TurnHandle;
|
|
745
993
|
if (isContinuation) {
|
|
746
994
|
const active = deps.driver.reentry();
|
|
747
995
|
if (!active) {
|
|
996
|
+
// Escalated calls have no turn to re-enter BY DESIGN: agy already
|
|
997
|
+
// got the poll handle and the result lives in the registry. Settle
|
|
998
|
+
// quietly instead of erroring the turn.
|
|
999
|
+
if (escalatedNames.length > 0) {
|
|
1000
|
+
appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished; the result is available via ${POLL_TOOL_NAME}.`);
|
|
1001
|
+
finalize(stream, blocks, "stop");
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
748
1004
|
deps.log?.("turn-error", { reason: "tool-result-no-active-turn" }, "warn");
|
|
749
1005
|
finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
|
|
750
1006
|
return;
|
|
@@ -754,8 +1010,9 @@ async function runTurnDriver(
|
|
|
754
1010
|
const prompt = extractUserPrompt(context);
|
|
755
1011
|
const images = extractImages(context);
|
|
756
1012
|
// An image-only message (no text) is valid on the ACP engine; only fail
|
|
757
|
-
// when there is nothing at all to send
|
|
758
|
-
|
|
1013
|
+
// when there is nothing at all to send (no text, no images, no late
|
|
1014
|
+
// tool results to deliver).
|
|
1015
|
+
if (!prompt && images.length === 0 && late.length === 0) {
|
|
759
1016
|
deps.log?.("turn-error", { reason: "no-user-message" }, "debug");
|
|
760
1017
|
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
761
1018
|
return;
|
|
@@ -764,7 +1021,9 @@ async function runTurnDriver(
|
|
|
764
1021
|
const agyModel = entry?.full ?? model.id;
|
|
765
1022
|
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
766
1023
|
const watermark = existing?.lastMessageCount ?? 0;
|
|
767
|
-
|
|
1024
|
+
// Late turns re-open the conversation with a synthetic prompt; the digest
|
|
1025
|
+
// would re-send context agy already holds, so skip it.
|
|
1026
|
+
const digest = config.digest && late.length === 0 ? buildContextDigest(context.messages, watermark) : "";
|
|
768
1027
|
// G1 delivery per engine. stream-json: digest rides inline in the prompt
|
|
769
1028
|
// (the CLI has no context channel). ACP: the server advertises
|
|
770
1029
|
// `embeddedContext`, so the digest ships as a native resource block
|
|
@@ -778,7 +1037,10 @@ async function runTurnDriver(
|
|
|
778
1037
|
// re-sending it every turn would bloat each prompt and bust the cache.
|
|
779
1038
|
const sysPrompt =
|
|
780
1039
|
config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
|
|
781
|
-
const fullPrompt =
|
|
1040
|
+
const fullPrompt =
|
|
1041
|
+
late.length > 0
|
|
1042
|
+
? buildLateResultPrompt(late, prompt || undefined)
|
|
1043
|
+
: buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
|
|
782
1044
|
try {
|
|
783
1045
|
handle = await deps.driver.run({
|
|
784
1046
|
cwd,
|