@estebanforge/pi-antigravity-bridge 1.7.6 → 1.7.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 +20 -0
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/driver-types.ts +14 -0
- package/src/driver.ts +41 -5
- package/src/provider.ts +123 -12
- package/src/sessions.ts +26 -0
- package/src/sysprompt-agent.ts +109 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.7.8] - 2026-09-30
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **The system prompt no longer rides the prompt line on stream-json.** Probed on agy 1.2.14: the CLI silently drops any single prompt past a ~25KB input cap. The drop reports SUCCESS with an empty response, zero usage, and no model step, and a conversation whose turn was dropped never answers again - resuming it hangs with no result frame. The G10 system-prompt block shipped inline on the first prompt of every fresh conversation by default and routinely crossed the cap, so provider sessions answered boilerplate while the user's message never ran (issue #2). The block now stages as an agy agent file under the agents discovery root and rides `--agent`: the CLI reads agent files from disk, so the cap never applies, and the prompt line carries only the user message. Staging is content-addressed - one dir per distinct body, write-once, pid-marked, orphan-swept - so two conversations in one process can never overwrite each other's carrier (the round-2 review blocked the original per-process slot for exactly that cross-contamination). A user-configured `config.agent` always wins, with the inline block as fallback and a one-time warning. ACP keeps the inline block: the JSON-RPC transport has no cap and no `--agent` slot.
|
|
10
|
+
- **A dropped or dead turn now fails loudly instead of poisoning the session.** The stream-json driver settles every turn with model-output and result-frame evidence. An OK outcome with no model output - the exact signature of the silent over-cap drop - fails the turn with the cause and the remedy, and never stores the conversation binding; a deadline on a bound conversation with no result frame clears the binding so the next turn starts a fresh conversation instead of hanging forever on a dead one. Real terse turns (tool steps, thinking, any usage) are untouched; a deadline that recovered a withheld answer keeps settling OK.
|
|
11
|
+
- **Regression coverage pins the whole failure chain at both layers.** The fake agy binary grew a SILENT-DROP branch reproducing the exact wire signature; driver tests pin the evidence fields; provider tests cover the drop, the poisoned resume, a deadline-with-result keeping its binding, terse-turn false positives, carrier isolation across interleaved conversations, and deadline-flavored error copy. 1343 tests, tsc clean; round-2 adversarial review signed off after the carrier-isolation blocker fix.
|
|
12
|
+
|
|
13
|
+
Thanks @Jamsa for the report (#2).
|
|
14
|
+
|
|
15
|
+
## [1.7.7] - 2026-09-30
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **A recreated agy conversation no longer starves the context digest.** When `session/load` failed, the ACP engine silently fell back to a fresh conversation (`session-load-failed-creating-fresh`), but the end-of-turn write-back kept advancing the stored watermark, so the new conversation never received the backlog: the digest delivered only the current prompt for the rest of the session, and the G10 system-prompt gate stayed closed too, because the surviving record always carried a conversation id. The write-back now compares the outcome's conversation id against the one stored before the turn and drops the record on a mismatch: the next turn re-delivers the full digest backlog and re-arms the system prompt, on both engines, with no protocol change. `SessionStore` gained a per-key `delete()` whose removal persists exactly once (the removal set prunes after a durable write, so a later persist can never destroy a record another process wrote in the meantime), preserving the multi-process merge invariant.
|
|
20
|
+
- **Failed and aborted turns no longer advance the digest watermark.** The write-back recorded `lastMessageCount` on any turn that reported a conversation id, ERROR and aborts included. Whether the prompt actually landed in the conversation's history is not observable from the provider, and advancing on that ambiguity starves the digest of the missed turn (the same failure class as the recreation bug above). The watermark now advances only on a clean, non-aborted turn; failed, aborted, and unknown outcomes leave the pre-turn record untouched, so the next digest re-delivers the missed turn bounded by the existing `maxChars` cap (default 8000) instead of losing it. With the digest off (the default) neither fix changes observable behavior.
|
|
21
|
+
- **Regression coverage pins both behaviors at the provider layer.** Eight new tests: record drop, backlog re-delivery, and system-prompt re-arm across the id swap; unchanged watermark across failed, aborted, and unknown outcomes on the same conversation; and the session-store delete and multi-process semantics, including a reclaimed key surviving an unrelated persist from another process. 1329 tests, tsc clean.
|
|
22
|
+
|
|
23
|
+
Thanks @fishfuuu for the report (#1).
|
|
24
|
+
|
|
5
25
|
## [1.7.6] - 2026-09-28
|
|
6
26
|
|
|
7
27
|
### Fixed
|
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
|
|
|
119
119
|
| `webTools` | `off` (no web tools), `on` (register `agy_web_search` + `agy_read_url` as Pi tools, usable by ANY provider's model). Each call spawns a one-shot search-only `agy` agent (plan mode, bridge MCP inheritance off), gates the answer on an observed native `search_web`/`read_url_content` step, and spends Antigravity quota. Off by default: Antigravity sessions already have native web tools on both engines; these serve other providers | `off` |
|
|
120
120
|
| `bridgeTools` | `none` (bridge off), `all` (every non-builtin tool, incl. other `Ask*` delegations), `mcp` (pi-mcp-adapter tools + skills bridge only) | `all` |
|
|
121
121
|
| `digest` | `off` (stable prompts; agy's prompt cache hits) or `on` (inject a delta of pi-side context - compaction summaries, other-provider turns - into each agy prompt; the delta changes every turn, so agy re-bills the full context). Enable for mixed-provider sessions where agy must see pi-side context | `off` |
|
|
122
|
-
| `systemPrompt` | `on` (
|
|
122
|
+
| `systemPrompt` | `on` (send pi's system prompt - operating instructions plus the global agent-dir `AGENTS.md` and ancestor `AGENTS.md`/`CLAUDE.md` - to the first prompt of each new agy conversation, plus a tool-priority note: Pi Bridge tools win over agy's native interactive ones, which never reach the user. Delivery: stream-json stages it as an agy agent file via `--agent`, off the prompt line - the CLI silently drops single prompts past a ~25KB cap and the dropped turn kills the conversation; ACP prepends it to the first prompt) or `off` (agy-native behavior) | `on` |
|
|
123
123
|
| `turnTimeoutMin` | Overall cap on ONE agy turn, minutes, both engines. When it fires, the bridge kills the agy process mid-task ("ACP turn exceeded the 10m deadline" / "agy exceeded the 10m turn timeout") - it is a bridge cap, not a Google server limit. Default is TTY-aware: `0` (no cap) on an interactive pi - you are the backstop with Esc; `20` headless, where nobody can abort and one runaway turn blocks the serialized turn queue. Opt into a gate with 1-1440 (free type via `config.json` or `/agy timeout <1-1440|off>`, or the `/agy` picker's preset list); `0` disables explicitly; anything else falls back to the TTY-aware default. The 5m inactivity stall guard always still bounds a hung server | `0` TTY / `20` headless |
|
|
124
124
|
| `inactivityTimeoutMin` | Silence cap, minutes, both engines: no stream-json stdout / no ACP session/update for this long fails the turn as a stall. `0` disables the guard | `5` |
|
|
125
125
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.7.
|
|
4
|
-
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi
|
|
3
|
+
"version": "1.7.8",
|
|
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",
|
|
7
7
|
"pi-extension",
|
package/src/driver-types.ts
CHANGED
|
@@ -90,6 +90,20 @@ export interface TurnOutcome {
|
|
|
90
90
|
usage?: AgyUsage;
|
|
91
91
|
finished: boolean;
|
|
92
92
|
aborted: boolean;
|
|
93
|
+
/** The model demonstrably produced output this turn: an agent_response
|
|
94
|
+
* or tool step was observed, or the settled response/usage is non-empty.
|
|
95
|
+
* Stream-json sets it on every outcome; ACP leaves it undefined.
|
|
96
|
+
* false + status OK is agy's silent over-cap drop signature (issue #2:
|
|
97
|
+
* prompts past ~25KB are discarded with SUCCESS, no error). */
|
|
98
|
+
modelOutputSeen?: boolean;
|
|
99
|
+
/** Stream-json only: a result frame arrived for this turn. Undefined on
|
|
100
|
+
* ACP. A deadline kill or process exit before the result frame means the
|
|
101
|
+
* turn never committed. */
|
|
102
|
+
sawResult?: boolean;
|
|
103
|
+
/** Stream-json only: the turn ended via the overall/inactivity deadline
|
|
104
|
+
* guard (not a user abort, not a process crash). With sawResult false on
|
|
105
|
+
* a resumed conversation this marks the conversation as poisoned. */
|
|
106
|
+
deadline?: "timeout" | "stall";
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
export interface TurnHandle {
|
package/src/driver.ts
CHANGED
|
@@ -67,6 +67,10 @@ interface ActiveTurn {
|
|
|
67
67
|
* transcript steps to those written after this. */
|
|
68
68
|
startedAt: number;
|
|
69
69
|
sawResult: boolean;
|
|
70
|
+
/** An agent_response or tool step arrived this turn. Distinguishes a
|
|
71
|
+
* real (if terse) turn from agy's silent over-cap drop, which settles
|
|
72
|
+
* SUCCESS with no model step at all (issue #2). */
|
|
73
|
+
sawModelStep: boolean;
|
|
70
74
|
/** Text-dedupe guard state (delta vs cumulative response_text). */
|
|
71
75
|
cumulativeText: boolean | undefined;
|
|
72
76
|
/** Open bridge parks. Each one suspends the stdout idle timer: agy is
|
|
@@ -120,6 +124,20 @@ function nowIso(): string {
|
|
|
120
124
|
return new Date().toISOString().slice(11, 19);
|
|
121
125
|
}
|
|
122
126
|
|
|
127
|
+
/** True when any usage counter carries a real value. The silent over-cap
|
|
128
|
+
* drop reports all-zero usage (no model call happened); a turn that burned
|
|
129
|
+
* any tokens demonstrably reached the model. */
|
|
130
|
+
function hasTokenEvidence(usage: AgyUsage | undefined): boolean {
|
|
131
|
+
if (!usage) return false;
|
|
132
|
+
return (
|
|
133
|
+
(usage.input_tokens ?? 0) > 0 ||
|
|
134
|
+
(usage.output_tokens ?? 0) > 0 ||
|
|
135
|
+
(usage.thinking_tokens ?? 0) > 0 ||
|
|
136
|
+
(usage.cache_read_tokens ?? 0) > 0 ||
|
|
137
|
+
(usage.total_tokens ?? 0) > 0
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
123
141
|
/** True when `next` is a cumulative resend of `accumulated` (it repeats every
|
|
124
142
|
* byte already streamed) rather than a fresh delta. Exported for tests. */
|
|
125
143
|
export function isCumulativeResend(accumulated: string, next: string): boolean {
|
|
@@ -287,6 +305,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
287
305
|
outcome,
|
|
288
306
|
response: "",
|
|
289
307
|
sawResult: false,
|
|
308
|
+
sawModelStep: false,
|
|
290
309
|
cumulativeText: undefined,
|
|
291
310
|
parks: 0,
|
|
292
311
|
startedAt: Date.now(),
|
|
@@ -500,6 +519,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
500
519
|
emit(turn, { type: "usage", usage: s.usage });
|
|
501
520
|
}
|
|
502
521
|
if (s.step_type === "agent_response") {
|
|
522
|
+
turn.sawModelStep = true;
|
|
503
523
|
const text =
|
|
504
524
|
typeof s.text_delta === "string"
|
|
505
525
|
? s.text_delta
|
|
@@ -513,6 +533,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
513
533
|
break;
|
|
514
534
|
}
|
|
515
535
|
if (s.step_type === "tool") {
|
|
536
|
+
turn.sawModelStep = true;
|
|
516
537
|
const name = s.tool_name ?? s.tool_info?.name ?? "tool";
|
|
517
538
|
const args =
|
|
518
539
|
s.tool_info?.parameters && typeof s.tool_info.parameters === "object"
|
|
@@ -614,9 +635,22 @@ export class StreamDriver implements TurnDriver {
|
|
|
614
635
|
this.#state = this.#child ? "ready" : "dead";
|
|
615
636
|
for (const wake of turn.wake) wake();
|
|
616
637
|
turn.wake = [];
|
|
617
|
-
|
|
638
|
+
// Evidence fields every consumer can rely on. modelOutputSeen separates a
|
|
639
|
+
// real (if terse) turn from agy's silent over-cap drop, which settles
|
|
640
|
+
// SUCCESS with NO model step, empty response, and zero usage (issue #2).
|
|
641
|
+
const usage = outcome.usage ?? turn.usage;
|
|
642
|
+
const enriched: TurnOutcome = {
|
|
643
|
+
...outcome,
|
|
644
|
+
usage,
|
|
645
|
+
sawResult: turn.sawResult,
|
|
646
|
+
modelOutputSeen:
|
|
647
|
+
turn.sawModelStep ||
|
|
648
|
+
outcome.response.length > 0 ||
|
|
649
|
+
hasTokenEvidence(usage),
|
|
650
|
+
};
|
|
651
|
+
turn.resolve(enriched);
|
|
618
652
|
try {
|
|
619
|
-
this.#onTurnEnd?.(
|
|
653
|
+
this.#onTurnEnd?.(enriched);
|
|
620
654
|
} catch {
|
|
621
655
|
/* listener errors must not break settling */
|
|
622
656
|
}
|
|
@@ -630,7 +664,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
630
664
|
* One found, the turn settles OK with the withheld answer instead of
|
|
631
665
|
* discarding finished work; either way the next turn respawns into a
|
|
632
666
|
* known state. */
|
|
633
|
-
async #turnDeadlineGuard(turn: ActiveTurn, label:
|
|
667
|
+
async #turnDeadlineGuard(turn: ActiveTurn, label: "timeout" | "stall", message: string): Promise<void> {
|
|
634
668
|
if (turn.closed) return;
|
|
635
669
|
this.#log(`${label}:${turn.id}`);
|
|
636
670
|
this.#killChild();
|
|
@@ -650,13 +684,14 @@ export class StreamDriver implements TurnDriver {
|
|
|
650
684
|
usage: turn.usage,
|
|
651
685
|
finished: true,
|
|
652
686
|
aborted: false,
|
|
687
|
+
deadline: label,
|
|
653
688
|
});
|
|
654
689
|
return;
|
|
655
690
|
}
|
|
656
|
-
this.#failTurn(turn, message);
|
|
691
|
+
this.#failTurn(turn, message, label);
|
|
657
692
|
}
|
|
658
693
|
|
|
659
|
-
#failTurn(turn: ActiveTurn, message: string): void {
|
|
694
|
+
#failTurn(turn: ActiveTurn, message: string, deadline?: "timeout" | "stall"): void {
|
|
660
695
|
this.#settle(turn, {
|
|
661
696
|
conversationId: turn.conversationId,
|
|
662
697
|
status: "ERROR",
|
|
@@ -665,6 +700,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
665
700
|
usage: turn.usage,
|
|
666
701
|
finished: true,
|
|
667
702
|
aborted: false,
|
|
703
|
+
deadline,
|
|
668
704
|
});
|
|
669
705
|
}
|
|
670
706
|
|
package/src/provider.ts
CHANGED
|
@@ -43,9 +43,22 @@ import { GATE_MARKER, mapNativeToShadow, stripMarkerFields } from "./approval-ga
|
|
|
43
43
|
import type { ApprovalDecision, ApprovalPayload, ApprovalParkApi } from "./mcp-server.js";
|
|
44
44
|
import path from "node:path";
|
|
45
45
|
import { TurnDiffContext, createExecGitOps, formatInlineDiff, parseEditToolInput } from "./diff-render.js";
|
|
46
|
+
import { stageSyspromptAgent } from "./sysprompt-agent.js";
|
|
46
47
|
|
|
47
48
|
const DEFAULT_TIMEOUT_MIN = 10;
|
|
48
49
|
|
|
50
|
+
/** Warned once per process: config.agent set while the system prompt needs
|
|
51
|
+
* the staged carrier agent (issue #2 inline-block fallback). */
|
|
52
|
+
let warnedUserAgentConflict = false;
|
|
53
|
+
|
|
54
|
+
/** conversationId -> staged carrier agent name. Gate-closed turns look the
|
|
55
|
+
* name up here so a conversation's agent field never flips: a change would
|
|
56
|
+
* recycle the agy process mid-conversation (profile drift), and a respawn
|
|
57
|
+
* must reload THAT conversation's own staged instructions. Content-addressed
|
|
58
|
+
* staging (one dir per body) keeps sibling conversations from overwriting
|
|
59
|
+
* each other's files. */
|
|
60
|
+
const stagedAgentByConversation = new Map<string, string>();
|
|
61
|
+
|
|
49
62
|
/** Zero-usage helper. agy doesn't expose token counts; pi's cost math gets
|
|
50
63
|
* zeros (we're not billing through this provider). */
|
|
51
64
|
function zeroUsage(): Usage {
|
|
@@ -1182,6 +1195,10 @@ async function runTurnDriver(
|
|
|
1182
1195
|
}
|
|
1183
1196
|
|
|
1184
1197
|
let handle: TurnHandle;
|
|
1198
|
+
// Name of the carrier agent staged for THIS turn's fresh conversation, if
|
|
1199
|
+
// any. Recorded into stagedAgentByConversation once the outcome binds the
|
|
1200
|
+
// conversation id, so later gate-closed turns pass the same agent.
|
|
1201
|
+
let stagedAgentName: string | undefined;
|
|
1185
1202
|
if (isContinuation) {
|
|
1186
1203
|
const active = deps.driver.reentry();
|
|
1187
1204
|
if (!active) {
|
|
@@ -1225,12 +1242,44 @@ async function runTurnDriver(
|
|
|
1225
1242
|
// The uri is suffixed per turn so a deduping server cannot serve stale
|
|
1226
1243
|
// content on turn 2+.
|
|
1227
1244
|
const embeddedDigest = deps.engine === "acp" && digest ? digest : undefined;
|
|
1228
|
-
//
|
|
1229
|
-
// re-sending
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1245
|
+
// G10, fresh conversations only (bound conversations ride agy's own
|
|
1246
|
+
// history; re-sending would bloat every prompt and bust the cache).
|
|
1247
|
+
// stream-json delivery is a staged agent file, NOT prompt text: agy's
|
|
1248
|
+
// stream-json path silently drops any turn past a ~25KB input cap and
|
|
1249
|
+
// the dropped turn leaves the conversation permanently unresponsive
|
|
1250
|
+
// (issue #2, probed 2026-09-30), and the block routinely crossed it.
|
|
1251
|
+
// ACP keeps the inline block (JSON-RPC transport; --agent is a CLI
|
|
1252
|
+
// feature). A user-configured agent always wins: never clobber it.
|
|
1253
|
+
const gateOpen = config.systemPrompt && !existing?.conversationId;
|
|
1254
|
+
const rawSysPrompt = gateOpen ? getCurrentSystemPrompt(context.messages) || undefined : undefined;
|
|
1255
|
+
let sysPrompt = rawSysPrompt;
|
|
1256
|
+
let agent = config.agent;
|
|
1257
|
+
if (deps.engine !== "acp") {
|
|
1258
|
+
if (rawSysPrompt && !config.agent) {
|
|
1259
|
+
const stagedName = stageSyspromptAgent(
|
|
1260
|
+
[SYSTEM_PROMPT_PREAMBLE, rawSysPrompt, TOOL_PRIORITY_NOTE].join("\n\n"),
|
|
1261
|
+
);
|
|
1262
|
+
if (stagedName) {
|
|
1263
|
+
agent = stagedName;
|
|
1264
|
+
sysPrompt = undefined;
|
|
1265
|
+
stagedAgentName = stagedName;
|
|
1266
|
+
}
|
|
1267
|
+
// Staging failed: inline fallback. The silent-drop guard below
|
|
1268
|
+
// keeps the resulting cap failure visible instead of quiet.
|
|
1269
|
+
} else if (rawSysPrompt && config.agent && !warnedUserAgentConflict) {
|
|
1270
|
+
warnedUserAgentConflict = true;
|
|
1271
|
+
deps.log?.(
|
|
1272
|
+
"sysprompt-agent-conflict",
|
|
1273
|
+
{ agent: config.agent, effect: "system prompt ships inline in the prompt (over-cap drop risk)" },
|
|
1274
|
+
"warn",
|
|
1275
|
+
);
|
|
1276
|
+
} else if (!rawSysPrompt && !config.agent && existing?.conversationId) {
|
|
1277
|
+
// Gate closed (bound conversation): pass the carrier staged for
|
|
1278
|
+
// THIS conversation, if any, so the driver profile never drifts on
|
|
1279
|
+
// "agent" and a respawned process reloads its own instructions.
|
|
1280
|
+
agent = stagedAgentByConversation.get(existing.conversationId);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1234
1283
|
const fullPrompt =
|
|
1235
1284
|
late.length > 0
|
|
1236
1285
|
? buildLateResultPrompt(late, prompt || undefined)
|
|
@@ -1242,12 +1291,12 @@ async function runTurnDriver(
|
|
|
1242
1291
|
effort,
|
|
1243
1292
|
mode: config.mode,
|
|
1244
1293
|
skipPermissions: config.skipPermissions,
|
|
1245
|
-
agent: config.agent,
|
|
1246
1294
|
timeoutMin: config.turnTimeoutMin,
|
|
1247
1295
|
inactivityMin: config.inactivityTimeoutMin,
|
|
1248
1296
|
conversationId: existing?.conversationId ?? null,
|
|
1249
1297
|
prompt: fullPrompt,
|
|
1250
1298
|
images: images.length > 0 ? images : undefined,
|
|
1299
|
+
agent,
|
|
1251
1300
|
contextBlock: embeddedDigest
|
|
1252
1301
|
? {
|
|
1253
1302
|
uri: `urn:pi-bridge:context-digest/${messageCount}`,
|
|
@@ -1282,12 +1331,74 @@ async function runTurnDriver(
|
|
|
1282
1331
|
}
|
|
1283
1332
|
|
|
1284
1333
|
const outcome = await handle.outcome;
|
|
1334
|
+
// Issue #2 recovery paths. agy's stream-json prompt path silently drops an
|
|
1335
|
+
// over-cap turn (SUCCESS, empty response, zero usage, NO model step) and a
|
|
1336
|
+
// dropped turn leaves the conversation permanently unresponsive, so both
|
|
1337
|
+
// failures clear the binding instead of settling a quiet empty reply —
|
|
1338
|
+
// settling OK here would persist a dead conversation for every later turn
|
|
1339
|
+
// (probe 2026-09-30: resume of a dropped-turn conversation never returns
|
|
1340
|
+
// a result frame). stream-json only: the ACP driver leaves the evidence
|
|
1341
|
+
// fields undefined.
|
|
1342
|
+
const silentDrop = outcome.status === "OK" && outcome.modelOutputSeen === false;
|
|
1343
|
+
const poisonedResume =
|
|
1344
|
+
outcome.status === "ERROR" &&
|
|
1345
|
+
outcome.deadline !== undefined &&
|
|
1346
|
+
outcome.sawResult === false &&
|
|
1347
|
+
Boolean(existing?.conversationId);
|
|
1348
|
+
if (silentDrop || poisonedResume) {
|
|
1349
|
+
if (existing?.conversationId || outcome.conversationId) {
|
|
1350
|
+
store.delete(key);
|
|
1351
|
+
if (outcome.conversationId) stagedAgentByConversation.delete(outcome.conversationId);
|
|
1352
|
+
}
|
|
1353
|
+
deps.log?.(
|
|
1354
|
+
"turn-error",
|
|
1355
|
+
{
|
|
1356
|
+
reason: silentDrop ? "silent-overcap-drop" : "poisoned-conversation",
|
|
1357
|
+
conversation: outcome.conversationId ?? null,
|
|
1358
|
+
},
|
|
1359
|
+
"error",
|
|
1360
|
+
);
|
|
1361
|
+
// A deadline with no model output is a stall/timeout, not the over-cap
|
|
1362
|
+
// signature: label it by its real cause (the deadline guard's recovered-
|
|
1363
|
+
// answer probe can settle OK with an empty response after the kill).
|
|
1364
|
+
const dropCause = outcome.deadline
|
|
1365
|
+
? "the turn hit its time limit and produced no model output"
|
|
1366
|
+
: "the prompt was silently dropped (agy discards prompts past ~25KB with no error)";
|
|
1367
|
+
finalize(
|
|
1368
|
+
stream,
|
|
1369
|
+
blocks,
|
|
1370
|
+
"error",
|
|
1371
|
+
silentDrop
|
|
1372
|
+
? `agy returned success but produced no model output: ${dropCause}. Common cause: a very large pasted prompt. The conversation binding was cleared; retry with a smaller prompt.`
|
|
1373
|
+
: "agy stopped responding on the resumed conversation (no response frame before the deadline; the conversation is dead). The binding was cleared, so the next antigravity turn starts a fresh conversation.",
|
|
1374
|
+
);
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1285
1377
|
if (outcome.conversationId) {
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1378
|
+
// A different id than the stored one means the driver recreated the
|
|
1379
|
+
// conversation under us (ACP session-load fallback, stream-json CLI
|
|
1380
|
+
// dropping a stale --conversation). The recreated conversation has seen
|
|
1381
|
+
// NOTHING, so the record's watermark would starve the digest forever and
|
|
1382
|
+
// its bound id would keep the sysPrompt gate closed. Drop the record:
|
|
1383
|
+
// the next turn resumes from watermark 0 with the gate re-armed (issue
|
|
1384
|
+
// #1). The recreation turn itself is past the gate, so it is the
|
|
1385
|
+
// documented residual; recovery starts on the next turn.
|
|
1386
|
+
if (existing?.conversationId && outcome.conversationId !== existing.conversationId) {
|
|
1387
|
+
store.delete(key);
|
|
1388
|
+
stagedAgentByConversation.delete(existing.conversationId);
|
|
1389
|
+
} else if (outcome.status === "OK" && !outcome.aborted) {
|
|
1390
|
+
store.set(key, {
|
|
1391
|
+
conversationId: outcome.conversationId,
|
|
1392
|
+
lastStepIdx: -1,
|
|
1393
|
+
lastMessageCount: messageCount,
|
|
1394
|
+
});
|
|
1395
|
+
if (stagedAgentName) stagedAgentByConversation.set(outcome.conversationId, stagedAgentName);
|
|
1396
|
+
}
|
|
1397
|
+
// A failed or aborted turn on the SAME conversation keeps the pre-turn
|
|
1398
|
+
// record: whether the prompt actually landed in agy's DB is not
|
|
1399
|
+
// observable from here. Keeping the watermark re-delivers the missed
|
|
1400
|
+
// turn in the next digest (bounded by the digest cap); advancing it
|
|
1401
|
+
// would starve the conversation of context it may never have received.
|
|
1291
1402
|
}
|
|
1292
1403
|
if (outcome.aborted) {
|
|
1293
1404
|
finalize(stream, blocks, "aborted", "Operation aborted");
|
package/src/sessions.ts
CHANGED
|
@@ -68,6 +68,7 @@ export class SessionStore {
|
|
|
68
68
|
private readonly path: string;
|
|
69
69
|
private cache: StoreMap = {};
|
|
70
70
|
private readonly dirty = new Set<string>();
|
|
71
|
+
private readonly removed = new Set<string>();
|
|
71
72
|
private writeChain: Promise<void> = Promise.resolve();
|
|
72
73
|
|
|
73
74
|
constructor(storePath: string = STORE_PATH) {
|
|
@@ -100,6 +101,7 @@ export class SessionStore {
|
|
|
100
101
|
set(key: string, session: AgySession): void {
|
|
101
102
|
this.cache[key] = session;
|
|
102
103
|
this.dirty.add(key);
|
|
104
|
+
this.removed.delete(key);
|
|
103
105
|
// Serialize through a chain so concurrent sets don't interleave renames.
|
|
104
106
|
this.writeChain = this.writeChain
|
|
105
107
|
.then(() => this.persist())
|
|
@@ -108,6 +110,22 @@ export class SessionStore {
|
|
|
108
110
|
});
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
/** Drop one session binding and persist the deletion. Used when the driver
|
|
114
|
+
* recreated the conversation under us: the record's watermark claims
|
|
115
|
+
* history the new conversation never saw, and its bound id keeps the
|
|
116
|
+
* sysPrompt gate closed. Dropping resets the watermark to 0 AND re-arms
|
|
117
|
+
* the gate on the next turn (issue #1). The write is queued. */
|
|
118
|
+
delete(key: string): void {
|
|
119
|
+
delete this.cache[key];
|
|
120
|
+
this.dirty.delete(key);
|
|
121
|
+
this.removed.add(key);
|
|
122
|
+
this.writeChain = this.writeChain
|
|
123
|
+
.then(() => this.persist())
|
|
124
|
+
.catch(() => {
|
|
125
|
+
/* swallow; next write retries */
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
111
129
|
private async persist(): Promise<void> {
|
|
112
130
|
const dir = path.dirname(this.path);
|
|
113
131
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
@@ -126,6 +144,9 @@ export class SessionStore {
|
|
|
126
144
|
for (const key of this.dirty) {
|
|
127
145
|
merged[key] = this.cache[key];
|
|
128
146
|
}
|
|
147
|
+
for (const key of this.removed) {
|
|
148
|
+
delete merged[key];
|
|
149
|
+
}
|
|
129
150
|
|
|
130
151
|
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
131
152
|
await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n", {
|
|
@@ -133,6 +154,10 @@ export class SessionStore {
|
|
|
133
154
|
});
|
|
134
155
|
await fs.promises.rename(tmp, this.path);
|
|
135
156
|
this.dirty.clear();
|
|
157
|
+
// Mirror dirty: once the deletion is durable, stop re-asserting it. A
|
|
158
|
+
// later unrelated persist must not destroy a record another process
|
|
159
|
+
// wrote for that key after our delete landed.
|
|
160
|
+
this.removed.clear();
|
|
136
161
|
}
|
|
137
162
|
|
|
138
163
|
/** Wipe all session bindings (forces fresh agy conversations on every
|
|
@@ -140,6 +165,7 @@ export class SessionStore {
|
|
|
140
165
|
clear(): void {
|
|
141
166
|
this.cache = {};
|
|
142
167
|
this.dirty.clear();
|
|
168
|
+
this.removed.clear();
|
|
143
169
|
this.writeChain = this.writeChain
|
|
144
170
|
.then(async () => {
|
|
145
171
|
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// G10 delivery for the stream-json engine: staged system-prompt agent.
|
|
2
|
+
//
|
|
3
|
+
// agy's stream-json prompt path silently drops any turn past a ~25KB input
|
|
4
|
+
// cap (probed 2026-09-30, issue #2: status SUCCESS, empty response, zero
|
|
5
|
+
// usage, no error — and the dropped turn leaves the conversation permanently
|
|
6
|
+
// unresponsive to later input). The pi system prompt block used to ride the
|
|
7
|
+
// first prompt of every fresh conversation and routinely crossed that cap,
|
|
8
|
+
// killing the whole session. It now stages as an agy agent file discovered
|
|
9
|
+
// under ~/.gemini/config/agents and passed via --agent: the CLI reads agent
|
|
10
|
+
// files from disk, off the prompt line, so the cap never applies.
|
|
11
|
+
//
|
|
12
|
+
// Frontmatter keeps agy's DEFAULTS on purpose (no excludeDefaultComponents,
|
|
13
|
+
// no tools list, no inheritMcp override): provider sessions need the normal
|
|
14
|
+
// toolset and the bridge MCP config for G9 round-trips. Contrast the ask-tool
|
|
15
|
+
// reviewer agent, which strips everything for sandboxing.
|
|
16
|
+
//
|
|
17
|
+
// Lifecycle: one agent dir per DISTINCT body (content-addressed by sha256);
|
|
18
|
+
// two conversations with different system prompts in one process stage two
|
|
19
|
+
// dirs and never overwrite each other, so a driver respawn mid-conversation
|
|
20
|
+
// always reloads that conversation's own instructions. Same body -> same dir
|
|
21
|
+
// (write-once; identical bytes are never rewritten). The name stays stable
|
|
22
|
+
// across the turns of a conversation so the driver's profile comparison
|
|
23
|
+
// never recycles on "agent" drift. The .pid marker lets sweepStaleWebAgents
|
|
24
|
+
// prune orphans (same doctrine as the web delegates and the ask-tool
|
|
25
|
+
// reviewer).
|
|
26
|
+
|
|
27
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import os from "node:os";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { sweepStaleWebAgents } from "./web-tools.js";
|
|
32
|
+
|
|
33
|
+
export const SYS_AGENT_PREFIX = "pi-bridge-sys-";
|
|
34
|
+
|
|
35
|
+
/** AGY_AGENTS_ROOT override for tests and sandboxes (same contract as the
|
|
36
|
+
* ask-tool reviewer root). */
|
|
37
|
+
export function syspromptAgentsRoot(): string {
|
|
38
|
+
return process.env.AGY_AGENTS_ROOT ?? path.join(os.homedir(), ".gemini", "config", "agents");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** agent.md for the carrier. The body is the pi system prompt composed by the
|
|
42
|
+
* provider (preamble + transcript system prompt + tool-priority note). */
|
|
43
|
+
export function syspromptAgentMd(name: string, body: string): string {
|
|
44
|
+
return [
|
|
45
|
+
"---",
|
|
46
|
+
`name: ${name}`,
|
|
47
|
+
"description: Pi bridge system-prompt carrier: pi operating instructions plus project AGENTS.md context",
|
|
48
|
+
"mainAgent: true",
|
|
49
|
+
"model: inherit",
|
|
50
|
+
"---",
|
|
51
|
+
"",
|
|
52
|
+
body,
|
|
53
|
+
"",
|
|
54
|
+
].join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Staged {
|
|
58
|
+
root: string;
|
|
59
|
+
name: string;
|
|
60
|
+
dir: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Content-addressed staging cache: body sha256 -> staged dir. Two
|
|
64
|
+
* conversations with different system prompts in one process must never
|
|
65
|
+
* share a carrier file (a rewrite would leak one conversation's instructions
|
|
66
|
+
* into the other's respawned agy process). */
|
|
67
|
+
const stagedByHash = new Map<string, Staged>();
|
|
68
|
+
|
|
69
|
+
function bodyHash(body: string): string {
|
|
70
|
+
return createHash("sha256").update(body).digest("hex").slice(0, 12);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Stage the carrier agent with `body` as its instructions. Content-addressed:
|
|
74
|
+
* the same body reuses its dir (no rewrite), a new body gets a fresh dir.
|
|
75
|
+
* Returns the agent name for --agent, or null when staging failed — the
|
|
76
|
+
* caller then falls back to the legacy inline prompt block, where the
|
|
77
|
+
* silent-drop guard keeps the cap risk visible. */
|
|
78
|
+
export function stageSyspromptAgent(
|
|
79
|
+
body: string,
|
|
80
|
+
opts: { agentsRoot?: string } = {},
|
|
81
|
+
): string | null {
|
|
82
|
+
try {
|
|
83
|
+
const root = opts.agentsRoot ?? syspromptAgentsRoot();
|
|
84
|
+
const hash = bodyHash(body);
|
|
85
|
+
const cached = stagedByHash.get(hash);
|
|
86
|
+
if (cached && cached.root === root) return cached.name;
|
|
87
|
+
sweepStaleWebAgents(root, Date.now(), SYS_AGENT_PREFIX);
|
|
88
|
+
fs.mkdirSync(root, { recursive: true });
|
|
89
|
+
// Nonce: two pi processes staging the same body must not fight over one
|
|
90
|
+
// dir; the pid plus nonce keeps every staging attempt self-contained.
|
|
91
|
+
const name = `${SYS_AGENT_PREFIX}${hash}-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
92
|
+
const dir = path.join(root, name);
|
|
93
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
94
|
+
fs.writeFileSync(path.join(dir, ".pid"), `${process.pid}\n`, { mode: 0o600 });
|
|
95
|
+
fs.writeFileSync(path.join(dir, "agent.md"), syspromptAgentMd(name, body), {
|
|
96
|
+
mode: 0o600,
|
|
97
|
+
});
|
|
98
|
+
const entry: Staged = { root, name, dir };
|
|
99
|
+
stagedByHash.set(hash, entry);
|
|
100
|
+
return entry.name;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Test hook: forget the staging cache without touching the filesystem. */
|
|
107
|
+
export function resetSyspromptAgentForTests(): void {
|
|
108
|
+
stagedByHash.clear();
|
|
109
|
+
}
|