@estebanforge/pi-antigravity-bridge 1.8.6 → 1.8.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 +16 -0
- package/README.md +1 -1
- package/extensions/index.ts +51 -6
- package/package.json +1 -1
- package/src/acp/driver.ts +7 -0
- package/src/acp/events.ts +40 -14
- package/src/acp/setup.ts +7 -1
- package/src/driver-types.ts +2 -1
- package/src/driver.ts +45 -13
- package/src/models.ts +4 -6
- package/src/poll-fold.ts +55 -0
- package/src/prompt-images.ts +92 -0
- package/src/provider.ts +115 -14
- package/src/queued-messages.ts +171 -0
- package/src/stream-sanitizer.ts +180 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.8.8] - 2026-10-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Short-circuit observational-memory background turns instead of letting Gemini hunt the missing observer tool.** pi-observational-memory's turn-end observer falls back to the session model, and its prompt demands a private `record_observations` tool the bridge never sends; on antigravity models that spawned autonomous `find`/`grep`/`sed` shell sweeps (~500 ms per cycle) hunting the tool before reporting "observer found nothing new in this chunk". `streamSimple` now detects the observer context structurally (its `record_observations`/`record_reflections`/`drop_observations` tool names) and finalizes with a clean empty stop, which engages the observer's empty-backoff instead of erroring every turn; a one-time per-process `observational-memory-unsupported` warning explains the gap.
|
|
10
|
+
|
|
11
|
+
- **Strip Antigravity's echoed `<SYSTEM_MESSAGE>` notifications and default Queued Messages to send-immediately.** The backend injects async background-task notifications wrapped in `<SYSTEM_MESSAGE>` blocks, and Gemini (notably 3.8 Flash) echoes them verbatim as assistant text; a stateful streaming sanitizer (`src/stream-sanitizer.ts`) now suppresses the exact preamble+block pattern in both engines' text paths, failing open at EOF so legitimate text is never lost. Separately, the engines' default "Queued Messages" behavior buffered user input until turn end while pi sends interactive input mid-turn; the bridge now writes `"queuedMessages": "send-immediately"` to each engine's settings.json when unset (atomic, existing config respected, corrupt files never touched).
|
|
12
|
+
|
|
13
|
+
- **Deliver image pixels across all the paths that used to lose them.** Verified live 2026-10-22 (couch session): gemini-3.8-flash read a screenshot, the parked bridge call escalated, the model never polled `bridge_poll_result`, and the stranded redelivery carried text only — the image never reached it and the model fell back to hunting OCR libraries. Late/stranded redeliveries now carry image blocks via `driver.run` (ACP native prompt blocks; stream-json stages them), user-attached images on the stream-json engine stage to temp files with a prompt note pointing at the bridge read tool (the CLI turn input is text-only; `src/prompt-images.ts`), and both engines now advertise `text+image` input. The tool-priority note states outright that `read` returns visible images (models refused screenshots from priors) and mandates polling `bridge_poll_result` before other work after a STILL RUNNING ack. Staging failures degrade to an explicit "image(s) could not be delivered" note instead of dropping silently.
|
|
14
|
+
|
|
15
|
+
## [1.8.7] - 2026-10-21
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Consecutive `bridge_poll_result` polls collapse into one counted transcript line.** Each poll used to append its own display-only entry (observed live: 60 `Poll git_commit` entries from a single wait, flushed in bursts when the parked round-trip resolved). Poll events now fold into a single ♊︎-marked `agy-poll-run` entry per consecutive run (`♊︎ bridge_poll_result Poll git_commit (x30)`; expanded view shows the poll span) with a live dim status counter while a run is open. Runs close on a target switch, the next non-poll display event, turn end, and agent end. Bridge footer statuses (`agy-native`, `agy-usage`, the poll counter) now render muted, matching pi-hostname's dim treatment.
|
|
20
|
+
|
|
5
21
|
## [1.8.6] - 2026-10-20
|
|
6
22
|
|
|
7
23
|
### Changed
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ The bridge starts a localhost MCP server inside pi's process. The exposed catalo
|
|
|
42
42
|
|
|
43
43
|
**Upstream pi APIs only.** 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.
|
|
44
44
|
|
|
45
|
-
**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. Delegation-shaped tools (pi subagent/delegation tools, e.g. `subagent_run`, `agent_spawn`) hold the synchronous window 120s instead (issue #6: orchestrators expect a plain result across the 30-120s subagent range, and 120s stays under the 180s wall); `escalateLongTools` in config.json adds custom names. 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.
|
|
45
|
+
**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. Delegation-shaped tools (pi subagent/delegation tools, e.g. `subagent_run`, `agent_spawn`) hold the synchronous window 120s instead (issue #6: orchestrators expect a plain result across the 30-120s subagent range, and 120s stays under the 180s wall); `escalateLongTools` in config.json adds custom names. 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. Consecutive polls render as ONE ♊︎-marked counted line (`♊︎ bridge_poll_result Poll git_commit (x30)`) instead of one entry per poll, with a live muted status counter while a run is open.
|
|
46
46
|
|
|
47
47
|
**Recursion safety.** Only the provider's agy receives the extra `--add-dir`. `AskAntigravity` is also filtered from the exposed tool list. Its delegated `agy -p` spawns with just the workspace, and for the whole delegated run the bridge hides its global per-pid entries: disabled before the spawn, released only on process close or error - agy watches the config and hot-reloads MCP servers on file changes, so a mid-run re-enable would poke the live delegation back into pi. A cross-process marker (`suppression.json` in the bridge's extensions-data dir) coordinates concurrent delegations: entries come back only when no live delegation remains, a session starting mid-delegation registers its own entry disabled, and the session-start heal re-enables only once the last delegator is gone (dead entries pruned by pid liveness with a 24h age bound). The inner agy therefore sees no bridge and cannot re-enter pi. Foreign servers in the global config are never touched.
|
|
48
48
|
|
package/extensions/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
formatPollAnswer,
|
|
49
49
|
type NativeDisplayEvent,
|
|
50
50
|
} from "../src/provider.js";
|
|
51
|
+
import { PollRunFolder, type PollRunData } from "../src/poll-fold.js";
|
|
51
52
|
import {
|
|
52
53
|
createShadowTool,
|
|
53
54
|
stripMarkerFields,
|
|
@@ -62,6 +63,7 @@ import { runAcpAuth } from "../src/acp/auth.js";
|
|
|
62
63
|
import { setAuthInvalidatedHook } from "../src/acp/connection.js";
|
|
63
64
|
import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
|
|
64
65
|
import { clearAcpAuthVerified, ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
|
|
66
|
+
import { ensureQueuedMessagesDefaults } from "../src/queued-messages.js";
|
|
65
67
|
import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
|
|
66
68
|
import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
67
69
|
import { agyMissingMessage, ENGINE_SETTINGS_ROW_ID, engineSettingsRow, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
|
|
@@ -116,6 +118,8 @@ function resolveAgyBinary(): string {
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
121
|
+
ensureQueuedMessagesDefaults({ onlyIfDirExists: true });
|
|
122
|
+
|
|
119
123
|
// Claim the AskAntigravity tool for this process. pi-ask-antigravity (if also
|
|
120
124
|
// installed) checks this in-process flag OR the bridge's package.json on disk
|
|
121
125
|
// and defers. See that extension's isBridgeInstalled().
|
|
@@ -143,9 +147,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
143
147
|
// kickIdle, and reentry pointing at the other engine (round-7 finding).
|
|
144
148
|
const engine: Engine = loadConfig().engine;
|
|
145
149
|
// Engine switching requires a restart, so the catalog-time engine read is
|
|
146
|
-
// authoritative for input advertising
|
|
147
|
-
//
|
|
148
|
-
|
|
150
|
+
// authoritative for input advertising. Both engines accept images: ACP
|
|
151
|
+
// rides native content blocks; stream-json stages user attachments to
|
|
152
|
+
// temp files and the prompt points the model at them (prompt-images.ts).
|
|
153
|
+
const modelInput: Array<"text" | "image"> = ["text", "image"];
|
|
149
154
|
const models = entries.map((e) => toPiModel(e, modelInput));
|
|
150
155
|
|
|
151
156
|
// failAll reasons the bridge treats as routine (turn end / shutdown
|
|
@@ -396,15 +401,52 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
396
401
|
}
|
|
397
402
|
return new Text(lines.join("\n"), 0, 0);
|
|
398
403
|
});
|
|
404
|
+
// Collapsed poll runs: one ♊︎-marked counted line per consecutive poll
|
|
405
|
+
// burst instead of one entry per poll.
|
|
406
|
+
pi.registerEntryRenderer<PollRunData>("agy-poll-run", (entry, { expanded }, theme) => {
|
|
407
|
+
const run = entry.data;
|
|
408
|
+
const count = run?.count ?? 0;
|
|
409
|
+
const label = `Poll ${run?.target ?? "bridge call"} (x${count}${run?.state === "failed" ? ", failed" : ""})`;
|
|
410
|
+
const lines = [`♊︎ ${theme.fg("toolTitle", POLL_TOOL_NAME)} ${theme.fg("muted", label)}`];
|
|
411
|
+
if (expanded && run) {
|
|
412
|
+
const time = (iso: string): string => new Date(iso).toLocaleTimeString();
|
|
413
|
+
lines.push(theme.fg("dim", `${count} ${count === 1 ? "poll" : "polls"}, ${time(run.firstAt)} → ${time(run.lastAt)}`));
|
|
414
|
+
}
|
|
415
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
416
|
+
});
|
|
399
417
|
}
|
|
400
418
|
let pendingNativeTools = 0;
|
|
419
|
+
const pollRuns = new PollRunFolder();
|
|
420
|
+
const closePollRun = (): void => {
|
|
421
|
+
const settled = pollRuns.flush();
|
|
422
|
+
if (settled) pi.appendEntry<PollRunData>("agy-poll-run", settled);
|
|
423
|
+
setDimStatus("agy-poll", undefined);
|
|
424
|
+
};
|
|
425
|
+
// Footer statuses render muted, matching pi-hostname's dim treatment.
|
|
426
|
+
const setDimStatus = (key: string, text: string | undefined): void => {
|
|
427
|
+
const ui = activeUi;
|
|
428
|
+
if (!ui) return;
|
|
429
|
+
ui.setStatus(key, text === undefined ? undefined : ui.theme.fg("dim", text));
|
|
430
|
+
};
|
|
401
431
|
const onNativeEvent = (event: NativeDisplayEvent): void => {
|
|
402
|
-
if (engine !== "acp"
|
|
432
|
+
if (engine !== "acp") return;
|
|
433
|
+
// Polls are bridge-local (never get a genuine Pi card); the mcpServer
|
|
434
|
+
// tag is unreliable on these frames, so the tool name discriminates.
|
|
435
|
+
if (event.name === POLL_TOOL_NAME) {
|
|
436
|
+
const view = event.callId ? roundTrips.poll(event.callId) : undefined;
|
|
437
|
+
const switched = pollRuns.add(view?.name ?? "unknown call", view?.state ?? "running", new Date());
|
|
438
|
+
if (switched) pi.appendEntry<PollRunData>("agy-poll-run", switched);
|
|
439
|
+
const open = pollRuns.open;
|
|
440
|
+
setDimStatus("agy-poll", open ? `♊︎ bridge_poll_result → ${open.target} (x${open.count})` : undefined);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
closePollRun();
|
|
444
|
+
if (event.mcpServer === "pi-bridge") return;
|
|
403
445
|
// MCP calls through our own pi-bridge already have genuine Pi tool cards;
|
|
404
446
|
// only native Antigravity / other MCP events need display-only entries.
|
|
405
447
|
if (event.status === "started") {
|
|
406
448
|
pendingNativeTools++;
|
|
407
|
-
|
|
449
|
+
setDimStatus("agy-native", `agy ${event.name}… (${pendingNativeTools})`);
|
|
408
450
|
return;
|
|
409
451
|
}
|
|
410
452
|
pendingNativeTools = Math.max(0, pendingNativeTools - 1);
|
|
@@ -439,6 +481,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
439
481
|
outcome.status === "OK" ? "info" : "warn",
|
|
440
482
|
);
|
|
441
483
|
roundTrips.failAll(FAIL_REASON_TURN_END);
|
|
484
|
+
// Closes a poll run that no display event flushed.
|
|
485
|
+
closePollRun();
|
|
442
486
|
// Stamp live escalations with the ended turn's conversation: the park
|
|
443
487
|
// pass returns before the provider's store.set, so a first-turn park
|
|
444
488
|
// has no other record of where its result must be re-delivered.
|
|
@@ -454,7 +498,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
454
498
|
pi.on("message_update", (event, ctx) => {
|
|
455
499
|
if (!ctx.hasUI || event.message.role !== "assistant" || event.message.provider !== "antigravity" || loadConfig().acp.usageEstimate === "off") return;
|
|
456
500
|
const { input, output } = event.message.usage;
|
|
457
|
-
const status = `ACP ≈ ${input} in / ${output} out
|
|
501
|
+
const status = ctx.ui.theme.fg("dim", `ACP ≈ ${input} in / ${output} out`);
|
|
458
502
|
if (status !== lastUsageStatus) {
|
|
459
503
|
lastUsageStatus = status;
|
|
460
504
|
ctx.ui.setStatus("agy-usage", status);
|
|
@@ -467,6 +511,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
467
511
|
pi.on("agent_end", (event, ctx) => {
|
|
468
512
|
clearUsageStatus(event, ctx);
|
|
469
513
|
pendingNativeTools = 0;
|
|
514
|
+
closePollRun();
|
|
470
515
|
if (ctx.hasUI) ctx.ui.setStatus("agy-native", undefined);
|
|
471
516
|
});
|
|
472
517
|
pi.on("session_shutdown", (event, ctx) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.8",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/acp/driver.ts
CHANGED
|
@@ -736,6 +736,13 @@ export class AcpDriver implements TurnDriver {
|
|
|
736
736
|
|
|
737
737
|
#settle(turn: ActiveTurn, outcome: TurnOutcome): void {
|
|
738
738
|
if (turn.closed) return;
|
|
739
|
+
const flushed = turn.response.flush();
|
|
740
|
+
if (flushed) {
|
|
741
|
+
turn.textDeltas += 1;
|
|
742
|
+
turn.textTokens += estimateTokens(flushed);
|
|
743
|
+
this.#emit(turn, { type: "text", delta: flushed });
|
|
744
|
+
}
|
|
745
|
+
outcome.response = turn.response.text;
|
|
739
746
|
// Gate B stopgap: synthesize usage on clean turns while the server sends
|
|
740
747
|
// none. Runs BEFORE close so the usage activity drains through the
|
|
741
748
|
// normal #nextActivity loop (provider maps it onto partial.usage).
|
package/src/acp/events.ts
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// - available_commands_update { availableCommands: [...] }
|
|
15
15
|
// Usage: ABSENT on RC01 (Gate B) — mapped defensively should a future build add it.
|
|
16
16
|
|
|
17
|
+
import { StreamSanitizer, sanitizeSystemMessage } from "../stream-sanitizer.js";
|
|
18
|
+
|
|
19
|
+
export { StreamSanitizer, sanitizeSystemMessage };
|
|
20
|
+
|
|
17
21
|
/** Map one `update` object to a driver-level event, or null when the update
|
|
18
22
|
* carries nothing the driver consumes (plan/commands are doctor/phase-2
|
|
19
23
|
* material; user_message_chunk is load replay and must never reach pi as
|
|
@@ -218,38 +222,60 @@ export function mapStopReason(stopReason: unknown): {
|
|
|
218
222
|
const FLIP_MIN_CHARS = 32;
|
|
219
223
|
|
|
220
224
|
export class TextAccumulator {
|
|
221
|
-
#
|
|
225
|
+
#rawAcc = "";
|
|
226
|
+
#sanitizedAcc = "";
|
|
222
227
|
#cumulative: boolean | undefined;
|
|
228
|
+
#sanitizer = new StreamSanitizer();
|
|
223
229
|
|
|
224
230
|
/** Returns the delta to emit, or null when nothing new should be emitted. */
|
|
225
231
|
append(delta: string): string | null {
|
|
232
|
+
let rawEmit: string | null = null;
|
|
226
233
|
if (this.#cumulative === undefined) this.#cumulative = false;
|
|
227
234
|
else if (
|
|
228
235
|
!this.#cumulative &&
|
|
229
|
-
this.#
|
|
230
|
-
delta.length > this.#
|
|
231
|
-
delta.startsWith(this.#
|
|
236
|
+
this.#rawAcc.length >= FLIP_MIN_CHARS &&
|
|
237
|
+
delta.length > this.#rawAcc.length &&
|
|
238
|
+
delta.startsWith(this.#rawAcc)
|
|
232
239
|
) {
|
|
233
240
|
this.#cumulative = true;
|
|
234
241
|
}
|
|
235
242
|
if (this.#cumulative) {
|
|
236
|
-
if (!delta.startsWith(this.#
|
|
243
|
+
if (!delta.startsWith(this.#rawAcc)) {
|
|
237
244
|
// Misflip evidence: back to deltas.
|
|
238
245
|
this.#cumulative = false;
|
|
239
|
-
this.#
|
|
240
|
-
|
|
246
|
+
this.#rawAcc += delta;
|
|
247
|
+
rawEmit = delta;
|
|
248
|
+
} else if (delta.length <= this.#rawAcc.length) {
|
|
249
|
+
return null; // duplicate frame
|
|
250
|
+
} else {
|
|
251
|
+
rawEmit = delta.slice(this.#rawAcc.length);
|
|
252
|
+
this.#rawAcc = delta;
|
|
241
253
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
254
|
+
} else {
|
|
255
|
+
this.#rawAcc += delta;
|
|
256
|
+
rawEmit = delta;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (rawEmit === null) return null;
|
|
260
|
+
const sanitized = this.#sanitizer.push(rawEmit);
|
|
261
|
+
if (sanitized.length > 0) {
|
|
262
|
+
this.#sanitizedAcc += sanitized;
|
|
263
|
+
return sanitized;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
flush(): string | null {
|
|
269
|
+
const flushed = this.#sanitizer.flush();
|
|
270
|
+
if (flushed.length > 0) {
|
|
271
|
+
this.#sanitizedAcc += flushed;
|
|
272
|
+
return flushed;
|
|
246
273
|
}
|
|
247
|
-
|
|
248
|
-
return delta;
|
|
274
|
+
return null;
|
|
249
275
|
}
|
|
250
276
|
|
|
251
277
|
get text(): string {
|
|
252
|
-
return this.#
|
|
278
|
+
return this.#sanitizedAcc;
|
|
253
279
|
}
|
|
254
280
|
}
|
|
255
281
|
|
package/src/acp/setup.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { createHash } from "node:crypto";
|
|
|
16
16
|
import fs from "node:fs";
|
|
17
17
|
import os from "node:os";
|
|
18
18
|
import path from "node:path";
|
|
19
|
+
import { ensureQueuedMessagesInFile, QUEUED_MESSAGES_SEND_IMMEDIATELY } from "../queued-messages.js";
|
|
19
20
|
|
|
20
21
|
export const REGISTRY_URL =
|
|
21
22
|
"https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json";
|
|
@@ -312,7 +313,11 @@ export function writeAuthType(type: "gemini-api-key" | "oauth-personal", dir?: s
|
|
|
312
313
|
/* fresh file */
|
|
313
314
|
}
|
|
314
315
|
const prevAuth = (typeof settings.auth === "object" && settings.auth !== null ? settings.auth : {}) as Record<string, unknown>;
|
|
315
|
-
|
|
316
|
+
const nextSettings: Record<string, unknown> = { ...settings, auth: { ...prevAuth, type } };
|
|
317
|
+
if (typeof nextSettings.queuedMessages !== "string" || !nextSettings.queuedMessages.trim()) {
|
|
318
|
+
nextSettings.queuedMessages = QUEUED_MESSAGES_SEND_IMMEDIATELY;
|
|
319
|
+
}
|
|
320
|
+
fs.writeFileSync(file, `${JSON.stringify(nextSettings, null, 2)}\n`, { mode: 0o600 });
|
|
316
321
|
}
|
|
317
322
|
|
|
318
323
|
export interface BinaryResolution {
|
|
@@ -386,6 +391,7 @@ export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupS
|
|
|
386
391
|
// plan as the agy CLI login). gemini-api-key (metered paid API) stays a
|
|
387
392
|
// manual option for headless boxes (/agy auth-manual); never the default.
|
|
388
393
|
const gdir = defaultGeminiDir(opts);
|
|
394
|
+
ensureQueuedMessagesInFile(path.join(gdir, "settings.json"));
|
|
389
395
|
const auth = readAuthState(gdir);
|
|
390
396
|
if (!auth.configured) {
|
|
391
397
|
try {
|
package/src/driver-types.ts
CHANGED
|
@@ -32,7 +32,8 @@ export interface DriverTurnRequest extends DriverProfile {
|
|
|
32
32
|
prompt: string;
|
|
33
33
|
/** Image blocks riding with the prompt. ACP forwards them as typed
|
|
34
34
|
* content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
|
|
35
|
-
* correctly);
|
|
35
|
+
* correctly); on stream-json the provider stages them to files, so the
|
|
36
|
+
* driver itself ignores this field. */
|
|
36
37
|
images?: Array<{ data: string; mimeType: string }>;
|
|
37
38
|
/** ACP only: pi-side context delivered as a native `embeddedContext`
|
|
38
39
|
* resource block instead of inline prompt text (G1 on ACP). Stream-json
|
package/src/driver.ts
CHANGED
|
@@ -21,7 +21,9 @@ import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "./frame-guar
|
|
|
21
21
|
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
22
22
|
import { gateHooksStaged } from "./approval-hook.js";
|
|
23
23
|
import { parkedTurnAnswer } from "./parked-turn.js";
|
|
24
|
+
import { defaultCliSettingsPath, ensureQueuedMessagesInFile } from "./queued-messages.js";
|
|
24
25
|
import { redactText } from "./redact.js";
|
|
26
|
+
import { StreamSanitizer, sanitizeSystemMessage } from "./stream-sanitizer.js";
|
|
25
27
|
import type {
|
|
26
28
|
AgyUsage,
|
|
27
29
|
DriverActivity,
|
|
@@ -44,6 +46,8 @@ export type {
|
|
|
44
46
|
TurnOutcome,
|
|
45
47
|
} from "./driver-types.js";
|
|
46
48
|
|
|
49
|
+
export { StreamSanitizer, sanitizeSystemMessage };
|
|
50
|
+
|
|
47
51
|
const LIFECYCLE_LIMIT = 24;
|
|
48
52
|
const THINKING_TOKEN_FLOOR = 64;
|
|
49
53
|
|
|
@@ -61,6 +65,8 @@ interface ActiveTurn {
|
|
|
61
65
|
idleTimer?: NodeJS.Timeout;
|
|
62
66
|
onAbort?: () => void;
|
|
63
67
|
response: string;
|
|
68
|
+
rawResponse: string;
|
|
69
|
+
sanitizer: StreamSanitizer;
|
|
64
70
|
usage?: AgyUsage;
|
|
65
71
|
conversationId?: string;
|
|
66
72
|
/** Wall clock at turn creation; the parked-turn probe filters
|
|
@@ -305,6 +311,8 @@ export class StreamDriver implements TurnDriver {
|
|
|
305
311
|
resolve,
|
|
306
312
|
outcome,
|
|
307
313
|
response: "",
|
|
314
|
+
rawResponse: "",
|
|
315
|
+
sanitizer: new StreamSanitizer(),
|
|
308
316
|
sawResult: false,
|
|
309
317
|
sawModelStep: false,
|
|
310
318
|
cumulativeText: undefined,
|
|
@@ -370,6 +378,8 @@ export class StreamDriver implements TurnDriver {
|
|
|
370
378
|
this.#boundConversation = request.conversationId ?? undefined;
|
|
371
379
|
this.#stderrTail = "";
|
|
372
380
|
|
|
381
|
+
ensureQueuedMessagesInFile(defaultCliSettingsPath());
|
|
382
|
+
|
|
373
383
|
const args: string[] = ["--add-dir", request.cwd];
|
|
374
384
|
// The bridge dir carries .agents/mcp_config.json (tool bridge) and, when
|
|
375
385
|
// the approval gate is on, .agents/hooks.json (gate staging). Only this
|
|
@@ -571,7 +581,11 @@ export class StreamDriver implements TurnDriver {
|
|
|
571
581
|
const ok = r.status === "OK" || r.status === "SUCCESS";
|
|
572
582
|
const status = ok ? "OK" : "ERROR";
|
|
573
583
|
// Prefer the streamed accumulation, fall back to the result body.
|
|
574
|
-
const
|
|
584
|
+
const fallback = typeof r.response === "string" ? sanitizeSystemMessage(r.response) : "";
|
|
585
|
+
const response = turn.response || fallback;
|
|
586
|
+
if (!turn.response && fallback) {
|
|
587
|
+
turn.sanitizer.reset();
|
|
588
|
+
}
|
|
575
589
|
turn.response = response;
|
|
576
590
|
this.#settle(turn, {
|
|
577
591
|
conversationId: turn.conversationId,
|
|
@@ -596,36 +610,54 @@ export class StreamDriver implements TurnDriver {
|
|
|
596
610
|
// misflips (round-7 review): short accumulations never flip, and a
|
|
597
611
|
// cumulative frame that no longer extends the accumulator is evidence
|
|
598
612
|
// of a misflip - fall back to append mode.
|
|
613
|
+
let rawDelta = "";
|
|
599
614
|
if (turn.cumulativeText === undefined) {
|
|
600
615
|
turn.cumulativeText = false;
|
|
601
616
|
} else if (
|
|
602
617
|
!turn.cumulativeText &&
|
|
603
|
-
shouldFlipToCumulative(turn.
|
|
618
|
+
shouldFlipToCumulative(turn.rawResponse, text)
|
|
604
619
|
) {
|
|
605
620
|
turn.cumulativeText = true;
|
|
606
621
|
}
|
|
607
622
|
if (turn.cumulativeText) {
|
|
608
|
-
if (!text.startsWith(turn.
|
|
623
|
+
if (!text.startsWith(turn.rawResponse)) {
|
|
609
624
|
// Misflip evidence: back to deltas.
|
|
610
625
|
turn.cumulativeText = false;
|
|
611
|
-
turn.
|
|
612
|
-
|
|
626
|
+
turn.rawResponse += text;
|
|
627
|
+
rawDelta = text;
|
|
628
|
+
} else if (text.length > turn.rawResponse.length) {
|
|
629
|
+
rawDelta = text.slice(turn.rawResponse.length);
|
|
630
|
+
turn.rawResponse = text;
|
|
631
|
+
} else {
|
|
613
632
|
return;
|
|
614
633
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
634
|
+
} else {
|
|
635
|
+
turn.rawResponse += text;
|
|
636
|
+
rawDelta = text;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (rawDelta.length > 0) {
|
|
640
|
+
const sanitizedDelta = turn.sanitizer.push(rawDelta);
|
|
641
|
+
if (sanitizedDelta.length > 0) {
|
|
642
|
+
turn.response += sanitizedDelta;
|
|
643
|
+
emit(turn, { type: "text", delta: sanitizedDelta });
|
|
619
644
|
}
|
|
620
|
-
return;
|
|
621
645
|
}
|
|
622
|
-
turn.response += text;
|
|
623
|
-
emit(turn, { type: "text", delta: text });
|
|
624
646
|
}
|
|
625
647
|
|
|
626
648
|
#settle(turn: ActiveTurn, outcome: TurnOutcome): void {
|
|
627
649
|
if (turn.closed) return;
|
|
628
650
|
turn.closed = true;
|
|
651
|
+
const flushed = turn.sanitizer.flush();
|
|
652
|
+
if (flushed.length > 0) {
|
|
653
|
+
turn.response += flushed;
|
|
654
|
+
emit(turn, { type: "text", delta: flushed });
|
|
655
|
+
}
|
|
656
|
+
if (outcome.deadline && outcome.status === "OK") {
|
|
657
|
+
outcome.response = outcome.response || turn.response;
|
|
658
|
+
} else {
|
|
659
|
+
outcome.response = turn.response || outcome.response;
|
|
660
|
+
}
|
|
629
661
|
if (turn.overallTimer) clearTimeout(turn.overallTimer);
|
|
630
662
|
if (turn.idleTimer) clearTimeout(turn.idleTimer);
|
|
631
663
|
if (turn.onAbort && turn.request.signal) {
|
|
@@ -680,7 +712,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
680
712
|
this.#settle(turn, {
|
|
681
713
|
conversationId: turn.conversationId,
|
|
682
714
|
status: "OK",
|
|
683
|
-
response: parked,
|
|
715
|
+
response: sanitizeSystemMessage(parked),
|
|
684
716
|
usage: turn.usage,
|
|
685
717
|
finished: true,
|
|
686
718
|
aborted: false,
|
package/src/models.ts
CHANGED
|
@@ -361,9 +361,8 @@ function thinkingLevelMapFor(efforts: readonly AgyEffort[]): ThinkingLevelMap {
|
|
|
361
361
|
}
|
|
362
362
|
|
|
363
363
|
/** Project an agy entry to pi's Model shape. `input` advertises accepted
|
|
364
|
-
* inputs
|
|
365
|
-
*
|
|
366
|
-
* the extension decides by engine at load time. */
|
|
364
|
+
* inputs; both engines accept images (ACP native content blocks,
|
|
365
|
+
* stream-json staged files), so callers pass ["text", "image"] uniformly. */
|
|
367
366
|
export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> = ["text"]): Model<Api> {
|
|
368
367
|
const effortDriven = !!entry.efforts && entry.efforts.length > 0;
|
|
369
368
|
return {
|
|
@@ -380,9 +379,8 @@ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> =
|
|
|
380
379
|
// none is ever sent.
|
|
381
380
|
reasoning: effortDriven,
|
|
382
381
|
...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
|
|
383
|
-
// Input advertising comes from the caller
|
|
384
|
-
//
|
|
385
|
-
// would let pi offer image attach only for them to be dropped.
|
|
382
|
+
// Input advertising comes from the caller: both engines deliver
|
|
383
|
+
// user-attached images now.
|
|
386
384
|
input,
|
|
387
385
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
388
386
|
// Gemini long context. agy doesn't expose the real per-model window in
|
package/src/poll-fold.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Fold state machine for bridge_poll_result display events: consecutive
|
|
2
|
+
// polls collapse into ONE counted transcript entry instead of one entry per
|
|
3
|
+
// poll (observed live 2026-10-21: 60 "Poll git_commit" entries from a single
|
|
4
|
+
// wait, all flushed in bursts when the parked round-trip resolved).
|
|
5
|
+
//
|
|
6
|
+
// Runs close on a target switch, on any non-poll display event, or at turn
|
|
7
|
+
// end; the caller persists each closed run as an "agy-poll-run" entry.
|
|
8
|
+
|
|
9
|
+
export interface PollRunData {
|
|
10
|
+
/** The pi tool the model kept polling, e.g. "git_commit". */
|
|
11
|
+
target: string;
|
|
12
|
+
count: number;
|
|
13
|
+
/** Strongest poll outcome seen: running < done < failed. Sticky so a
|
|
14
|
+
* still-running poll after a terminal one cannot downgrade the summary. */
|
|
15
|
+
state: "running" | "done" | "failed";
|
|
16
|
+
firstAt: string;
|
|
17
|
+
lastAt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STATE_RANK: Record<PollRunData["state"], number> = { running: 0, done: 1, failed: 2 };
|
|
21
|
+
|
|
22
|
+
export class PollRunFolder {
|
|
23
|
+
#run: PollRunData | undefined;
|
|
24
|
+
|
|
25
|
+
/** The open run, for a live status-line counter. */
|
|
26
|
+
get open(): PollRunData | undefined {
|
|
27
|
+
return this.#run;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Fold one poll event. Returns a run to persist when this poll switched
|
|
31
|
+
* targets away from the open run; the new poll always starts or extends
|
|
32
|
+
* a run, so callers append the return value BEFORE reading `open`. */
|
|
33
|
+
add(target: string, state: PollRunData["state"], at: Date): PollRunData | undefined {
|
|
34
|
+
let closed: PollRunData | undefined;
|
|
35
|
+
if (this.#run && this.#run.target !== target) closed = this.#close();
|
|
36
|
+
if (!this.#run) {
|
|
37
|
+
this.#run = { target, count: 0, state: "running", firstAt: at.toISOString(), lastAt: at.toISOString() };
|
|
38
|
+
}
|
|
39
|
+
this.#run.count += 1;
|
|
40
|
+
this.#run.lastAt = at.toISOString();
|
|
41
|
+
if (STATE_RANK[state] > STATE_RANK[this.#run.state]) this.#run.state = state;
|
|
42
|
+
return closed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Close and return the open run, if any. Idempotent. */
|
|
46
|
+
flush(): PollRunData | undefined {
|
|
47
|
+
return this.#run ? this.#close() : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#close(): PollRunData {
|
|
51
|
+
const run = this.#run as PollRunData;
|
|
52
|
+
this.#run = undefined;
|
|
53
|
+
return run;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Stream-json prompt staging: user-attached image blocks cannot ride the CLI's
|
|
2
|
+
// text-only turn input, so they are staged to temp files and the prompt points
|
|
3
|
+
// the model at them. The model views them through the bridge read tool, whose
|
|
4
|
+
// tool-result image content the CLI's MCP client delivers (probe 2026-09-07).
|
|
5
|
+
// The ACP engine does not use this module: image blocks ride session/prompt
|
|
6
|
+
// natively.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export const STAGE_DIR_PREFIX = "pi-bridge-img-";
|
|
13
|
+
|
|
14
|
+
/** Retention for staged files: the model may re-read them on later turns of
|
|
15
|
+
* the same conversation, so sweeps cannot delete a dir this turn's prompt
|
|
16
|
+
* still references. */
|
|
17
|
+
export const STAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export interface StagedImage {
|
|
20
|
+
data: string;
|
|
21
|
+
mimeType: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EXT_BY_MIME: Record<string, string> = {
|
|
25
|
+
"image/png": ".png",
|
|
26
|
+
"image/jpeg": ".jpg",
|
|
27
|
+
"image/gif": ".gif",
|
|
28
|
+
"image/webp": ".webp",
|
|
29
|
+
"image/bmp": ".bmp",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function sweepStale(root: string): void {
|
|
33
|
+
let entries: string[];
|
|
34
|
+
try {
|
|
35
|
+
entries = fs.readdirSync(root);
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const cutoff = Date.now() - STAGE_MAX_AGE_MS;
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
if (!entry.startsWith(STAGE_DIR_PREFIX)) continue;
|
|
42
|
+
const full = path.join(root, entry);
|
|
43
|
+
try {
|
|
44
|
+
// lstat: never follow a symlink planted at our prefix in a shared tmp.
|
|
45
|
+
if (!fs.lstatSync(full).isDirectory()) continue;
|
|
46
|
+
if (fs.statSync(full).mtimeMs < cutoff) fs.rmSync(full, { recursive: true, force: true });
|
|
47
|
+
} catch {
|
|
48
|
+
// Raced with another process's sweep or deletion: nothing to do.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Write image blocks to a fresh staging dir under `root` (default the OS tmp
|
|
54
|
+
* dir). Returns null on any failure (including an empty/malformed payload):
|
|
55
|
+
* the turn then runs text-only instead of failing. */
|
|
56
|
+
export function stagePromptImages(
|
|
57
|
+
images: Array<StagedImage>,
|
|
58
|
+
root?: string,
|
|
59
|
+
): { dir: string; paths: string[] } | null {
|
|
60
|
+
const base = root ?? os.tmpdir();
|
|
61
|
+
let dir: string | null = null;
|
|
62
|
+
try {
|
|
63
|
+
sweepStale(base);
|
|
64
|
+
dir = fs.mkdtempSync(path.join(base, STAGE_DIR_PREFIX));
|
|
65
|
+
const paths = images.map((img, i) => {
|
|
66
|
+
const bytes = Buffer.from(img.data, "base64");
|
|
67
|
+
if (bytes.length === 0) throw new Error("empty image payload");
|
|
68
|
+
const ext = EXT_BY_MIME[img.mimeType] ?? ".bin";
|
|
69
|
+
const file = path.join(dir!, `image-${i + 1}${ext}`);
|
|
70
|
+
fs.writeFileSync(file, bytes, { mode: 0o600 });
|
|
71
|
+
return file;
|
|
72
|
+
});
|
|
73
|
+
return { dir, paths };
|
|
74
|
+
} catch {
|
|
75
|
+
// A mid-write failure must not leak a partial dir; the 24h sweep would
|
|
76
|
+
// reap it anyway, but the next staging call in this process is likely
|
|
77
|
+
// seconds away.
|
|
78
|
+
if (dir) {
|
|
79
|
+
try {
|
|
80
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
} catch {
|
|
82
|
+
// Sweep reaps it later.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildImageNote(paths: string[]): string {
|
|
90
|
+
const list = paths.map((p) => `- ${p}`).join("\n");
|
|
91
|
+
return `[The user attached ${paths.length} image(s) to this message. They are staged at:\n${list}\nView them by calling the pi read tool on these exact paths; the image content is delivered to you directly.]`;
|
|
92
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import {
|
|
21
21
|
createAssistantMessageEventStream,
|
|
22
22
|
getCurrentSystemPrompt,
|
|
23
|
+
getCurrentTools,
|
|
23
24
|
type AssistantMessage,
|
|
24
25
|
type AssistantMessageEventStream,
|
|
25
26
|
type JsonValue,
|
|
@@ -44,6 +45,7 @@ import type { ApprovalDecision, ApprovalPayload, ApprovalParkApi } from "./mcp-s
|
|
|
44
45
|
import path from "node:path";
|
|
45
46
|
import { TurnDiffContext, createExecGitOps, formatInlineDiff, parseEditToolInput } from "./diff-render.js";
|
|
46
47
|
import { stageSyspromptAgent } from "./sysprompt-agent.js";
|
|
48
|
+
import { buildImageNote, stagePromptImages } from "./prompt-images.js";
|
|
47
49
|
|
|
48
50
|
const DEFAULT_TIMEOUT_MIN = 10;
|
|
49
51
|
|
|
@@ -51,6 +53,29 @@ const DEFAULT_TIMEOUT_MIN = 10;
|
|
|
51
53
|
* the staged carrier agent (issue #2 inline-block fallback). */
|
|
52
54
|
let warnedUserAgentConflict = false;
|
|
53
55
|
|
|
56
|
+
/** Set of caller-scoped tool names declared by pi-observational-memory background workers.
|
|
57
|
+
* Antigravity models run a closed tool loop on the backend CLI/ACP process and do not
|
|
58
|
+
* support caller-scoped local tools. */
|
|
59
|
+
export const OM_WORKER_TOOL_NAMES = new Set([
|
|
60
|
+
"record_observations",
|
|
61
|
+
"record_reflections",
|
|
62
|
+
"drop_observations",
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/** Detect if a turn belongs to a pi-observational-memory background worker. */
|
|
66
|
+
export function isObservationalMemoryContext(context: TranscriptContext): boolean {
|
|
67
|
+
const currentTools = getCurrentTools(context.messages);
|
|
68
|
+
return currentTools.some((t) => OM_WORKER_TOOL_NAMES.has(t.name));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Track whether we have warned about observational-memory tool mismatch in this process. */
|
|
72
|
+
let warnedObservationalMemoryMismatch = false;
|
|
73
|
+
|
|
74
|
+
/** Reset latch for unit testing. */
|
|
75
|
+
export function _resetObservationalMemoryWarnedForTesting(): void {
|
|
76
|
+
warnedObservationalMemoryMismatch = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
/** conversationId -> staged carrier agent name. Gate-closed turns look the
|
|
55
80
|
* name up here so a conversation's agent field never flips: a change would
|
|
56
81
|
* recycle the agy process mid-conversation (profile drift), and a respawn
|
|
@@ -80,7 +105,8 @@ function extractUserPrompt(context: TranscriptContext): string | null {
|
|
|
80
105
|
if (!last || last.role !== "user") return null;
|
|
81
106
|
const content = last.content;
|
|
82
107
|
if (typeof content === "string") return content;
|
|
83
|
-
// Flatten text blocks; images ride separately via extractImages (
|
|
108
|
+
// Flatten text blocks; images ride separately via extractImages (native
|
|
109
|
+
// blocks on ACP, staged files on stream-json via prompt-images.ts).
|
|
84
110
|
return content
|
|
85
111
|
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
86
112
|
.map((b) => b.text)
|
|
@@ -89,8 +115,9 @@ function extractUserPrompt(context: TranscriptContext): string | null {
|
|
|
89
115
|
}
|
|
90
116
|
|
|
91
117
|
/** Image blocks of the latest user message (pi-ai ImageContent: base64 data
|
|
92
|
-
* + mimeType).
|
|
93
|
-
* stream-json
|
|
118
|
+
* + mimeType). On the ACP engine they ride session/prompt as native
|
|
119
|
+
* content blocks; on stream-json they are staged to temp files and the
|
|
120
|
+
* prompt names the paths (see prompt-images.ts). */
|
|
94
121
|
function extractImages(context: TranscriptContext): Array<{ data: string; mimeType: string }> {
|
|
95
122
|
const last = context.messages[context.messages.length - 1];
|
|
96
123
|
if (!last || last.role !== "user" || typeof last.content === "string") return [];
|
|
@@ -140,7 +167,7 @@ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
|
|
|
140
167
|
* the user's machine must go through the bridge. Rides the systemPrompt
|
|
141
168
|
* gate: the note ships only when the system prompt ships. */
|
|
142
169
|
export const TOOL_PRIORITY_NOTE =
|
|
143
|
-
"[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). Delegate subagent work the same way: when pi's tool list includes a delegation tool (for example subagent, Agent, agent_spawn, agent_wait, or subagent_run), always delegate through it and never through the native invoke_subagent, run_subagent, or manage_subagents; use native ones only if no pi delegation tool is listed, because native subagents run inside agy and are invisible to pi and anything watching it. 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.]";
|
|
170
|
+
"[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). Image files are visible to you: the Pi Bridge read tool returns image files as visible images (png, jpg, webp, gif, bmp), so never claim you cannot view images. When a bridge call answers STILL RUNNING, poll bridge_poll_result before doing other work: until you poll, the result (including any image) stays undelivered to you. Delegate subagent work the same way: when pi's tool list includes a delegation tool (for example subagent, Agent, agent_spawn, agent_wait, or subagent_run), always delegate through it and never through the native invoke_subagent, run_subagent, or manage_subagents; use native ones only if no pi delegation tool is listed, because native subagents run inside agy and are invisible to pi and anything watching it. 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.]";
|
|
144
171
|
|
|
145
172
|
/** Assemble the full agy prompt: system prompt block, pi-side digest, user
|
|
146
173
|
* prompt. Empty parts are dropped. Pure; exported for unit testing.
|
|
@@ -332,6 +359,9 @@ export interface NativeDisplayEvent {
|
|
|
332
359
|
name: string;
|
|
333
360
|
mcpServer?: string;
|
|
334
361
|
status: "started" | "completed" | "failed";
|
|
362
|
+
/** Bridge callId from the tool args (poll calls carry one); lets the
|
|
363
|
+
* extension resolve the polled pi tool via the escalation registry. */
|
|
364
|
+
callId?: string;
|
|
335
365
|
path?: string;
|
|
336
366
|
command?: string;
|
|
337
367
|
output?: string;
|
|
@@ -919,7 +949,8 @@ export class ToolRoundTrips {
|
|
|
919
949
|
/** Complete a parked call from a pi toolResult message. Returns false when
|
|
920
950
|
* the id matches nothing pending. Image blocks ride the result on both
|
|
921
951
|
* engines (probe-verified on each); the late-delivery prompt stays
|
|
922
|
-
* text-only
|
|
952
|
+
* text-only and its images re-attach through driver.run (native blocks on
|
|
953
|
+
* ACP, staged files on stream-json). */
|
|
923
954
|
resolve(
|
|
924
955
|
toolCallId: string,
|
|
925
956
|
text: string,
|
|
@@ -1030,6 +1061,9 @@ export interface LateToolResult {
|
|
|
1030
1061
|
reason: string;
|
|
1031
1062
|
text: string;
|
|
1032
1063
|
isError: boolean;
|
|
1064
|
+
/** Image blocks of the stranded result (read on an image file). The
|
|
1065
|
+
* redelivery prompt is text-only, so they ride driver.run images. */
|
|
1066
|
+
images?: Array<{ data: string; mimeType: string }>;
|
|
1033
1067
|
}
|
|
1034
1068
|
|
|
1035
1069
|
/** Frame late tool results so agy treats them as the results its bridge calls
|
|
@@ -1143,10 +1177,16 @@ export function consumeActivity(
|
|
|
1143
1177
|
// model-facing result is produced. Persist a card only on completion.
|
|
1144
1178
|
if (feats.engine === "acp" && feats.onNativeEvent) {
|
|
1145
1179
|
try {
|
|
1146
|
-
feats.onNativeEvent({ name: activity.name, mcpServer: activity.mcpServer, status: "started" });
|
|
1180
|
+
feats.onNativeEvent({ name: activity.name, mcpServer: activity.mcpServer, status: "started", callId: callIdArg(activity.args) });
|
|
1147
1181
|
} catch { /* UI failure must not fail the generation. */ }
|
|
1148
1182
|
}
|
|
1149
1183
|
return "continue";
|
|
1184
|
+
/** Bridge callId arg of an MCP tool call (bridge_poll_result requires it). */
|
|
1185
|
+
function callIdArg(args: Record<string, unknown>): string | undefined {
|
|
1186
|
+
const v = args.callId;
|
|
1187
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1150
1190
|
/** File-path argument of an ACP edit tool (observed: `file_path`); other
|
|
1151
1191
|
* tool kinds (execute, read) don't carry one. Returns undefined for
|
|
1152
1192
|
* non-edit tools. */
|
|
@@ -1183,7 +1223,7 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
|
|
|
1183
1223
|
let displayed = false;
|
|
1184
1224
|
try {
|
|
1185
1225
|
if (feats.onNativeEvent) {
|
|
1186
|
-
feats.onNativeEvent({ name: activity.name, mcpServer: activity.mcpServer, status: "completed", path: file, command, output: activity.output, diff: diffText });
|
|
1226
|
+
feats.onNativeEvent({ name: activity.name, mcpServer: activity.mcpServer, status: "completed", path: file, command, output: activity.output, diff: diffText, callId: callIdArg(activity.args) });
|
|
1187
1227
|
displayed = true;
|
|
1188
1228
|
}
|
|
1189
1229
|
} catch { /* A stale renderer must never fail the model turn. */ }
|
|
@@ -1299,8 +1339,10 @@ async function runTurnDriver(
|
|
|
1299
1339
|
// Images ride tool results on BOTH engines (ACP probe 2026-09-05;
|
|
1300
1340
|
// stream-json probe 2026-09-07: the CLI's MCP client delivers tool-result
|
|
1301
1341
|
// image content to the model — two-tone PNG named from the result alone,
|
|
1302
|
-
// no decoders in the frame trail). The late-delivery
|
|
1303
|
-
//
|
|
1342
|
+
// no decoders in the frame trail). The late-delivery PROMPT stays
|
|
1343
|
+
// text-only: image blocks ride driver.run images instead, so both
|
|
1344
|
+
// engines deliver them (ACP native prompt blocks; stream-json staged
|
|
1345
|
+
// files).
|
|
1304
1346
|
for (const r of results)
|
|
1305
1347
|
deps.roundTrips.resolve(r.toolCallId, r.text, r.isError, r.images);
|
|
1306
1348
|
|
|
@@ -1317,7 +1359,14 @@ async function runTurnDriver(
|
|
|
1317
1359
|
if (!isContinuation) {
|
|
1318
1360
|
for (const r of collectToolResults(context.messages, deps.roundTrips.deadIds)) {
|
|
1319
1361
|
const dead = deps.roundTrips.consumeDead(r.toolCallId);
|
|
1320
|
-
if (dead)
|
|
1362
|
+
if (dead)
|
|
1363
|
+
late.push({
|
|
1364
|
+
name: dead.name,
|
|
1365
|
+
reason: dead.reason,
|
|
1366
|
+
text: r.text,
|
|
1367
|
+
isError: r.isError,
|
|
1368
|
+
images: r.images.length > 0 ? r.images : undefined,
|
|
1369
|
+
});
|
|
1321
1370
|
}
|
|
1322
1371
|
if (late.length > 0) {
|
|
1323
1372
|
deps.log?.("late-result", { tools: late.map((l) => l.name), freshConversation: !existing?.conversationId }, "info");
|
|
@@ -1344,8 +1393,25 @@ async function runTurnDriver(
|
|
|
1344
1393
|
reason: "you never received this result: you ended your turn without calling bridge_poll_result",
|
|
1345
1394
|
text: view.text ?? "(no output)",
|
|
1346
1395
|
isError: view.isError ?? false,
|
|
1396
|
+
images: view.images,
|
|
1347
1397
|
};
|
|
1348
1398
|
});
|
|
1399
|
+
/** Stream-json has no image channel in the turn input: stage the blocks
|
|
1400
|
+
* to temp files and let the note point the model at the bridge read
|
|
1401
|
+
* tool, whose result images the CLI delivers (probe 2026-09-07). ACP
|
|
1402
|
+
* rides native content blocks; no staging there. */
|
|
1403
|
+
const stageImageNote = (imgs: Array<{ data: string; mimeType: string }>): string => {
|
|
1404
|
+
if (deps.engine === "acp" || imgs.length === 0) return "";
|
|
1405
|
+
const staged = stagePromptImages(imgs);
|
|
1406
|
+
if (!staged) {
|
|
1407
|
+
deps.log?.("turn-warning", { reason: "image-staging-failed", count: imgs.length }, "warn");
|
|
1408
|
+
// Silence would strand the pixels forever: on a redelivery the claim
|
|
1409
|
+
// is already consumed, so the model must at least learn the image
|
|
1410
|
+
// existed and re-request it.
|
|
1411
|
+
return `\n\n[${imgs.length} image(s) could not be delivered to this turn; ask the user to re-attach or re-read the original path.]`;
|
|
1412
|
+
}
|
|
1413
|
+
return `\n\n${buildImageNote(staged.paths)}`;
|
|
1414
|
+
};
|
|
1349
1415
|
|
|
1350
1416
|
const entry = entries.find((e) => e.id === model.id) ?? null;
|
|
1351
1417
|
const agyModel = entry?.full ?? model.id;
|
|
@@ -1448,10 +1514,15 @@ async function runTurnDriver(
|
|
|
1448
1514
|
agent = stagedAgentByConversation.get(existing.conversationId);
|
|
1449
1515
|
}
|
|
1450
1516
|
}
|
|
1517
|
+
// Late/stranded image results ride driver.run images (the redelivery
|
|
1518
|
+
// prompt is text-only); user-message images ride them on ACP natively
|
|
1519
|
+
// and staged files on stream-json.
|
|
1520
|
+
const runImages = [...images, ...late.flatMap((l) => l.images ?? [])];
|
|
1451
1521
|
const fullPrompt =
|
|
1452
|
-
late.length > 0
|
|
1522
|
+
(late.length > 0
|
|
1453
1523
|
? buildLateResultPrompt(late, prompt || undefined)
|
|
1454
|
-
: buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "")
|
|
1524
|
+
: buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "")) +
|
|
1525
|
+
stageImageNote(runImages);
|
|
1455
1526
|
try {
|
|
1456
1527
|
handle = await deps.driver.run({
|
|
1457
1528
|
cwd,
|
|
@@ -1463,7 +1534,7 @@ async function runTurnDriver(
|
|
|
1463
1534
|
inactivityMin: config.inactivityTimeoutMin,
|
|
1464
1535
|
conversationId: redeliveryConversation ?? existing?.conversationId ?? null,
|
|
1465
1536
|
prompt: fullPrompt,
|
|
1466
|
-
images:
|
|
1537
|
+
images: runImages.length > 0 ? runImages : undefined,
|
|
1467
1538
|
agent,
|
|
1468
1539
|
contextBlock: embeddedDigest
|
|
1469
1540
|
? {
|
|
@@ -1560,6 +1631,7 @@ async function runTurnDriver(
|
|
|
1560
1631
|
if (more.length > 0) {
|
|
1561
1632
|
const conv = redeliveryConversation ?? outcome.conversationId ?? existing?.conversationId ?? null;
|
|
1562
1633
|
deps.log?.("escalated-redelivery-turn", { tools: more.map((s) => s.name), conversation: conv }, "info");
|
|
1634
|
+
const moreImages = more.flatMap((s) => s.images ?? []);
|
|
1563
1635
|
try {
|
|
1564
1636
|
const lateHandle = await deps.driver.run({
|
|
1565
1637
|
cwd,
|
|
@@ -1570,7 +1642,8 @@ async function runTurnDriver(
|
|
|
1570
1642
|
timeoutMin: config.turnTimeoutMin,
|
|
1571
1643
|
inactivityMin: config.inactivityTimeoutMin,
|
|
1572
1644
|
conversationId: conv,
|
|
1573
|
-
prompt: buildLateResultPrompt(more),
|
|
1645
|
+
prompt: buildLateResultPrompt(more) + stageImageNote(moreImages),
|
|
1646
|
+
images: moreImages.length > 0 ? moreImages : undefined,
|
|
1574
1647
|
agent: config.agent ?? (conv ? stagedAgentByConversation.get(conv) : undefined),
|
|
1575
1648
|
signal: options?.signal,
|
|
1576
1649
|
});
|
|
@@ -1641,6 +1714,34 @@ export function createStreamSimple(
|
|
|
1641
1714
|
|
|
1642
1715
|
return function streamSimple(model, context, options) {
|
|
1643
1716
|
const stream = createAssistantMessageEventStream();
|
|
1717
|
+
|
|
1718
|
+
// Short-circuit pi-observational-memory background workers.
|
|
1719
|
+
// Observational memory workers declare caller-scoped tools (record_observations,
|
|
1720
|
+
// record_reflections, drop_observations). Antigravity models run in a closed backend
|
|
1721
|
+
// process with their own tool execution loop and do not support caller-scoped tools.
|
|
1722
|
+
// If dispatched to agy, the model sees tool instructions without tools and enters
|
|
1723
|
+
// an autonomous loop searching the disk for their implementation.
|
|
1724
|
+
// Returning a clean stop with empty content triggers OM's empty-observer backoff,
|
|
1725
|
+
// preventing high-frequency retries on subsequent turns.
|
|
1726
|
+
if (isObservationalMemoryContext(context)) {
|
|
1727
|
+
if (!warnedObservationalMemoryMismatch) {
|
|
1728
|
+
warnedObservationalMemoryMismatch = true;
|
|
1729
|
+
deps.log?.(
|
|
1730
|
+
"turn-warning",
|
|
1731
|
+
{
|
|
1732
|
+
reason: "observational-memory-unsupported",
|
|
1733
|
+
message:
|
|
1734
|
+
"Observational memory background workers cannot run against Antigravity models (caller-scoped tools unsupported). Configure a dedicated model under 'observational-memory.model' in settings.json.",
|
|
1735
|
+
},
|
|
1736
|
+
"warn",
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
const partial = newAssistant(model);
|
|
1740
|
+
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
1741
|
+
finalize(stream, blocks, "stop");
|
|
1742
|
+
return stream;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1644
1745
|
// Fire the async turn; return the stream synchronously per pi's contract.
|
|
1645
1746
|
// Engine selection: the latched load-time engine when the extension
|
|
1646
1747
|
// provides one, else the per-call config read (tests). One loadConfig()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Settings manager for Antigravity's "Queued Messages" delivery strategy.
|
|
2
|
+
//
|
|
3
|
+
// In agy v1.2.14+, agy introduced the "Queued Messages" option:
|
|
4
|
+
// - "queue" (agy default): hold follow-up / steering messages until current turn ends
|
|
5
|
+
// - "send-immediately": interrupt the agent with follow-up / steering messages immediately
|
|
6
|
+
//
|
|
7
|
+
// Pi expects immediate message delivery / steering during interactive chat.
|
|
8
|
+
// This module ensures that both the stream-json engine (~/.gemini/antigravity-cli/settings.json)
|
|
9
|
+
// and ACP engine (~/.gemini/antigravity-acp/settings.json) default to "send-immediately"
|
|
10
|
+
// so user steering works as expected in Pi without manual user configuration.
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
export const QUEUED_MESSAGES_SEND_IMMEDIATELY = "send-immediately";
|
|
17
|
+
|
|
18
|
+
export function defaultCliSettingsPath(): string {
|
|
19
|
+
return path.join(os.homedir(), ".gemini", "antigravity-cli", "settings.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function defaultAcpSettingsPath(): string {
|
|
23
|
+
return path.join(os.homedir(), ".gemini", "antigravity-acp", "settings.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EnsureFileResult {
|
|
27
|
+
path: string;
|
|
28
|
+
updated: boolean;
|
|
29
|
+
value: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface EnsureFileOptions {
|
|
34
|
+
defaultValue?: string;
|
|
35
|
+
onlyIfDirExists?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ensure `queuedMessages` is present in the specified settings.json file.
|
|
40
|
+
* If unset or empty, writes `send-immediately`.
|
|
41
|
+
*
|
|
42
|
+
* Safety guards:
|
|
43
|
+
* - If the file exists but has invalid JSON or is not an object, does NOT clobber; returns updated: false.
|
|
44
|
+
* - If the file already specifies a non-empty `queuedMessages`, leaves it untouched.
|
|
45
|
+
* - Resolves symlinks before writing to protect dotfiles managers.
|
|
46
|
+
* - Writes via temp file and atomic rename.
|
|
47
|
+
* - Traps any I/O exceptions so callers never crash on read-only or permission-denied files.
|
|
48
|
+
*/
|
|
49
|
+
export function ensureQueuedMessagesInFile(
|
|
50
|
+
filePath: string,
|
|
51
|
+
opts: EnsureFileOptions | string = {},
|
|
52
|
+
): EnsureFileResult {
|
|
53
|
+
const options: EnsureFileOptions =
|
|
54
|
+
typeof opts === "string" ? { defaultValue: opts } : opts;
|
|
55
|
+
const defaultValue = options.defaultValue ?? QUEUED_MESSAGES_SEND_IMMEDIATELY;
|
|
56
|
+
const onlyIfDirExists = options.onlyIfDirExists ?? false;
|
|
57
|
+
|
|
58
|
+
let resolvedPath = filePath;
|
|
59
|
+
try {
|
|
60
|
+
if (fs.existsSync(filePath)) {
|
|
61
|
+
resolvedPath = fs.realpathSync(filePath);
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Keep original path if realpath fails
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const dir = path.dirname(resolvedPath);
|
|
68
|
+
if (onlyIfDirExists && !fs.existsSync(dir)) {
|
|
69
|
+
return { path: resolvedPath, updated: false, value: "" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let settings: Record<string, unknown> = {};
|
|
73
|
+
let fileExists = false;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const raw = fs.readFileSync(resolvedPath, "utf8");
|
|
77
|
+
fileExists = true;
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw);
|
|
81
|
+
} catch (parseErr) {
|
|
82
|
+
// Malformed JSON: do NOT clobber the user's file.
|
|
83
|
+
return {
|
|
84
|
+
path: resolvedPath,
|
|
85
|
+
updated: false,
|
|
86
|
+
value: "",
|
|
87
|
+
error: `malformed JSON in ${resolvedPath}: ${String(parseErr)}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
92
|
+
return {
|
|
93
|
+
path: resolvedPath,
|
|
94
|
+
updated: false,
|
|
95
|
+
value: "",
|
|
96
|
+
error: `settings in ${resolvedPath} is not a JSON object`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
settings = parsed as Record<string, unknown>;
|
|
100
|
+
} catch (err: unknown) {
|
|
101
|
+
const code = (err as { code?: string })?.code;
|
|
102
|
+
if (code !== "ENOENT") {
|
|
103
|
+
// Read error other than absent file (e.g. EACCES): do not overwrite.
|
|
104
|
+
return {
|
|
105
|
+
path: resolvedPath,
|
|
106
|
+
updated: false,
|
|
107
|
+
value: "",
|
|
108
|
+
error: `failed reading ${resolvedPath}: ${String(err)}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const current = settings.queuedMessages;
|
|
114
|
+
if (typeof current === "string" && current.trim().length > 0) {
|
|
115
|
+
return { path: resolvedPath, updated: false, value: current };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
settings.queuedMessages = defaultValue;
|
|
119
|
+
|
|
120
|
+
let tmpPath = "";
|
|
121
|
+
try {
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
123
|
+
tmpPath = path.join(dir, `.${path.basename(resolvedPath)}.${process.pid}.${Date.now()}.tmp`);
|
|
124
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
|
|
125
|
+
fs.renameSync(tmpPath, resolvedPath);
|
|
126
|
+
return { path: resolvedPath, updated: true, value: defaultValue };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (tmpPath) {
|
|
129
|
+
try {
|
|
130
|
+
fs.unlinkSync(tmpPath);
|
|
131
|
+
} catch {
|
|
132
|
+
/* ignore */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
path: resolvedPath,
|
|
137
|
+
updated: false,
|
|
138
|
+
value: "",
|
|
139
|
+
error: `failed writing ${resolvedPath}: ${String(err)}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface EnsureQueuedMessagesOptions {
|
|
145
|
+
cliSettingsPath?: string;
|
|
146
|
+
acpSettingsPath?: string;
|
|
147
|
+
defaultValue?: string;
|
|
148
|
+
onlyIfDirExists?: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface EnsureQueuedMessagesReport {
|
|
152
|
+
cli: EnsureFileResult;
|
|
153
|
+
acp: EnsureFileResult;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Ensure both stream-json (CLI) and ACP engines default to "send-immediately".
|
|
158
|
+
*/
|
|
159
|
+
export function ensureQueuedMessagesDefaults(
|
|
160
|
+
opts: EnsureQueuedMessagesOptions = {},
|
|
161
|
+
): EnsureQueuedMessagesReport {
|
|
162
|
+
const cliPath = opts.cliSettingsPath ?? defaultCliSettingsPath();
|
|
163
|
+
const acpPath = opts.acpSettingsPath ?? defaultAcpSettingsPath();
|
|
164
|
+
const defVal = opts.defaultValue ?? QUEUED_MESSAGES_SEND_IMMEDIATELY;
|
|
165
|
+
const onlyIfDirExists = opts.onlyIfDirExists ?? false;
|
|
166
|
+
|
|
167
|
+
const cli = ensureQueuedMessagesInFile(cliPath, { defaultValue: defVal, onlyIfDirExists });
|
|
168
|
+
const acp = ensureQueuedMessagesInFile(acpPath, { defaultValue: defVal, onlyIfDirExists });
|
|
169
|
+
|
|
170
|
+
return { cli, acp };
|
|
171
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Stream Delta Sanitizer:
|
|
2
|
+
// Google Antigravity injects asynchronous background task notifications into
|
|
3
|
+
// the active LLM context formatted as:
|
|
4
|
+
// The following is a <SYSTEM_MESSAGE> not actually sent by the user...
|
|
5
|
+
// <SYSTEM_MESSAGE>
|
|
6
|
+
// [Message] timestamp=... sender=... content=...
|
|
7
|
+
// </SYSTEM_MESSAGE>
|
|
8
|
+
//
|
|
9
|
+
// Models (notably Gemini 3.8 Flash) frequently regurgitate or echo this preamble
|
|
10
|
+
// verbatim as an assistant text response before proceeding with tools or answer.
|
|
11
|
+
//
|
|
12
|
+
// StreamSanitizer buffers and strips these blocks statefully across streaming
|
|
13
|
+
// deltas so they never leak into the user's transcript.
|
|
14
|
+
|
|
15
|
+
export const START_MARKER = "The following is a <SYSTEM_MESSAGE>";
|
|
16
|
+
export const END_MARKER = "</SYSTEM_MESSAGE>";
|
|
17
|
+
|
|
18
|
+
export const SYSTEM_MESSAGE_RE =
|
|
19
|
+
/(?:\r?\n)?The following is a <SYSTEM_MESSAGE>[\s\S]*?<\/SYSTEM_MESSAGE>(?:\r?\n)?/g;
|
|
20
|
+
|
|
21
|
+
/** Non-streaming sanitization for full text strings (result bodies, parked turns, transcripts). */
|
|
22
|
+
export function sanitizeSystemMessage(text: string): string {
|
|
23
|
+
if (!text) return "";
|
|
24
|
+
return text.replace(SYSTEM_MESSAGE_RE, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stateful streaming sanitizer that inspects and suppresses <SYSTEM_MESSAGE> blocks across chunks. */
|
|
28
|
+
export class StreamSanitizer {
|
|
29
|
+
#buffer = "";
|
|
30
|
+
#suppressing = false;
|
|
31
|
+
#suppressedAcc = "";
|
|
32
|
+
#emittedAny = false;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Feed an incoming chunk of streamed text.
|
|
36
|
+
* Returns the sanitized delta to emit downstream (empty string if suppressed or buffering).
|
|
37
|
+
*/
|
|
38
|
+
push(chunk: string): string {
|
|
39
|
+
if (!chunk) return "";
|
|
40
|
+
this.#buffer += chunk;
|
|
41
|
+
let out = "";
|
|
42
|
+
|
|
43
|
+
while (this.#buffer.length > 0) {
|
|
44
|
+
if (!this.#suppressing) {
|
|
45
|
+
const startIdx = this.#buffer.indexOf(START_MARKER);
|
|
46
|
+
if (startIdx !== -1) {
|
|
47
|
+
// There is a start marker in the buffer.
|
|
48
|
+
if (startIdx > 0) {
|
|
49
|
+
const before = this.#buffer.slice(0, startIdx);
|
|
50
|
+
if (!this.#emittedAny && before.trim() === "") {
|
|
51
|
+
// Discard leading padding before initial system message.
|
|
52
|
+
} else {
|
|
53
|
+
// Strip at most one trailing newline right before the block.
|
|
54
|
+
const trimmedBefore = before.replace(/\r?\n$/, "");
|
|
55
|
+
if (trimmedBefore.length > 0) {
|
|
56
|
+
out += trimmedBefore;
|
|
57
|
+
this.#emittedAny = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
this.#buffer = this.#buffer.slice(startIdx + START_MARKER.length);
|
|
62
|
+
this.#suppressing = true;
|
|
63
|
+
this.#suppressedAcc = START_MARKER;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// START_MARKER is not yet in the buffer.
|
|
68
|
+
// Check if the tail of buffer matches a prefix of START_MARKER.
|
|
69
|
+
const maxOverlap = Math.min(this.#buffer.length, START_MARKER.length - 1);
|
|
70
|
+
let overlapLen = 0;
|
|
71
|
+
for (let len = maxOverlap; len > 0; len--) {
|
|
72
|
+
if (START_MARKER.startsWith(this.#buffer.slice(-len))) {
|
|
73
|
+
overlapLen = len;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (overlapLen > 0) {
|
|
79
|
+
const safeLen = this.#buffer.length - overlapLen;
|
|
80
|
+
if (safeLen > 0) {
|
|
81
|
+
out += this.#buffer.slice(0, safeLen);
|
|
82
|
+
this.#emittedAny = true;
|
|
83
|
+
this.#buffer = this.#buffer.slice(safeLen);
|
|
84
|
+
}
|
|
85
|
+
// Keep overlapLen in buffer to see if next chunk completes the marker.
|
|
86
|
+
break;
|
|
87
|
+
} else {
|
|
88
|
+
out += this.#buffer;
|
|
89
|
+
this.#emittedAny = true;
|
|
90
|
+
this.#buffer = "";
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
// Suppressing inside <SYSTEM_MESSAGE>...</SYSTEM_MESSAGE>
|
|
95
|
+
const endIdx = this.#buffer.indexOf(END_MARKER);
|
|
96
|
+
if (endIdx !== -1) {
|
|
97
|
+
let afterEnd = endIdx + END_MARKER.length;
|
|
98
|
+
if (!this.#emittedAny) {
|
|
99
|
+
// Discard all leading newlines before the first real content.
|
|
100
|
+
while (
|
|
101
|
+
afterEnd < this.#buffer.length &&
|
|
102
|
+
(this.#buffer[afterEnd] === "\r" || this.#buffer[afterEnd] === "\n")
|
|
103
|
+
) {
|
|
104
|
+
afterEnd++;
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
// Consume trailing newlines after </SYSTEM_MESSAGE> to preserve paragraph spacing.
|
|
108
|
+
if (this.#buffer.startsWith("\r\n\r\n", afterEnd)) {
|
|
109
|
+
afterEnd += 4;
|
|
110
|
+
} else if (this.#buffer.startsWith("\n\n", afterEnd)) {
|
|
111
|
+
afterEnd += 2;
|
|
112
|
+
} else if (this.#buffer.startsWith("\r\n", afterEnd)) {
|
|
113
|
+
afterEnd += 2;
|
|
114
|
+
} else if (this.#buffer.startsWith("\n", afterEnd)) {
|
|
115
|
+
afterEnd += 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.#buffer = this.#buffer.slice(afterEnd);
|
|
119
|
+
this.#suppressing = false;
|
|
120
|
+
this.#suppressedAcc = "";
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// END_MARKER not yet encountered. Check if tail matches prefix of END_MARKER.
|
|
125
|
+
const maxOverlap = Math.min(this.#buffer.length, END_MARKER.length - 1);
|
|
126
|
+
let overlapLen = 0;
|
|
127
|
+
for (let len = maxOverlap; len > 0; len--) {
|
|
128
|
+
if (END_MARKER.startsWith(this.#buffer.slice(-len))) {
|
|
129
|
+
overlapLen = len;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (overlapLen > 0) {
|
|
135
|
+
this.#suppressedAcc += this.#buffer.slice(0, -overlapLen);
|
|
136
|
+
this.#buffer = this.#buffer.slice(-overlapLen);
|
|
137
|
+
} else {
|
|
138
|
+
this.#suppressedAcc += this.#buffer;
|
|
139
|
+
this.#buffer = "";
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Flush remaining buffered text when stream ends. */
|
|
149
|
+
flush(): string {
|
|
150
|
+
if (this.#suppressing) {
|
|
151
|
+
const unclosed = this.#suppressedAcc + this.#buffer;
|
|
152
|
+
this.#buffer = "";
|
|
153
|
+
this.#suppressedAcc = "";
|
|
154
|
+
this.#suppressing = false;
|
|
155
|
+
if (unclosed.length > 0) {
|
|
156
|
+
this.#emittedAny = true;
|
|
157
|
+
}
|
|
158
|
+
return unclosed;
|
|
159
|
+
}
|
|
160
|
+
const remaining = this.#buffer;
|
|
161
|
+
this.#buffer = "";
|
|
162
|
+
if (remaining.length > 0) {
|
|
163
|
+
this.#emittedAny = true;
|
|
164
|
+
}
|
|
165
|
+
return remaining;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Peek at non-suppressed buffer contents without consuming them. */
|
|
169
|
+
peek(): string {
|
|
170
|
+
return this.#suppressing ? "" : this.#buffer;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Reset state. */
|
|
174
|
+
reset(): void {
|
|
175
|
+
this.#buffer = "";
|
|
176
|
+
this.#suppressedAcc = "";
|
|
177
|
+
this.#suppressing = false;
|
|
178
|
+
this.#emittedAny = false;
|
|
179
|
+
}
|
|
180
|
+
}
|