@estebanforge/pi-antigravity-bridge 1.9.0 → 1.9.2
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 +21 -0
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/ask-tool.ts +3 -3
- package/src/driver.ts +46 -12
- package/src/provider.ts +61 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.9.2] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- The AskAntigravity tool's expand hint now comes from the render context pi injects (`context.keyHint`) instead of the package import. pi 1.5 blinds an imported `keyHint` to the host keybindings (app.* ids format as ""), so the hint rendered as "to expand" with no key. Peer floor moves to `^1.5.0` accordingly.
|
|
9
|
+
|
|
10
|
+
## [1.9.1] - 2026-10-29
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A retry-dropped attempt no longer stalls the session with "No user message to send to agy."** When pi retries after a failed turn, pi core drops the failed attempt from the projected context (`_omitRecoveryAttempt`), leaving a `toolResult` or assistant tail with no user prompt, no pending park, and nothing late. The fresh-pass guard used to hard-error there, and pi does not retry that error, so an unattended session stalled for ~6 hours on it (issue #8). With a bound conversation, the provider now re-opens it with a synthetic resume prompt (telling agy the previous attempt was discarded and not to repeat completed actions) instead of erroring. The digest is skipped on resume turns, the guard stays for unbound conversations and empty transcripts, and the `no-user-message` turn error now logs at error level (it was debug, so the failure left no trace in the error-only daily log).
|
|
15
|
+
|
|
16
|
+
- **A first-ever turn that parks now binds its conversation immediately.** The binding used to be written only on a completed OK pass, so a first turn that parked a bridge call and then died (failed continuation, process restart) left no record, and the retry-dropped tail above still hit the hard error. A park is positive evidence that agy received the prompt, so the provider persists the snapshot's conversation id at park time (first binding only, staged agent moving with it). A later ERROR keeps the park-time binding, except the issue #2 signatures that prove the conversation dead (silent drop / deadline kill with no model output seen), which still clear it by design.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **The stream-json overall cap now pauses on parks, matching the ACP engine.** `turnTimeoutMin` used to be armed once and cleared only at settle on stream-json, so parked pi-tool time and human-gated escalations consumed the turn cap (with a 15m cap, a 16m escalation died mid-turn). The cap now carries a wall-clock deadline plus a remaining-budget capture, pauses at the first park, and resumes with the remainder on unpark; a 0 cap stays disabled across park cycles. Turn durations on stream-json can now exceed `turnTimeoutMin` by the parked span. This makes the documented "escalated calls get their own 30-minute budget" claim true on both engines.
|
|
21
|
+
|
|
22
|
+
### Known limits
|
|
23
|
+
|
|
24
|
+
- **A tool result whose tombstone died with the process is lost, not delivered.** Tombstones are in-memory. After a process restart, a retry-dropped tail whose tool result never reached agy resumes the conversation without that output; agy may repeat the call or proceed without it.
|
|
25
|
+
|
|
5
26
|
## [1.9.0] - 2026-10-27
|
|
6
27
|
|
|
7
28
|
### Fixed
|
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. 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.
|
|
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. If pi retries a failed turn and drops the attempt from the projected context (pi core `_omitRecoveryAttempt`), leaving a toolResult or assistant tail with no live turn, the provider re-opens the bound conversation with a synthetic resume prompt instead of the unretryable "No user message" hard error (issue #8); a first-ever turn that parks binds its conversation at park time, so a retry-dropped tail has a conversation to resume into unless the park produced no conversation id or the later failure proved the conversation dead. 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
|
|
|
@@ -120,8 +120,8 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
|
|
|
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
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; the carrier declares `subagent: false` + `agents: []` so the native subagent tools stay masked (ACP carries no agent file, so its natives remain and the tool-priority note steers the model to pi delegation). ACP prepends it to the first prompt) or `off` (agy-native behavior) | `on` |
|
|
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
|
-
| `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` |
|
|
123
|
+
| `turnTimeoutMin` | Overall cap on ONE agy turn, minutes, both engines. Parked time (pi-tool round-trips, escalated calls, human-gated approvals) does not count against it on either engine: the cap pauses with remaining-budget semantics and resumes on unpark. 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
|
+
| `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. Parks (bridge calls, escalated calls, ACP permission dialogs) pause it; agy-native long steps such as builds do not. A false kill costs the whole turn, and on stream-json a stall kill with no result frame clears the conversation binding. Keep `5`; use `2`-`3` only when native steps are known to be short; do not use `1` | `5` |
|
|
125
125
|
|
|
126
126
|
Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_ASK_TOOL`, `AGY_WEB_TOOLS`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`, `AGY_TURN_TIMEOUT_MIN`, `AGY_INACTIVITY_TIMEOUT_MIN`. Env wins over the file, so while `AGY_DIGEST` or `AGY_SYSTEM_PROMPT` is set, the matching `/agy digest` or `/agy system-prompt` toggle persists a value that never takes effect.
|
|
127
127
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.2",
|
|
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
]
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@earendil-works/pi-coding-agent": "^1.
|
|
49
|
+
"@earendil-works/pi-coding-agent": "^1.5.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"@earendil-works/pi-coding-agent": {
|
package/src/ask-tool.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { randomBytes } from "node:crypto";
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import { buildSessionContext, getAgentDir
|
|
19
|
+
import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Text } from "@earendil-works/pi-tui";
|
|
21
21
|
import { contentText, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
22
22
|
import { Type } from "typebox";
|
|
@@ -759,7 +759,7 @@ export async function registerAskAntigravityTool(
|
|
|
759
759
|
}
|
|
760
760
|
return new Text(text, 0, 0);
|
|
761
761
|
},
|
|
762
|
-
renderResult(result, { expanded, isPartial }, theme) {
|
|
762
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
763
763
|
const d = result.details as AgyDetails | undefined;
|
|
764
764
|
if (isPartial) {
|
|
765
765
|
const status = result.content[0]?.type === "text" ? result.content[0].text : "working...";
|
|
@@ -789,7 +789,7 @@ export async function registerAskAntigravityTool(
|
|
|
789
789
|
const lines = truncated.split("\n").slice(0, PREVIEW_MAX_LINES);
|
|
790
790
|
if (lines.length) text += `\n${theme.fg("toolOutput", lines.join("\n"))}`;
|
|
791
791
|
if (body.length > PREVIEW_MAX_CHARS || body.split("\n").length > PREVIEW_MAX_LINES) {
|
|
792
|
-
text += `\n${theme.fg("dim", `… (${keyHint("app.tools.expand", "to expand")})`)}`;
|
|
792
|
+
text += `\n${theme.fg("dim", `… (${context.keyHint("app.tools.expand", "to expand")})`)}`;
|
|
793
793
|
}
|
|
794
794
|
}
|
|
795
795
|
return new Text(text, 0, 0);
|
package/src/driver.ts
CHANGED
|
@@ -62,6 +62,12 @@ interface ActiveTurn {
|
|
|
62
62
|
resolve: (o: TurnOutcome) => void;
|
|
63
63
|
outcome: Promise<TurnOutcome>;
|
|
64
64
|
overallTimer?: NodeJS.Timeout;
|
|
65
|
+
/** Overall-cap bookkeeping for park pauses: a wall-clock deadline while
|
|
66
|
+
* the timer runs, null while parked (parked time must not consume the
|
|
67
|
+
* budget), mirroring the ACP driver's remaining-budget semantics. */
|
|
68
|
+
overallDeadline: number | null;
|
|
69
|
+
/** Remaining budget captured at park time; null while the timer runs. */
|
|
70
|
+
overallRemainingMs: number | null;
|
|
65
71
|
idleTimer?: NodeJS.Timeout;
|
|
66
72
|
onAbort?: () => void;
|
|
67
73
|
response: string;
|
|
@@ -117,6 +123,17 @@ function makeHandle(turn: ActiveTurn): TurnHandle {
|
|
|
117
123
|
pushExternal: (activity) => {
|
|
118
124
|
if (activity.type === "bridge_call") {
|
|
119
125
|
turn.parks += 1;
|
|
126
|
+
// A park suspends both caps: agy is blocked waiting on the MCP
|
|
127
|
+
// HTTP response, so parked time consumes neither the stall guard
|
|
128
|
+
// nor the turn deadline (pi-tool and human-gated time stay free).
|
|
129
|
+
if (turn.overallTimer) {
|
|
130
|
+
clearTimeout(turn.overallTimer);
|
|
131
|
+
turn.overallTimer = undefined;
|
|
132
|
+
}
|
|
133
|
+
if (turn.overallDeadline !== null) {
|
|
134
|
+
turn.overallRemainingMs = Math.max(0, turn.overallDeadline - Date.now());
|
|
135
|
+
turn.overallDeadline = null;
|
|
136
|
+
}
|
|
120
137
|
if (turn.idleTimer) {
|
|
121
138
|
clearTimeout(turn.idleTimer);
|
|
122
139
|
turn.idleTimer = undefined;
|
|
@@ -204,13 +221,20 @@ export class StreamDriver implements TurnDriver {
|
|
|
204
221
|
const turn = this.#active;
|
|
205
222
|
if (!turn || turn.closed) return;
|
|
206
223
|
if (turn.parks > 0) turn.parks -= 1;
|
|
207
|
-
if (turn.parks === 0
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
turn
|
|
212
|
-
|
|
213
|
-
|
|
224
|
+
if (turn.parks === 0) {
|
|
225
|
+
if (turn.overallRemainingMs !== null) {
|
|
226
|
+
const remaining = turn.overallRemainingMs;
|
|
227
|
+
turn.overallRemainingMs = null;
|
|
228
|
+
this.#startOverallTimer(turn, remaining);
|
|
229
|
+
}
|
|
230
|
+
if (!turn.idleTimer) {
|
|
231
|
+
const idleMin = turn.request.inactivityMin ?? 5;
|
|
232
|
+
// 0 disables the stall guard (config inactivityTimeoutMin: 0).
|
|
233
|
+
if (idleMin > 0) {
|
|
234
|
+
turn.idleTimer = setTimeout(() => {
|
|
235
|
+
void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
|
|
236
|
+
}, idleMin * 60_000);
|
|
237
|
+
}
|
|
214
238
|
}
|
|
215
239
|
}
|
|
216
240
|
}
|
|
@@ -312,6 +336,8 @@ export class StreamDriver implements TurnDriver {
|
|
|
312
336
|
outcome,
|
|
313
337
|
response: "",
|
|
314
338
|
rawResponse: "",
|
|
339
|
+
overallDeadline: null,
|
|
340
|
+
overallRemainingMs: null,
|
|
315
341
|
sanitizer: new StreamSanitizer(),
|
|
316
342
|
sawResult: false,
|
|
317
343
|
sawModelStep: false,
|
|
@@ -343,11 +369,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
343
369
|
// 0 disables a cap (config turnTimeoutMin / inactivityTimeoutMin: 0):
|
|
344
370
|
// setTimeout(fn, 0) would fire instantly and kill every turn.
|
|
345
371
|
const totalMin = turn.request.timeoutMin ?? 10;
|
|
346
|
-
if (totalMin > 0)
|
|
347
|
-
turn.overallTimer = setTimeout(() => {
|
|
348
|
-
void this.#turnDeadlineGuard(turn, "timeout", `agy exceeded the ${totalMin}m turn timeout`);
|
|
349
|
-
}, totalMin * 60_000);
|
|
350
|
-
}
|
|
372
|
+
if (totalMin > 0) this.#startOverallTimer(turn, totalMin * 60_000);
|
|
351
373
|
const idleMin = turn.request.inactivityMin ?? 5;
|
|
352
374
|
if (idleMin > 0) {
|
|
353
375
|
turn.idleTimer = setTimeout(() => {
|
|
@@ -356,6 +378,18 @@ export class StreamDriver implements TurnDriver {
|
|
|
356
378
|
}
|
|
357
379
|
}
|
|
358
380
|
|
|
381
|
+
/** Arm the overall turn cap. A deadline is a wall-clock instant while
|
|
382
|
+
* running; parks capture the remainder and clear it (see
|
|
383
|
+
* ActiveTurn.overallDeadline). A 0ms remainder fires on the next tick,
|
|
384
|
+
* which is correct: the budget is spent. */
|
|
385
|
+
#startOverallTimer(turn: ActiveTurn, ms: number): void {
|
|
386
|
+
const totalMin = turn.request.timeoutMin ?? 10;
|
|
387
|
+
turn.overallDeadline = Date.now() + ms;
|
|
388
|
+
turn.overallTimer = setTimeout(() => {
|
|
389
|
+
void this.#turnDeadlineGuard(turn, "timeout", `agy exceeded the ${totalMin}m turn timeout`);
|
|
390
|
+
}, ms);
|
|
391
|
+
}
|
|
392
|
+
|
|
359
393
|
#start(request: DriverTurnRequest): void {
|
|
360
394
|
this.#state = "starting";
|
|
361
395
|
this.#generation += 1;
|
package/src/provider.ts
CHANGED
|
@@ -138,7 +138,18 @@ function extractImages(context: TranscriptContext): Array<{ data: string; mimeTy
|
|
|
138
138
|
|
|
139
139
|
const COMPACTION_MARKER = "compacted into the following summary";
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
/** Synthetic prompt for a resume turn (issue #8): pi's retry dropped the
|
|
142
|
+
* failed attempt from the projected context (_omitRecoveryAttempt), leaving
|
|
143
|
+
* a toolResult or assistant tail with no live agy turn. The bound
|
|
144
|
+
* conversation already holds the task history, so re-open it with this
|
|
145
|
+
* instead of the unretryable "no user message" hard error (which stalled
|
|
146
|
+
* an unattended session for ~6 hours). The wording must stress not
|
|
147
|
+
* repeating actions: the discarded attempt may have half-run with side
|
|
148
|
+
* effects agy saw but pi never recorded. */
|
|
149
|
+
export const RESUME_PROMPT =
|
|
150
|
+
"[The previous attempt of this turn failed and was discarded. Check your conversation history for the last step you completed, then continue the task from there. Do not repeat completed actions.]";
|
|
151
|
+
|
|
152
|
+
export const DIGEST_PREAMBLE =
|
|
142
153
|
"[The following is context from the broader pi session that this Antigravity turn was not directly spawned for: compaction summaries and turns handled by other providers or pi's own tools. Your own prior turns are already in your conversation history. Use this for continuity only.]";
|
|
143
154
|
|
|
144
155
|
// --- pi system prompt (G10) ----------------------------------------------------
|
|
@@ -1453,20 +1464,40 @@ async function runTurnDriver(
|
|
|
1453
1464
|
return;
|
|
1454
1465
|
}
|
|
1455
1466
|
// Fresh pass, or a stranded escalated result being re-delivered.
|
|
1456
|
-
const
|
|
1467
|
+
const userPrompt = extractUserPrompt(context);
|
|
1457
1468
|
const images = extractImages(context);
|
|
1469
|
+
// Resume pass (issue #8): pi's retry dropped the failed attempt from the
|
|
1470
|
+
// projected context, so the transcript ends in a toolResult or assistant
|
|
1471
|
+
// message and there is no user prompt, no pending park, and nothing
|
|
1472
|
+
// late. With a bound conversation, agy holds the task history: re-open
|
|
1473
|
+
// it with a synthetic resume prompt instead of the hard error below
|
|
1474
|
+
// (pi does not retry that error; it stalls unattended sessions). The
|
|
1475
|
+
// binding is required: "continue" into a brand-new conversation has no
|
|
1476
|
+
// task to continue. Restricted to the two retry tails: an empty
|
|
1477
|
+
// transcript (lastRole undefined) and a user tail both keep their
|
|
1478
|
+
// existing behavior.
|
|
1479
|
+
const lastRole = context.messages[context.messages.length - 1]?.role;
|
|
1480
|
+
const resume =
|
|
1481
|
+
!userPrompt &&
|
|
1482
|
+
images.length === 0 &&
|
|
1483
|
+
late.length === 0 &&
|
|
1484
|
+
Boolean(existing?.conversationId) &&
|
|
1485
|
+
(lastRole === "toolResult" || lastRole === "assistant");
|
|
1486
|
+
const prompt = resume ? RESUME_PROMPT : userPrompt;
|
|
1487
|
+
if (resume) deps.log?.("resume-turn", { conversation: existing?.conversationId }, "error");
|
|
1458
1488
|
// An image-only message (no text) is valid on the ACP engine; only fail
|
|
1459
1489
|
// when there is nothing at all to send (no text, no images, no late
|
|
1460
1490
|
// tool results to deliver).
|
|
1461
1491
|
if (!prompt && images.length === 0 && late.length === 0) {
|
|
1462
|
-
deps.log?.("turn-error", { reason: "no-user-message" }, "
|
|
1492
|
+
deps.log?.("turn-error", { reason: "no-user-message" }, "error");
|
|
1463
1493
|
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
1464
1494
|
return;
|
|
1465
1495
|
}
|
|
1466
1496
|
const watermark = existing?.lastMessageCount ?? 0;
|
|
1467
|
-
// Late turns re-open the conversation with a synthetic
|
|
1468
|
-
// would re-send context agy already holds
|
|
1469
|
-
|
|
1497
|
+
// Late and resume turns re-open the conversation with a synthetic
|
|
1498
|
+
// prompt; the digest would re-send context agy already holds (against a
|
|
1499
|
+
// stale watermark: a failed pass never advances it), so skip it.
|
|
1500
|
+
const digest = config.digest && late.length === 0 && !resume ? buildContextDigest(context.messages, watermark) : "";
|
|
1470
1501
|
// G1 delivery per engine. stream-json: digest rides inline in the prompt
|
|
1471
1502
|
// (the CLI has no context channel). ACP: the server advertises
|
|
1472
1503
|
// `embeddedContext`, so the digest ships as a native resource block
|
|
@@ -1575,7 +1606,24 @@ async function runTurnDriver(
|
|
|
1575
1606
|
if (consumeActivity(stream, blocks, activity, diffCtx, cwd, feats) === "parked") return "parked";
|
|
1576
1607
|
}
|
|
1577
1608
|
};
|
|
1578
|
-
if ((await drainTurn(handle)) === "parked")
|
|
1609
|
+
if ((await drainTurn(handle)) === "parked") {
|
|
1610
|
+
// A park proves agy got the prompt and is working in this conversation.
|
|
1611
|
+
// Persist a first-turn binding now: otherwise a first-ever turn that
|
|
1612
|
+
// parks and then dies (failed continuation, process restart) leaves no
|
|
1613
|
+
// record, and the retry-dropped tail (issue #8) has nothing to resume
|
|
1614
|
+
// into. Only when no record exists: an unbound run() cannot have
|
|
1615
|
+
// recreated a conversation, so the snapshot id is the real one, and
|
|
1616
|
+
// the staged agent must move with the binding or a stream-json resume
|
|
1617
|
+
// re-runs without its carrier-agent profile.
|
|
1618
|
+
if (!existing) {
|
|
1619
|
+
const parkedConv = deps.driver.snapshot().conversationId;
|
|
1620
|
+
if (parkedConv) {
|
|
1621
|
+
store.set(key, { conversationId: parkedConv, lastStepIdx: -1, lastMessageCount: messageCount });
|
|
1622
|
+
if (stagedAgentName) stagedAgentByConversation.set(parkedConv, stagedAgentName);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1579
1627
|
|
|
1580
1628
|
let outcome = await handle.outcome;
|
|
1581
1629
|
// Issue #2 recovery paths. agy's stream-json prompt path silently drops an
|
|
@@ -1585,12 +1633,17 @@ async function runTurnDriver(
|
|
|
1585
1633
|
// settling OK here would persist a dead conversation for every later turn
|
|
1586
1634
|
// (probe 2026-09-30: resume of a dropped-turn conversation never returns
|
|
1587
1635
|
// a result frame). stream-json only: the ACP driver leaves the evidence
|
|
1588
|
-
// fields undefined.
|
|
1636
|
+
// fields undefined. modelOutputSeen === false keeps deadline kills of a
|
|
1637
|
+
// turn that demonstrably worked (it parked, so a model step arrived) on
|
|
1638
|
+
// the plain retryable error path: "conversation is dead" would be wrong
|
|
1639
|
+
// for a first turn, and deleting a park-time binding (issue #8) would
|
|
1640
|
+
// strip the retry of its conversation.
|
|
1589
1641
|
const silentDrop = outcome.status === "OK" && outcome.modelOutputSeen === false;
|
|
1590
1642
|
const poisonedResume =
|
|
1591
1643
|
outcome.status === "ERROR" &&
|
|
1592
1644
|
outcome.deadline !== undefined &&
|
|
1593
1645
|
outcome.sawResult === false &&
|
|
1646
|
+
outcome.modelOutputSeen === false &&
|
|
1594
1647
|
Boolean(existing?.conversationId);
|
|
1595
1648
|
if (silentDrop || poisonedResume) {
|
|
1596
1649
|
if (existing?.conversationId || outcome.conversationId) {
|