@estebanforge/pi-antigravity-bridge 1.5.3 → 1.6.1
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 +12 -0
- package/README.md +3 -1
- package/extensions/index.ts +41 -2
- package/package.json +1 -1
- package/src/acp/driver.ts +8 -2
- package/src/ask-tool.ts +52 -5
- package/src/config.ts +56 -0
- package/src/driver-types.ts +2 -2
- package/src/driver.ts +27 -18
- package/src/models.ts +42 -1
- package/src/provider.ts +3 -41
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.6.1] - 2026-09-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`thinking` / `effort` params on AskAntigravity (synonyms).** Delegation calls had no direct tier knob: the only lever was tier sugar inside `model` ("flash high"), so a caller told "peer review on high thinking" fell through to the configured default (medium) with no signal anything was dropped. The tool now takes `thinking` (pi vocabulary: minimal|low|medium|high|xhigh|max, clamped to agy's low|medium|high; unknown values fall back to low) plus `effort` as the same knob under agy's own name, so calling models reach for either word. An explicit level beats a tier embedded in `model` and the config default; passing both with different values errors; fixed-thinking families (Claude, GPT-OSS) ignore it because agy rejects `--effort` for them. `toAgyEffort` moved from `src/provider.ts` to `src/models.ts` so provider turns and the delegation tool share one clamp.
|
|
10
|
+
|
|
11
|
+
## [1.6.0] - 2026-09-14
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Turn-cap knobs (`turnTimeoutMin`, `inactivityTimeoutMin`) with TTY-aware defaults. The "ACP turn exceeded the 10m deadline" error was the bridge's own overall turn cap - not a Google server limit: the ACP server binary exposes no timeout flag (`--helpfull` lists only debug/notices), and the stream-json engine carried the identical 10m cap ("agy exceeded the 10m turn timeout"). The cap never refreshed on activity, so healthy long turns died mid-task at exactly 10m. The default is now `0` (no cap) on an interactive pi - the user aborts with Esc and is the better backstop - and `20` minutes headless, where nobody can abort and one runaway turn blocks the drivers' serialized turn queue. Opt into a timed gate with 1-1440 minutes: free type via `config.json` or the new `/agy timeout <1-1440|off>` subcommand, or pick a preset (0/1/5/10/15/30/60/120/360/720/1440) in the `/agy` settings picker; `0` disables explicitly; garbage, negative, or >1440 falls back to the TTY-aware default. The 5m inactivity stall guard is unchanged, so a silent hung server still dies. Env `AGY_TURN_TIMEOUT_MIN` / `AGY_INACTIVITY_TIMEOUT_MIN` win over the file.
|
|
16
|
+
|
|
5
17
|
## [1.5.3] - 2026-09-10
|
|
6
18
|
|
|
7
19
|
### Fixed
|
package/README.md
CHANGED
|
@@ -123,8 +123,10 @@ If `agy models` fails at load (binary missing, auth not done, network stall), a
|
|
|
123
123
|
| `bridgeTools` | `none` (bridge off), `all` (every non-builtin tool, incl. other `Ask*` delegations), `mcp` (pi-mcp-adapter tools + skills bridge only) | `all` |
|
|
124
124
|
| `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` |
|
|
125
125
|
| `systemPrompt` | `on` (prepend 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) or `off` (agy-native behavior) | `on` |
|
|
126
|
+
| `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 |
|
|
127
|
+
| `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` |
|
|
126
128
|
|
|
127
|
-
Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_ASK_TOOL`, `AGY_DIGEST`, `AGY_SYSTEM_PROMPT`. 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.
|
|
129
|
+
Env overrides: `AGY_BRIDGE_TOOLS`, `AGY_ASK_TOOL`, `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.
|
|
128
130
|
|
|
129
131
|
The `activate_skill` catalog mirrors pi's directory-based skill discovery: the two global dirs plus project dirs, the latter only when pi has trusted the project (same gate pi itself applies). Pi's other skill sources - the `skills` settings array, `package.json` entries, and `--skill` CLI paths - are not mirrored and won't appear in the catalog.
|
|
130
132
|
|
package/extensions/index.ts
CHANGED
|
@@ -65,7 +65,7 @@ import { runAcpAuth } from "../src/acp/auth.js";
|
|
|
65
65
|
import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
|
|
66
66
|
import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
|
|
67
67
|
import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
|
|
68
|
-
import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
68
|
+
import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
69
69
|
import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
|
|
70
70
|
import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
|
|
71
71
|
import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
|
|
@@ -905,6 +905,7 @@ interface PendingConfig {
|
|
|
905
905
|
skipPermissions?: boolean;
|
|
906
906
|
defaultModel?: string;
|
|
907
907
|
defaultThinking?: ThinkingTier;
|
|
908
|
+
turnTimeoutMin?: number;
|
|
908
909
|
askTool?: boolean;
|
|
909
910
|
bridgeTools?: BridgeTools;
|
|
910
911
|
digest?: boolean;
|
|
@@ -941,7 +942,7 @@ function statusText(ctx: AgyCommandCtx): string {
|
|
|
941
942
|
function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
942
943
|
pi.registerCommand("agy", {
|
|
943
944
|
description:
|
|
944
|
-
"Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|digest on|off|system-prompt on|off|acp-bin <path|auto>|patch-cleanup|clear]",
|
|
945
|
+
"Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
|
|
945
946
|
handler: async (args, cmdCtx: ExtensionCommandContext) => {
|
|
946
947
|
const ui = cmdCtx.ui;
|
|
947
948
|
if (ui) activeUi = ui;
|
|
@@ -976,6 +977,33 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
976
977
|
);
|
|
977
978
|
return;
|
|
978
979
|
}
|
|
980
|
+
if (sub === "timeout") {
|
|
981
|
+
// Free-type entry point for the turn cap (the TUI picker only offers
|
|
982
|
+
// presets). Same sanity rules as loadConfig: 1..MAX valid, off/0
|
|
983
|
+
// disables, anything else rejected with the valid range.
|
|
984
|
+
const live = loadConfig();
|
|
985
|
+
const cur = live.turnTimeoutMin;
|
|
986
|
+
if (!val) {
|
|
987
|
+
ui?.notify(
|
|
988
|
+
`Turn time cap: ${cur === 0 ? "off (no cap)" : `${cur}m`}. Usage: /agy timeout <1-${MAX_TURN_CAP_MIN}|off>. Default: off on an interactive pi, 20m headless.`,
|
|
989
|
+
"info",
|
|
990
|
+
);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const parsed = val === "off" ? 0 : parseCapMinutes(val, Number.NaN);
|
|
994
|
+
if (Number.isNaN(parsed)) {
|
|
995
|
+
ui?.notify(`Invalid turn cap "${val}". Use 1-${MAX_TURN_CAP_MIN} minutes, or off/0 to disable.`, "error");
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
saveConfig({ turnTimeoutMin: parsed });
|
|
999
|
+
ui?.notify(
|
|
1000
|
+
parsed === 0
|
|
1001
|
+
? `Turn time cap disabled. The inactivity stall guard (${live.inactivityTimeoutMin}m silence) still applies.`
|
|
1002
|
+
: `Turn time cap set to ${parsed}m. Takes effect next turn.`,
|
|
1003
|
+
"info",
|
|
1004
|
+
);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
979
1007
|
if (sub === "engine") {
|
|
980
1008
|
if (val === "acp" || val === "stream-json") {
|
|
981
1009
|
if (val === "acp" && loadConfig().mode === "plan") {
|
|
@@ -1354,6 +1382,14 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
1354
1382
|
currentValue: config.bridgeTools,
|
|
1355
1383
|
values: ["all", "mcp", "none"],
|
|
1356
1384
|
},
|
|
1385
|
+
{
|
|
1386
|
+
id: "turn-cap",
|
|
1387
|
+
label: "Turn time cap",
|
|
1388
|
+
description:
|
|
1389
|
+
"Max minutes for ONE agy turn before the bridge kills it. Default: no cap on an interactive pi (abort with Esc), 20 headless. 0 disables; 1-1440 enables the gate. Free type via /agy timeout <minutes|off> or config.json. Takes effect next turn.",
|
|
1390
|
+
currentValue: String(config.turnTimeoutMin),
|
|
1391
|
+
values: ["0", "1", "5", "10", "15", "30", "60", "120", "360", "720", "1440"],
|
|
1392
|
+
},
|
|
1357
1393
|
{
|
|
1358
1394
|
id: "digest",
|
|
1359
1395
|
label: "Context digest",
|
|
@@ -1394,6 +1430,8 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
1394
1430
|
pending.askTool = newValue === "on";
|
|
1395
1431
|
} else if (id === "bridge") {
|
|
1396
1432
|
pending.bridgeTools = newValue as BridgeTools;
|
|
1433
|
+
} else if (id === "turn-cap") {
|
|
1434
|
+
pending.turnTimeoutMin = Number(newValue);
|
|
1397
1435
|
} else if (id === "digest") {
|
|
1398
1436
|
pending.digest = newValue === "on";
|
|
1399
1437
|
} else if (id === "system-prompt") {
|
|
@@ -1441,6 +1479,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
1441
1479
|
pending.bridgeTools !== undefined ? `bridge=${next.bridgeTools}` : null,
|
|
1442
1480
|
pending.digest !== undefined ? `digest=${next.digest ? "on" : "off"}` : null,
|
|
1443
1481
|
pending.systemPrompt !== undefined ? `system-prompt=${next.systemPrompt ? "on" : "off"}` : null,
|
|
1482
|
+
pending.turnTimeoutMin !== undefined ? `turn cap=${next.turnTimeoutMin === 0 ? "off" : `${next.turnTimeoutMin}m`}` : null,
|
|
1444
1483
|
]
|
|
1445
1484
|
.filter(Boolean)
|
|
1446
1485
|
.join(", ");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/acp/driver.ts
CHANGED
|
@@ -611,6 +611,9 @@ export class AcpDriver implements TurnDriver {
|
|
|
611
611
|
}
|
|
612
612
|
|
|
613
613
|
#startOverallTimer(turn: ActiveTurn, ms: number): void {
|
|
614
|
+
// 0 disables the deadline (config turnTimeoutMin: 0). setTimeout(fn, 0)
|
|
615
|
+
// would fire instantly and kill every turn, guard or not.
|
|
616
|
+
if (ms <= 0) return;
|
|
614
617
|
// The deadline lives HERE, not just in the callers: the running branch
|
|
615
618
|
// of #armOverall never assigns it, and #pauseOverall keys off
|
|
616
619
|
// `deadline !== null` to do anything at all. Without this line every
|
|
@@ -649,13 +652,16 @@ export class AcpDriver implements TurnDriver {
|
|
|
649
652
|
#armIdle(turn: ActiveTurn): void {
|
|
650
653
|
if (turn.idleTimer) clearTimeout(turn.idleTimer);
|
|
651
654
|
if (turn.parks > 0) return; // parked: idle timer resumes on unpark
|
|
655
|
+
const idleMs = this.#idleBudgetMs(turn);
|
|
656
|
+
// 0 disables the stall guard (config inactivityTimeoutMin: 0).
|
|
657
|
+
if (idleMs <= 0) return;
|
|
652
658
|
turn.idleTimer = setTimeout(() => {
|
|
653
659
|
if (turn.closed) return;
|
|
654
660
|
this.#log("stall", { sessionId: turn.sessionId });
|
|
655
661
|
this.#conn?.abortAll("idle stall");
|
|
656
662
|
this.#conn?.kill();
|
|
657
|
-
this.#failTurn(turn, `ACP stalled for ${(
|
|
658
|
-
},
|
|
663
|
+
this.#failTurn(turn, `ACP stalled for ${(idleMs / 60_000) | 0}m with no output`);
|
|
664
|
+
}, idleMs);
|
|
659
665
|
}
|
|
660
666
|
|
|
661
667
|
#clearIdle(turn: ActiveTurn): void {
|
package/src/ask-tool.ts
CHANGED
|
@@ -17,7 +17,7 @@ import * as path from "node:path";
|
|
|
17
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { buildSessionContext, getAgentDir, keyHint } from "@earendil-works/pi-coding-agent";
|
|
19
19
|
import { Text } from "@earendil-works/pi-tui";
|
|
20
|
-
import { contentText } from "@earendil-works/pi-ai";
|
|
20
|
+
import { contentText, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
21
21
|
import { Type } from "typebox";
|
|
22
22
|
import {
|
|
23
23
|
CONVERSATIONS_DIR,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "./discovery.js";
|
|
27
27
|
import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
|
|
28
28
|
import { acquireBridgeSuppression } from "./mcp-registration.js";
|
|
29
|
-
import { spawnAgyModelsRaw } from "./models.js";
|
|
29
|
+
import { AGY_EFFORT_ORDER, spawnAgyModelsRaw, toAgyEffort } from "./models.js";
|
|
30
30
|
|
|
31
31
|
// --- Constants -------------------------------------------------------------
|
|
32
32
|
|
|
@@ -80,7 +80,12 @@ EXECUTION MODES (param: mode):
|
|
|
80
80
|
- **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks.
|
|
81
81
|
- **accept-edits** (default): agy applies edits directly inside the workspace.
|
|
82
82
|
|
|
83
|
-
COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits
|
|
83
|
+
COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.
|
|
84
|
+
|
|
85
|
+
THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
|
|
86
|
+
- pi calls it thinking, agy calls it effort. Same thing. Pass ONE of the two.
|
|
87
|
+
- Values (pi vocabulary): minimal|low|medium|high|xhigh|max. Clamped to agy's low|medium|high; unknown values fall back to low. "peer review on high thinking" -> thinking: "high".
|
|
88
|
+
- An explicit level beats a tier embedded in model ("flash high") and the configured default. Omit both for the configured default.`;
|
|
84
89
|
|
|
85
90
|
// --- Types -----------------------------------------------------------------
|
|
86
91
|
|
|
@@ -176,6 +181,10 @@ export function resolveModel(
|
|
|
176
181
|
input: string,
|
|
177
182
|
entries: ModelEntry[],
|
|
178
183
|
defaultThinking: ThinkingTier,
|
|
184
|
+
/** Explicit thinking param (thinking/effort). Beats a tier embedded in
|
|
185
|
+
* the alias ("flash high") and the configured default; clamped to the
|
|
186
|
+
* family's real tiers, ignored for fixed-thinking families. */
|
|
187
|
+
preferredTier?: ThinkingTier,
|
|
179
188
|
): ResolvedModel | null {
|
|
180
189
|
const lower = input.toLowerCase().trim();
|
|
181
190
|
|
|
@@ -232,6 +241,7 @@ export function resolveModel(
|
|
|
232
241
|
if (familyTiers.size === 0) return toResolved(candidates[0].full, null);
|
|
233
242
|
|
|
234
243
|
const preferred =
|
|
244
|
+
preferredTier ??
|
|
235
245
|
tier ??
|
|
236
246
|
(familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
|
|
237
247
|
const chosenTier = nearestTier([...familyTiers], preferred);
|
|
@@ -293,6 +303,18 @@ export async function registerAskAntigravityTool(
|
|
|
293
303
|
"Model alias or exact id. Friendly: 'flash', 'pro', 'gemini'. Add a tier: 'flash high'. Pin a version: '3.5 flash'. Exact: 'gemini-3.6-flash-medium'. Omit for the configured default.",
|
|
294
304
|
}),
|
|
295
305
|
),
|
|
306
|
+
thinking: Type.Optional(
|
|
307
|
+
Type.String({
|
|
308
|
+
description:
|
|
309
|
+
"Thinking level (= agy effort tier). pi vocabulary: minimal|low|medium|high|xhigh|max, clamped to agy's low|medium|high (unknown values fall back to low). Overrides a tier embedded in `model`. Omit for the configured default.",
|
|
310
|
+
}),
|
|
311
|
+
),
|
|
312
|
+
effort: Type.Optional(
|
|
313
|
+
Type.String({
|
|
314
|
+
description:
|
|
315
|
+
"Alias for `thinking` (agy's own name for the same knob). Pass ONE of the two; different values on both is an error.",
|
|
316
|
+
}),
|
|
317
|
+
),
|
|
296
318
|
mode: Type.Optional(
|
|
297
319
|
Type.Union([Type.Literal("plan"), Type.Literal("accept-edits")], {
|
|
298
320
|
description:
|
|
@@ -327,8 +349,14 @@ export async function registerAskAntigravityTool(
|
|
|
327
349
|
// row identifies what will actually run, not just explicit args.
|
|
328
350
|
const cfg = loadConfig();
|
|
329
351
|
const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
|
|
352
|
+
const thinkingArg = (args.thinking as string | undefined) ?? (args.effort as string | undefined);
|
|
330
353
|
const resolved =
|
|
331
|
-
resolveModel(
|
|
354
|
+
resolveModel(
|
|
355
|
+
requestedModel,
|
|
356
|
+
entries,
|
|
357
|
+
cfg.defaultThinking,
|
|
358
|
+
thinkingArg ? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER) : undefined,
|
|
359
|
+
) ?? { model: requestedModel };
|
|
332
360
|
const thinking: ThinkingTier = resolved.effort ?? cfg.defaultThinking;
|
|
333
361
|
const mode: AgyMode = (args.mode as AgyMode | undefined) ?? "accept-edits";
|
|
334
362
|
const useDigest = typeof args.digest === "boolean" ? args.digest : mode === "plan";
|
|
@@ -415,8 +443,27 @@ export async function registerAskAntigravityTool(
|
|
|
415
443
|
details: emptyDetails(requestedModel),
|
|
416
444
|
};
|
|
417
445
|
}
|
|
446
|
+
if (
|
|
447
|
+
typeof params.thinking === "string" &&
|
|
448
|
+
typeof params.effort === "string" &&
|
|
449
|
+
params.thinking !== params.effort
|
|
450
|
+
) {
|
|
451
|
+
return {
|
|
452
|
+
content: [
|
|
453
|
+
{
|
|
454
|
+
type: "text",
|
|
455
|
+
text: "thinking and effort are synonyms for the same knob - pass one, not both with different values.",
|
|
456
|
+
},
|
|
457
|
+
],
|
|
458
|
+
details: emptyDetails(requestedModel),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
const thinkingArg = (params.thinking as string | undefined) ?? (params.effort as string | undefined);
|
|
462
|
+
const preferredTier = thinkingArg
|
|
463
|
+
? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER)
|
|
464
|
+
: undefined;
|
|
418
465
|
const resolved =
|
|
419
|
-
resolveModel(requestedModel, entries, config.defaultThinking) ?? {
|
|
466
|
+
resolveModel(requestedModel, entries, config.defaultThinking, preferredTier) ?? {
|
|
420
467
|
model: requestedModel,
|
|
421
468
|
};
|
|
422
469
|
|
package/src/config.ts
CHANGED
|
@@ -141,6 +141,26 @@ export interface AgyConfig {
|
|
|
141
141
|
* digest (per-turn) is not. Turn off for agy-native behavior (agy's own
|
|
142
142
|
* system prompt only). */
|
|
143
143
|
systemPrompt: boolean;
|
|
144
|
+
/** Overall cap on ONE agy turn in minutes, both engines. When it fires, the
|
|
145
|
+
* bridge kills the agy process mid-task ("ACP turn exceeded the Xm
|
|
146
|
+
* deadline" / "agy exceeded the Xm turn timeout"). The ACP server binary
|
|
147
|
+
* exposes no timeout flag of its own (--helpfull: debug/notices only), so
|
|
148
|
+
* this bridge cap is the only knob.
|
|
149
|
+
*
|
|
150
|
+
* Default is TTY-aware: 0 (no cap) on an interactive pi - the user aborts
|
|
151
|
+
* with Esc and is the better backstop - and 20 on headless runs, where
|
|
152
|
+
* nobody can abort and one runaway turn blocks the drivers' serialized
|
|
153
|
+
* turn queue. Explicit values opt into the gate: 1..1440 valid, 0
|
|
154
|
+
* disables, anything else (garbage, negative, >1440) falls back to the
|
|
155
|
+
* TTY-aware default. The inactivity stall guard (5m silence) still bounds
|
|
156
|
+
* a hung server either way. Next-turn effect. Env AGY_TURN_TIMEOUT_MIN
|
|
157
|
+
* wins over the file. */
|
|
158
|
+
turnTimeoutMin: number;
|
|
159
|
+
/** Silence cap in minutes, both engines: no stream-json stdout / no ACP
|
|
160
|
+
* session/update for this long fails the turn as a stall. Default 5;
|
|
161
|
+
* 0 disables the guard. Next-turn effect.
|
|
162
|
+
* Env AGY_INACTIVITY_TIMEOUT_MIN wins over the file. */
|
|
163
|
+
inactivityTimeoutMin: number;
|
|
144
164
|
/** Approval gate over agy NATIVE tool calls (create_file, run_command,
|
|
145
165
|
* ...). pi tools agy calls already pass through pi's gates via the G9
|
|
146
166
|
* round-trip; this covers the rest. Default auto (off until a
|
|
@@ -158,10 +178,34 @@ const DEFAULTS: AgyConfig = {
|
|
|
158
178
|
bridgeTools: "all",
|
|
159
179
|
digest: false,
|
|
160
180
|
systemPrompt: true,
|
|
181
|
+
turnTimeoutMin: 0, // placeholder: the real default is resolved per loadConfig call (defaultTurnTimeoutMin)
|
|
182
|
+
inactivityTimeoutMin: 5,
|
|
161
183
|
approvals: { gateMode: "auto", mode: "ask" },
|
|
162
184
|
acp: { bin: "", permissions: "auto", usageEstimate: "estimate" },
|
|
163
185
|
};
|
|
164
186
|
|
|
187
|
+
/** Hard ceiling for a free-typed turn cap: one day, in minutes. */
|
|
188
|
+
export const MAX_TURN_CAP_MIN = 1440;
|
|
189
|
+
|
|
190
|
+
/** TTY-aware default for the overall turn cap. An interactive user aborts
|
|
191
|
+
* with Esc and is the better backstop; the wall clock only earns its keep
|
|
192
|
+
* where nobody can abort (headless runs: one runaway turn also blocks the
|
|
193
|
+
* drivers' serialized turn queue). */
|
|
194
|
+
export function defaultTurnTimeoutMin(): number {
|
|
195
|
+
return process.stdout.isTTY || process.stdin.isTTY ? 0 : 20;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Explicit cap minutes: 0 keeps (disable), 1..MAX_TURN_CAP_MIN keeps;
|
|
199
|
+
* garbage, negative, or out-of-range falls back to the caller's default.
|
|
200
|
+
* Pass Number.NaN as the fallback to detect rejection via isNaN. */
|
|
201
|
+
export function parseCapMinutes(raw: string | number | undefined, fallback: number): number {
|
|
202
|
+
if (raw === undefined || String(raw).trim() === "") return fallback;
|
|
203
|
+
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
|
|
204
|
+
if (!Number.isFinite(n)) return fallback;
|
|
205
|
+
if (n === 0) return 0;
|
|
206
|
+
return n >= 1 && n <= MAX_TURN_CAP_MIN ? n : fallback;
|
|
207
|
+
}
|
|
208
|
+
|
|
165
209
|
/** Load config merged over defaults. Env vars override the file when set. */
|
|
166
210
|
export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
167
211
|
let file: Partial<AgyConfig> = {};
|
|
@@ -228,6 +272,16 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
228
272
|
? ["1", "true", "on"].includes(envSys.toLowerCase())
|
|
229
273
|
: file.systemPrompt ?? DEFAULTS.systemPrompt;
|
|
230
274
|
|
|
275
|
+
// Turn caps, minutes. Env wins over the file (same pattern as mode).
|
|
276
|
+
const turnTimeoutMin = parseCapMinutes(
|
|
277
|
+
process.env.AGY_TURN_TIMEOUT_MIN ?? file.turnTimeoutMin,
|
|
278
|
+
defaultTurnTimeoutMin(),
|
|
279
|
+
);
|
|
280
|
+
const inactivityTimeoutMin = parseCapMinutes(
|
|
281
|
+
process.env.AGY_INACTIVITY_TIMEOUT_MIN ?? file.inactivityTimeoutMin,
|
|
282
|
+
DEFAULTS.inactivityTimeoutMin,
|
|
283
|
+
);
|
|
284
|
+
|
|
231
285
|
// Approval gate (docs/TODO.md 2.5). Unknown values fall back to "auto"
|
|
232
286
|
// so a typo can never silently force the gate on.
|
|
233
287
|
const gateRaw = (process.env.AGY_APPROVALS ?? file.approvals?.gateMode ?? DEFAULTS.approvals.gateMode).toLowerCase();
|
|
@@ -268,6 +322,8 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
268
322
|
bridgeTools,
|
|
269
323
|
digest,
|
|
270
324
|
systemPrompt,
|
|
325
|
+
turnTimeoutMin,
|
|
326
|
+
inactivityTimeoutMin,
|
|
271
327
|
approvals: { gateMode, mode: gateAskMode },
|
|
272
328
|
patchCleanupNotified: file.patchCleanupNotified === true,
|
|
273
329
|
};
|
package/src/driver-types.ts
CHANGED
|
@@ -36,9 +36,9 @@ export interface DriverTurnRequest extends DriverProfile {
|
|
|
36
36
|
contextBlock?: { uri: string; text: string };
|
|
37
37
|
signal?: AbortSignal;
|
|
38
38
|
/** Overall turn cap in minutes (default 10). Fractional values are valid
|
|
39
|
-
* (tests use sub-minute caps). */
|
|
39
|
+
* (tests use sub-minute caps). 0 disables the cap. */
|
|
40
40
|
timeoutMin?: number;
|
|
41
|
-
/** Stdout-inactivity cap in minutes (default 5). */
|
|
41
|
+
/** Stdout-inactivity cap in minutes (default 5). 0 disables the cap. */
|
|
42
42
|
inactivityMin?: number;
|
|
43
43
|
}
|
|
44
44
|
|
package/src/driver.ts
CHANGED
|
@@ -165,12 +165,15 @@ export class StreamDriver implements TurnDriver {
|
|
|
165
165
|
if (turn.parks > 0) turn.parks -= 1;
|
|
166
166
|
if (turn.parks === 0 && !turn.idleTimer) {
|
|
167
167
|
const idleMin = turn.request.inactivityMin ?? 5;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
168
|
+
// 0 disables the stall guard (config inactivityTimeoutMin: 0).
|
|
169
|
+
if (idleMin > 0) {
|
|
170
|
+
turn.idleTimer = setTimeout(() => {
|
|
171
|
+
if (turn.closed) return;
|
|
172
|
+
this.#log(`stall:${turn.id}`);
|
|
173
|
+
this.#killChild();
|
|
174
|
+
this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
|
|
175
|
+
}, idleMin * 60_000);
|
|
176
|
+
}
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
179
|
|
|
@@ -295,20 +298,26 @@ export class StreamDriver implements TurnDriver {
|
|
|
295
298
|
}
|
|
296
299
|
|
|
297
300
|
#armTimers(turn: ActiveTurn): void {
|
|
301
|
+
// 0 disables a cap (config turnTimeoutMin / inactivityTimeoutMin: 0):
|
|
302
|
+
// setTimeout(fn, 0) would fire instantly and kill every turn.
|
|
298
303
|
const totalMin = turn.request.timeoutMin ?? 10;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
304
|
+
if (totalMin > 0) {
|
|
305
|
+
turn.overallTimer = setTimeout(() => {
|
|
306
|
+
if (turn.closed) return;
|
|
307
|
+
this.#log(`timeout:${turn.id}`);
|
|
308
|
+
this.#killChild();
|
|
309
|
+
this.#failTurn(turn, `agy exceeded the ${totalMin}m turn timeout`);
|
|
310
|
+
}, totalMin * 60_000);
|
|
311
|
+
}
|
|
305
312
|
const idleMin = turn.request.inactivityMin ?? 5;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
313
|
+
if (idleMin > 0) {
|
|
314
|
+
turn.idleTimer = setTimeout(() => {
|
|
315
|
+
if (turn.closed) return;
|
|
316
|
+
this.#log(`stall:${turn.id}`);
|
|
317
|
+
this.#killChild();
|
|
318
|
+
this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
|
|
319
|
+
}, idleMin * 60_000);
|
|
320
|
+
}
|
|
312
321
|
}
|
|
313
322
|
|
|
314
323
|
#start(request: DriverTurnRequest): void {
|
package/src/models.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { spawn } from "node:child_process";
|
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import os from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
|
-
import type { Api, Model, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
24
|
+
import type { Api, Model, ThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
25
25
|
|
|
26
26
|
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
27
27
|
|
|
@@ -31,6 +31,47 @@ export type AgyEffort = "low" | "medium" | "high";
|
|
|
31
31
|
/** low < medium < high, for sorting/clamping. */
|
|
32
32
|
const EFFORT_RANK: Record<AgyEffort, number> = { low: 0, medium: 1, high: 2 };
|
|
33
33
|
|
|
34
|
+
/** pi thinking-effort order mirrors agy's, for clamping. */
|
|
35
|
+
export const AGY_EFFORT_ORDER: readonly AgyEffort[] = ["low", "medium", "high"];
|
|
36
|
+
|
|
37
|
+
/** Map pi's thinking level onto one of the tiers `efforts` the base actually
|
|
38
|
+
* supports, clamping to the nearest available. agy rejects an effort tier a
|
|
39
|
+
* base doesn't list (e.g. medium on Pro), so we never emit one. Shared by the
|
|
40
|
+
* provider turns AND the AskAntigravity delegation tool (thinking/effort
|
|
41
|
+
* params): one vocabulary, one clamp. When pi sends no level we fall to the
|
|
42
|
+
* first available tier. */
|
|
43
|
+
export function toAgyEffort(
|
|
44
|
+
reasoning: ThinkingLevel | undefined,
|
|
45
|
+
efforts: readonly AgyEffort[],
|
|
46
|
+
): AgyEffort {
|
|
47
|
+
let candidate: AgyEffort;
|
|
48
|
+
switch (reasoning) {
|
|
49
|
+
case "minimal":
|
|
50
|
+
case "low":
|
|
51
|
+
candidate = "low";
|
|
52
|
+
break;
|
|
53
|
+
case "medium":
|
|
54
|
+
candidate = "medium";
|
|
55
|
+
break;
|
|
56
|
+
case "high":
|
|
57
|
+
case "xhigh":
|
|
58
|
+
case "max":
|
|
59
|
+
candidate = "high";
|
|
60
|
+
break;
|
|
61
|
+
default:
|
|
62
|
+
candidate = efforts[0] ?? "low";
|
|
63
|
+
}
|
|
64
|
+
if (efforts.includes(candidate)) return candidate;
|
|
65
|
+
const i = AGY_EFFORT_ORDER.indexOf(candidate);
|
|
66
|
+
for (let j = i; j < AGY_EFFORT_ORDER.length; j++) {
|
|
67
|
+
if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
|
|
68
|
+
}
|
|
69
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
70
|
+
if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
|
|
71
|
+
}
|
|
72
|
+
return efforts[0] ?? "low";
|
|
73
|
+
}
|
|
74
|
+
|
|
34
75
|
/** Split an agy slug into (base, tier). tier is null when the slug has no
|
|
35
76
|
* -high/-medium/-low suffix (claude-sonnet-4-6, gpt-oss-120b-medium's "medium"
|
|
36
77
|
* IS its suffix here, claude-opus-4-6-thinking is not a tier). */
|
package/src/provider.ts
CHANGED
|
@@ -33,7 +33,7 @@ import type { Api } from "@earendil-works/pi-ai";
|
|
|
33
33
|
import type { DriverActivity, TurnDriver, TurnHandle } from "./driver-types.js";
|
|
34
34
|
import { toPiUsage } from "./stream-events.js";
|
|
35
35
|
import { mapAgyToolToNative } from "./native-tools.js";
|
|
36
|
-
import {
|
|
36
|
+
import { toAgyEffort, type AgyModelEntry } from "./models.js";
|
|
37
37
|
import { SessionStore } from "./sessions.js";
|
|
38
38
|
import { loadConfig } from "./config.js";
|
|
39
39
|
import { GATE_MARKER, mapNativeToShadow, stripMarkerFields } from "./approval-gate.js";
|
|
@@ -338,46 +338,6 @@ export interface StreamSimpleDeps {
|
|
|
338
338
|
log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
-
/** pi thinking-effort order mirrors agy's, for clamping. */
|
|
342
|
-
const AGY_EFFORT_ORDER: readonly AgyEffort[] = ["low", "medium", "high"];
|
|
343
|
-
|
|
344
|
-
/** Map pi's thinking level onto one of the tiers `efforts` the base actually
|
|
345
|
-
* supports, clamping to the nearest available. agy rejects an effort tier a
|
|
346
|
-
* base doesn't list (e.g. medium on Pro), so we never emit one. A base slug is
|
|
347
|
-
* invalid without --effort, so when pi sends no level we default to the
|
|
348
|
-
* middle tier (or the highest available). */
|
|
349
|
-
export function toAgyEffort(
|
|
350
|
-
reasoning: ThinkingLevel | undefined,
|
|
351
|
-
efforts: readonly AgyEffort[],
|
|
352
|
-
): AgyEffort {
|
|
353
|
-
let candidate: AgyEffort;
|
|
354
|
-
switch (reasoning) {
|
|
355
|
-
case "minimal":
|
|
356
|
-
case "low":
|
|
357
|
-
candidate = "low";
|
|
358
|
-
break;
|
|
359
|
-
case "medium":
|
|
360
|
-
candidate = "medium";
|
|
361
|
-
break;
|
|
362
|
-
case "high":
|
|
363
|
-
case "xhigh":
|
|
364
|
-
case "max":
|
|
365
|
-
candidate = "high";
|
|
366
|
-
break;
|
|
367
|
-
default:
|
|
368
|
-
candidate = efforts[0] ?? "low";
|
|
369
|
-
}
|
|
370
|
-
if (efforts.includes(candidate)) return candidate;
|
|
371
|
-
const i = AGY_EFFORT_ORDER.indexOf(candidate);
|
|
372
|
-
for (let j = i; j < AGY_EFFORT_ORDER.length; j++) {
|
|
373
|
-
if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
|
|
374
|
-
}
|
|
375
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
376
|
-
if (efforts.includes(AGY_EFFORT_ORDER[j])) return AGY_EFFORT_ORDER[j];
|
|
377
|
-
}
|
|
378
|
-
return efforts[0] ?? "low";
|
|
379
|
-
}
|
|
380
|
-
|
|
381
341
|
// --- G9: no-patch pi-tool round-trips -----------------------------------------
|
|
382
342
|
//
|
|
383
343
|
// The MCP bridge's onToolCall parks the call here instead of executing it:
|
|
@@ -1231,6 +1191,8 @@ async function runTurnDriver(
|
|
|
1231
1191
|
effort,
|
|
1232
1192
|
mode: config.mode,
|
|
1233
1193
|
skipPermissions: config.skipPermissions,
|
|
1194
|
+
timeoutMin: config.turnTimeoutMin,
|
|
1195
|
+
inactivityMin: config.inactivityTimeoutMin,
|
|
1234
1196
|
conversationId: existing?.conversationId ?? null,
|
|
1235
1197
|
prompt: fullPrompt,
|
|
1236
1198
|
images: images.length > 0 ? images : undefined,
|