@aexol/spectral 0.9.115 → 0.9.117
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/dist/extensions/browser/config.d.ts +25 -2
- package/dist/extensions/browser/config.d.ts.map +1 -1
- package/dist/extensions/browser/config.js +34 -2
- package/dist/extensions/browser/scenario/scenario-tool.d.ts.map +1 -1
- package/dist/extensions/browser/scenario/scenario-tool.js +5 -3
- package/dist/extensions/browser/tools/screenshot.d.ts +0 -3
- package/dist/extensions/browser/tools/screenshot.d.ts.map +1 -1
- package/dist/extensions/browser/tools/screenshot.js +9 -12
- package/dist/sdk/coding-agent/core/exec.js +2 -2
- package/dist/sdk/coding-agent/core/tools/bash.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/tools/bash.js +23 -4
- package/dist/sdk/coding-agent/utils/child-process.d.ts +12 -1
- package/dist/sdk/coding-agent/utils/child-process.d.ts.map +1 -1
- package/dist/sdk/coding-agent/utils/child-process.js +34 -3
- package/dist/server/agent-bridge.d.ts +28 -3
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +117 -18
- package/package.json +1 -1
|
@@ -14,9 +14,32 @@ export interface BrowserConfig {
|
|
|
14
14
|
width: number;
|
|
15
15
|
height: number;
|
|
16
16
|
};
|
|
17
|
-
/** Whether to run the browser headless during recordings. Defaults to false
|
|
18
|
-
* (headful) for higher-quality video capture, mirroring the demo recorder. */
|
|
19
17
|
recordHeadless: boolean;
|
|
20
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a (possibly relative) output directory to an absolute path.
|
|
21
|
+
*
|
|
22
|
+
* - Absolute paths are returned as-is (normalized).
|
|
23
|
+
* - Relative paths are resolved against `cwd` when provided, otherwise against
|
|
24
|
+
* `$HOME` — matching the convention in `server/paths.ts` (the server process
|
|
25
|
+
* runs from `$HOME`, so relative-to-home matches the user's mental model).
|
|
26
|
+
*
|
|
27
|
+
* This prevents browser screenshots/recordings from leaking into
|
|
28
|
+
* `~/.spectral/browser-output/` when the extension is loaded from a server
|
|
29
|
+
* whose `process.cwd()` is the home directory rather than the project root.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a (possibly relative) output directory to an absolute path.
|
|
33
|
+
*
|
|
34
|
+
* - Absolute paths are returned as-is (normalized).
|
|
35
|
+
* - Relative paths are resolved against `cwd` when provided, otherwise against
|
|
36
|
+
* `$HOME` — matching the convention in `server/paths.ts` (the server process
|
|
37
|
+
* runs from `$HOME`, so relative-to-home matches the user's mental model).
|
|
38
|
+
*
|
|
39
|
+
* This prevents browser screenshots/recordings from leaking into
|
|
40
|
+
* `~/.spectral/browser-output/` when the extension is loaded from a server
|
|
41
|
+
* whose `process.cwd()` is the home directory rather than the project root.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveOutputDir(input: string, cwd?: string): string;
|
|
21
44
|
export declare function getBrowserConfig(): BrowserConfig;
|
|
22
45
|
//# sourceMappingURL=config.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/extensions/browser/config.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,aAAa;IAC5B,WAAW,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC/C,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAC5C,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;GAWG;AACH;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED,wBAAgB,gBAAgB,IAAI,aAAa,CAgBhD"}
|
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a (possibly relative) output directory to an absolute path.
|
|
5
|
+
*
|
|
6
|
+
* - Absolute paths are returned as-is (normalized).
|
|
7
|
+
* - Relative paths are resolved against `cwd` when provided, otherwise against
|
|
8
|
+
* `$HOME` — matching the convention in `server/paths.ts` (the server process
|
|
9
|
+
* runs from `$HOME`, so relative-to-home matches the user's mental model).
|
|
10
|
+
*
|
|
11
|
+
* This prevents browser screenshots/recordings from leaking into
|
|
12
|
+
* `~/.spectral/browser-output/` when the extension is loaded from a server
|
|
13
|
+
* whose `process.cwd()` is the home directory rather than the project root.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a (possibly relative) output directory to an absolute path.
|
|
17
|
+
*
|
|
18
|
+
* - Absolute paths are returned as-is (normalized).
|
|
19
|
+
* - Relative paths are resolved against `cwd` when provided, otherwise against
|
|
20
|
+
* `$HOME` — matching the convention in `server/paths.ts` (the server process
|
|
21
|
+
* runs from `$HOME`, so relative-to-home matches the user's mental model).
|
|
22
|
+
*
|
|
23
|
+
* This prevents browser screenshots/recordings from leaking into
|
|
24
|
+
* `~/.spectral/browser-output/` when the extension is loaded from a server
|
|
25
|
+
* whose `process.cwd()` is the home directory rather than the project root.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveOutputDir(input, cwd) {
|
|
28
|
+
if (isAbsolute(input))
|
|
29
|
+
return resolve(input);
|
|
30
|
+
const base = cwd ?? homedir();
|
|
31
|
+
return resolve(base, input);
|
|
32
|
+
}
|
|
1
33
|
export function getBrowserConfig() {
|
|
2
34
|
return {
|
|
3
35
|
browserName: process.env.BROWSER_NAME ?? "chromium",
|
|
@@ -8,9 +40,9 @@ export function getBrowserConfig() {
|
|
|
8
40
|
},
|
|
9
41
|
navigationTimeout: Number(process.env.BROWSER_NAVIGATION_TIMEOUT) || 60_000,
|
|
10
42
|
actionTimeout: Number(process.env.BROWSER_ACTION_TIMEOUT) || 5_000,
|
|
11
|
-
outputDir: process.env.BROWSER_OUTPUT_DIR ?? ".spectral/browser-output",
|
|
43
|
+
outputDir: resolveOutputDir(process.env.BROWSER_OUTPUT_DIR ?? ".spectral/browser-output"),
|
|
12
44
|
debug: process.env.BROWSER_DEBUG === "true" ? true : false,
|
|
13
|
-
recordOutputDir: process.env.BROWSER_RECORD_DIR ?? ".spectral/browser-recordings",
|
|
45
|
+
recordOutputDir: resolveOutputDir(process.env.BROWSER_RECORD_DIR ?? ".spectral/browser-recordings"),
|
|
14
46
|
recordViewport: undefined, // defaults to viewport via code
|
|
15
47
|
recordHeadless: process.env.BROWSER_RECORD_HEADLESS === "true" ? true : false,
|
|
16
48
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scenario-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/scenario/scenario-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"scenario-tool.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/scenario/scenario-tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AACvF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAuC5D,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CAgWjF"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveOutputDir } from "../config.js";
|
|
1
2
|
import { parseMarkdownScenarioFile, } from "./markdown-parser.js";
|
|
2
3
|
function sleep(ms) {
|
|
3
4
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -82,7 +83,7 @@ export function registerScenarioTools(ext, bs) {
|
|
|
82
83
|
},
|
|
83
84
|
required: ["url"],
|
|
84
85
|
},
|
|
85
|
-
async execute(_toolCallId, params) {
|
|
86
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
86
87
|
const p = params;
|
|
87
88
|
if (!p.url) {
|
|
88
89
|
return {
|
|
@@ -272,9 +273,10 @@ export function registerScenarioTools(ext, bs) {
|
|
|
272
273
|
case "screenshot": {
|
|
273
274
|
const config = bs.getConfig();
|
|
274
275
|
const { mkdirSync } = await import("node:fs");
|
|
275
|
-
|
|
276
|
+
const outDir = resolveOutputDir(config.outputDir, ctx.cwd);
|
|
277
|
+
mkdirSync(outDir, { recursive: true });
|
|
276
278
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
277
|
-
const filepath = `${
|
|
279
|
+
const filepath = `${outDir}/scenario-step-${i + 1}-${ts}.png`;
|
|
278
280
|
await page.screenshot({ path: filepath });
|
|
279
281
|
detail = `Screenshot: ${filepath}`;
|
|
280
282
|
break;
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Screenshot & snapshot tools: browser_take_screenshot, browser_snapshot.
|
|
3
|
-
*/
|
|
4
1
|
import type { ExtensionAPI } from "../../../sdk/coding-agent/index.js";
|
|
5
2
|
import { BrowserService } from "../browser-service.js";
|
|
6
3
|
export declare function registerScreenshotTools(ext: ExtensionAPI, bs: BrowserService): void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/tools/screenshot.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"screenshot.d.ts","sourceRoot":"","sources":["../../../../src/extensions/browser/tools/screenshot.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,oCAAoC,CAAC;AAEvF,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,YAAY,EAAE,EAAE,EAAE,cAAc,GAAG,IAAI,CA8InF"}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Screenshot & snapshot tools: browser_take_screenshot, browser_snapshot.
|
|
3
|
-
*/
|
|
4
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
2
|
import { join } from "node:path";
|
|
3
|
+
import { resolveOutputDir } from "../config.js";
|
|
6
4
|
export function registerScreenshotTools(ext, bs) {
|
|
7
5
|
const screenshotTool = {
|
|
8
6
|
name: "browser_take_screenshot",
|
|
@@ -29,14 +27,15 @@ export function registerScreenshotTools(ext, bs) {
|
|
|
29
27
|
},
|
|
30
28
|
},
|
|
31
29
|
},
|
|
32
|
-
async execute(_toolCallId, params) {
|
|
30
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
33
31
|
const p = params;
|
|
34
32
|
try {
|
|
35
33
|
const page = bs.getActivePage();
|
|
36
34
|
const config = bs.getConfig();
|
|
37
|
-
|
|
35
|
+
const outDir = resolveOutputDir(config.outputDir, ctx.cwd);
|
|
36
|
+
mkdirSync(outDir, { recursive: true });
|
|
38
37
|
const filename = p.filename ?? `screenshot-${Date.now()}.${p.type ?? "png"}`;
|
|
39
|
-
const filepath = join(
|
|
38
|
+
const filepath = join(outDir, filename);
|
|
40
39
|
await page.screenshot({
|
|
41
40
|
path: filepath,
|
|
42
41
|
fullPage: p.fullPage ?? false,
|
|
@@ -73,12 +72,11 @@ export function registerScreenshotTools(ext, bs) {
|
|
|
73
72
|
},
|
|
74
73
|
},
|
|
75
74
|
},
|
|
76
|
-
async execute(_toolCallId, params) {
|
|
75
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
77
76
|
const p = params;
|
|
78
77
|
try {
|
|
79
78
|
const page = bs.getActivePage();
|
|
80
79
|
const config = bs.getConfig();
|
|
81
|
-
// Inject ARIA snapshot helper
|
|
82
80
|
const snapshot = await page.evaluate(() => {
|
|
83
81
|
function buildSnapshot(element, depth) {
|
|
84
82
|
if (depth > 20)
|
|
@@ -95,7 +93,6 @@ export function registerScreenshotTools(ext, bs) {
|
|
|
95
93
|
const text = element.childNodes.length === 1 && element.childNodes[0].nodeType === Node.TEXT_NODE
|
|
96
94
|
? ` "${element.textContent?.trim()}"`
|
|
97
95
|
: "";
|
|
98
|
-
// Build element description
|
|
99
96
|
let desc = `${indent}${tag}${id}${className}`;
|
|
100
97
|
if (role)
|
|
101
98
|
desc += ` [role=${role}]`;
|
|
@@ -103,7 +100,6 @@ export function registerScreenshotTools(ext, bs) {
|
|
|
103
100
|
desc += ` [aria-label="${ariaLabel}"]`;
|
|
104
101
|
if (text && text.length < 100)
|
|
105
102
|
desc += text;
|
|
106
|
-
// Interactive hints
|
|
107
103
|
const href = element.href;
|
|
108
104
|
const placeholder = element.placeholder;
|
|
109
105
|
const value = element.value;
|
|
@@ -121,9 +117,10 @@ export function registerScreenshotTools(ext, bs) {
|
|
|
121
117
|
}
|
|
122
118
|
return buildSnapshot(document.body ?? document.documentElement, 0);
|
|
123
119
|
});
|
|
124
|
-
|
|
120
|
+
const outDir = resolveOutputDir(config.outputDir, ctx.cwd);
|
|
121
|
+
mkdirSync(outDir, { recursive: true });
|
|
125
122
|
const filename = p.filename ?? `snapshot-${Date.now()}.txt`;
|
|
126
|
-
const filepath = join(
|
|
123
|
+
const filepath = join(outDir, filename);
|
|
127
124
|
writeFileSync(filepath, snapshot, "utf-8");
|
|
128
125
|
const truncated = snapshot.length > 50000
|
|
129
126
|
? snapshot.slice(0, 50000) + `\n\n[truncated — full snapshot saved to ${filepath}]`
|
|
@@ -54,13 +54,13 @@ export async function execCommand(command, args, cwd, options) {
|
|
|
54
54
|
// Wait for process termination without hanging on inherited stdio handles
|
|
55
55
|
// held open by detached descendants.
|
|
56
56
|
waitForChildProcess(proc)
|
|
57
|
-
.then((code) => {
|
|
57
|
+
.then(({ code, forcedKill }) => {
|
|
58
58
|
if (timeoutId)
|
|
59
59
|
clearTimeout(timeoutId);
|
|
60
60
|
if (options?.signal) {
|
|
61
61
|
options.signal.removeEventListener("abort", killProcess);
|
|
62
62
|
}
|
|
63
|
-
resolve({ stdout, stderr, code: code ?? 0, killed });
|
|
63
|
+
resolve({ stdout, stderr, code: forcedKill ? 1 : code ?? 0, killed: forcedKill || killed });
|
|
64
64
|
})
|
|
65
65
|
.catch((_err) => {
|
|
66
66
|
if (timeoutId)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bash.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/bash.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAE9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAU5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAExG,QAAA,MAAM,UAAU;;;EAGd,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B;;;;;;OAMG;IACH,IAAI,EAAE,CACL,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;QACR,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;QAC/B,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;KACxB,KACG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;CAC1C;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,
|
|
1
|
+
{"version":3,"file":"bash.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/bash.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAE9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAU5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAExG,QAAA,MAAM,UAAU;;;EAGd,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B;;;;;;OAMG;IACH,IAAI,EAAE,CACL,OAAO,EAAE,MAAM,EACf,GAAG,EAAE,MAAM,EACX,OAAO,EAAE;QACR,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;QAC/B,MAAM,CAAC,EAAE,WAAW,CAAC;QACrB,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;KACxB,KACG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAC;CAC1C;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,cAAc,CAkF1F;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;CACvB;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,gBAAgB,KAAK,gBAAgB,CAAC;AAO5E,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,mFAAmF;IACnF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,aAAa,CAAC;CAC1B;AAyFD,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CAsIhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
|
|
@@ -41,8 +41,17 @@ export function createLocalBashOperations(options) {
|
|
|
41
41
|
if (timeout !== undefined && timeout > 0) {
|
|
42
42
|
timeoutHandle = setTimeout(() => {
|
|
43
43
|
timedOut = true;
|
|
44
|
-
if (child.pid)
|
|
45
|
-
|
|
44
|
+
if (child.pid) {
|
|
45
|
+
try {
|
|
46
|
+
killProcessTree(child.pid);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// ESRCH: process already exited. If it's truly gone, treat as normal exit.
|
|
50
|
+
if (child.exitCode !== null || child.signalCode) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
46
55
|
}, timeout * 1000);
|
|
47
56
|
}
|
|
48
57
|
// Stream stdout and stderr.
|
|
@@ -61,8 +70,13 @@ export function createLocalBashOperations(options) {
|
|
|
61
70
|
}
|
|
62
71
|
// Handle shell spawn errors and wait for the process to terminate without hanging
|
|
63
72
|
// on inherited stdio handles held by detached descendants.
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
// Hard deadline = max(LLM timeout + 5s grace, 30m default). Guarantees the
|
|
74
|
+
// promise resolves even if a detached descendant keeps the pipe open.
|
|
75
|
+
const hardDeadlineMs = timeout !== undefined && timeout > 0
|
|
76
|
+
? Math.max(timeout * 1000 + 5000, BASH_HARD_DEADLINE_MS)
|
|
77
|
+
: BASH_HARD_DEADLINE_MS;
|
|
78
|
+
waitForChildProcess(child, hardDeadlineMs)
|
|
79
|
+
.then(({ code, forcedKill }) => {
|
|
66
80
|
if (child.pid)
|
|
67
81
|
untrackDetachedChildPid(child.pid);
|
|
68
82
|
if (timeoutHandle)
|
|
@@ -73,6 +87,10 @@ export function createLocalBashOperations(options) {
|
|
|
73
87
|
reject(new Error("aborted"));
|
|
74
88
|
return;
|
|
75
89
|
}
|
|
90
|
+
if (forcedKill) {
|
|
91
|
+
reject(new Error(`Command force-killed after hard deadline (${Math.round(hardDeadlineMs / 60000)}m)`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
76
94
|
if (timedOut) {
|
|
77
95
|
reject(new Error(`timeout:${timeout}`));
|
|
78
96
|
return;
|
|
@@ -98,6 +116,7 @@ function resolveSpawnContext(command, cwd, spawnHook) {
|
|
|
98
116
|
}
|
|
99
117
|
const BASH_PREVIEW_LINES = 5;
|
|
100
118
|
const BASH_UPDATE_THROTTLE_MS = 100;
|
|
119
|
+
const BASH_HARD_DEADLINE_MS = Number(process.env.SPECTRAL_BASH_HARD_DEADLINE_MS) || 30 * 60 * 1000;
|
|
101
120
|
function formatDuration(ms) {
|
|
102
121
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
103
122
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { type ChildProcess, type ChildProcessByStdio, type SpawnOptions, type SpawnOptionsWithStdioTuple, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns, type StdioNull, type StdioPipe } from "node:child_process";
|
|
2
2
|
import type { Readable } from "node:stream";
|
|
3
|
+
export interface WaitForChildProcessResult {
|
|
4
|
+
/** Process exit code, or null if the process did not exit cleanly. */
|
|
5
|
+
code: number | null;
|
|
6
|
+
/** True when the hard deadline fired and we force-killed the process tree. */
|
|
7
|
+
forcedKill: boolean;
|
|
8
|
+
}
|
|
3
9
|
export declare function spawnProcess(command: string, args: string[], options: SpawnOptionsWithStdioTuple<StdioNull, StdioPipe, StdioPipe>): ChildProcessByStdio<null, Readable, Readable>;
|
|
4
10
|
export declare function spawnProcess(command: string, args: string[], options: SpawnOptions): ChildProcess;
|
|
5
11
|
export declare function spawnProcessSync(command: string, args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string>;
|
|
@@ -10,6 +16,11 @@ export declare function spawnProcessSync(command: string, args: string[], option
|
|
|
10
16
|
* handles. In that case the child emits `exit`, but `close` can hang forever even
|
|
11
17
|
* though the original process is already gone. We wait briefly for stdio to end,
|
|
12
18
|
* then forcibly stop tracking the inherited handles.
|
|
19
|
+
*
|
|
20
|
+
* As an escape hatch, a hard deadline (default 30 minutes, env-overridable via
|
|
21
|
+
* SPECTRAL_BASH_HARD_DEADLINE_MS) force-kills the process tree and resolves the
|
|
22
|
+
* promise so callers can never hang indefinitely on a pipe held open by a
|
|
23
|
+
* detached descendant.
|
|
13
24
|
*/
|
|
14
|
-
export declare function waitForChildProcess(child: ChildProcess): Promise<
|
|
25
|
+
export declare function waitForChildProcess(child: ChildProcess, hardDeadlineMs?: number): Promise<WaitForChildProcessResult>;
|
|
15
26
|
//# sourceMappingURL=child-process.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"child-process.d.ts","sourceRoot":"","sources":["../../../../src/sdk/coding-agent/utils/child-process.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,mBAAmB,EAGxB,KAAK,YAAY,EACjB,KAAK,0BAA0B,EAC/B,KAAK,kCAAkC,EACvC,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACd,KAAK,SAAS,EACd,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"child-process.d.ts","sourceRoot":"","sources":["../../../../src/sdk/coding-agent/utils/child-process.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,YAAY,EACjB,KAAK,mBAAmB,EAGxB,KAAK,YAAY,EACjB,KAAK,0BAA0B,EAC/B,KAAK,kCAAkC,EACvC,KAAK,gBAAgB,EACrB,KAAK,SAAS,EACd,KAAK,SAAS,EACd,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAO5C,MAAM,WAAW,yBAAyB;IACzC,sEAAsE;IACtE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,8EAA8E;IAC9E,UAAU,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,YAAY,CAC3B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,0BAA0B,CAAC,SAAS,EAAE,SAAS,EAAE,SAAS,CAAC,GAClE,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,QAAQ,CAAC,CAAC;AACjD,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,YAAY,GAAG,YAAY,CAAC;AAKnG,wBAAgB,gBAAgB,CAC/B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,EAAE,kCAAkC,GACzC,gBAAgB,CAAC,MAAM,CAAC,CAI1B;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAClC,KAAK,EAAE,YAAY,EACnB,cAAc,GAAE,MAA8B,GAC5C,OAAO,CAAC,yBAAyB,CAAC,CAsFpC"}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, } from "node:child_process";
|
|
2
2
|
import crossSpawn from "cross-spawn";
|
|
3
|
+
import { killProcessTree } from "./shell.js";
|
|
3
4
|
const EXIT_STDIO_GRACE_MS = 100;
|
|
5
|
+
const BASH_HARD_DEADLINE_MS = Number(process.env.SPECTRAL_BASH_HARD_DEADLINE_MS) || 30 * 60 * 1000;
|
|
4
6
|
export function spawnProcess(command, args, options) {
|
|
5
7
|
return process.platform === "win32" ? crossSpawn(command, args, options) : nodeSpawn(command, args, options);
|
|
6
8
|
}
|
|
@@ -16,13 +18,19 @@ export function spawnProcessSync(command, args, options) {
|
|
|
16
18
|
* handles. In that case the child emits `exit`, but `close` can hang forever even
|
|
17
19
|
* though the original process is already gone. We wait briefly for stdio to end,
|
|
18
20
|
* then forcibly stop tracking the inherited handles.
|
|
21
|
+
*
|
|
22
|
+
* As an escape hatch, a hard deadline (default 30 minutes, env-overridable via
|
|
23
|
+
* SPECTRAL_BASH_HARD_DEADLINE_MS) force-kills the process tree and resolves the
|
|
24
|
+
* promise so callers can never hang indefinitely on a pipe held open by a
|
|
25
|
+
* detached descendant.
|
|
19
26
|
*/
|
|
20
|
-
export function waitForChildProcess(child) {
|
|
27
|
+
export function waitForChildProcess(child, hardDeadlineMs = BASH_HARD_DEADLINE_MS) {
|
|
21
28
|
return new Promise((resolve, reject) => {
|
|
22
29
|
let settled = false;
|
|
23
30
|
let exited = false;
|
|
24
31
|
let exitCode = null;
|
|
25
32
|
let postExitTimer;
|
|
33
|
+
let hardDeadlineTimer;
|
|
26
34
|
let stdoutEnded = child.stdout === null;
|
|
27
35
|
let stderrEnded = child.stderr === null;
|
|
28
36
|
const cleanup = () => {
|
|
@@ -30,20 +38,24 @@ export function waitForChildProcess(child) {
|
|
|
30
38
|
clearTimeout(postExitTimer);
|
|
31
39
|
postExitTimer = undefined;
|
|
32
40
|
}
|
|
41
|
+
if (hardDeadlineTimer) {
|
|
42
|
+
clearTimeout(hardDeadlineTimer);
|
|
43
|
+
hardDeadlineTimer = undefined;
|
|
44
|
+
}
|
|
33
45
|
child.removeListener("error", onError);
|
|
34
46
|
child.removeListener("exit", onExit);
|
|
35
47
|
child.removeListener("close", onClose);
|
|
36
48
|
child.stdout?.removeListener("end", onStdoutEnd);
|
|
37
49
|
child.stderr?.removeListener("end", onStderrEnd);
|
|
38
50
|
};
|
|
39
|
-
const finalize = (code) => {
|
|
51
|
+
const finalize = (code, forcedKill = false) => {
|
|
40
52
|
if (settled)
|
|
41
53
|
return;
|
|
42
54
|
settled = true;
|
|
43
55
|
cleanup();
|
|
44
56
|
child.stdout?.destroy();
|
|
45
57
|
child.stderr?.destroy();
|
|
46
|
-
resolve(code);
|
|
58
|
+
resolve({ code, forcedKill });
|
|
47
59
|
};
|
|
48
60
|
const maybeFinalizeAfterExit = () => {
|
|
49
61
|
if (!exited || settled)
|
|
@@ -83,5 +95,24 @@ export function waitForChildProcess(child) {
|
|
|
83
95
|
child.once("error", onError);
|
|
84
96
|
child.once("exit", onExit);
|
|
85
97
|
child.once("close", onClose);
|
|
98
|
+
// Hard deadline escape hatch: if the child (or a grandchild) keeps the
|
|
99
|
+
// stdout/stderr pipe open indefinitely, force-kill the tree and resolve.
|
|
100
|
+
if (hardDeadlineMs > 0 && !settled) {
|
|
101
|
+
hardDeadlineTimer = setTimeout(() => {
|
|
102
|
+
if (settled)
|
|
103
|
+
return;
|
|
104
|
+
if (child.pid) {
|
|
105
|
+
try {
|
|
106
|
+
killProcessTree(child.pid);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Process may already be gone; ignore.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
child.stdout?.destroy();
|
|
113
|
+
child.stderr?.destroy();
|
|
114
|
+
finalize(null, true);
|
|
115
|
+
}, hardDeadlineMs);
|
|
116
|
+
}
|
|
86
117
|
});
|
|
87
118
|
}
|
|
@@ -168,6 +168,8 @@ export declare class AgentBridge {
|
|
|
168
168
|
private lastAppliedModelId?;
|
|
169
169
|
private promptActivityTimer?;
|
|
170
170
|
private promptReject?;
|
|
171
|
+
/** Per-turn tool execution backstop timer (see TOOL_EXECUTION_TIMEOUT_MS). */
|
|
172
|
+
private toolExecutionTimer?;
|
|
171
173
|
/** Current model's credit rates (from availableAgentModels), used for token_usage. */
|
|
172
174
|
private activeCreditRates;
|
|
173
175
|
private memoryPhase;
|
|
@@ -342,12 +344,35 @@ export declare class AgentBridge {
|
|
|
342
344
|
*/
|
|
343
345
|
private withPromptTimeout;
|
|
344
346
|
/**
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
347
|
+
* Whether `ev` represents real agent progress and should reset the
|
|
348
|
+
* inactivity watchdog. High-frequency churn events that can stream
|
|
349
|
+
* indefinitely without progress are excluded:
|
|
350
|
+
* - `tool_execution_update` (e.g. bash stdout chunks from a grandchild)
|
|
351
|
+
* - `compaction_delta`
|
|
352
|
+
* - `token_usage` (wire-only; not a session event, excluded for clarity)
|
|
353
|
+
*
|
|
354
|
+
* Meaningful events map the task's allow-list (message_start, text/thinking
|
|
355
|
+
* deltas via message_update, message_end, agent_end, tool_execution_start,
|
|
356
|
+
* tool_execution_end → tool_result / subagent_end).
|
|
357
|
+
*/
|
|
358
|
+
private isMeaningfulPromptActivity;
|
|
359
|
+
/**
|
|
360
|
+
* Reset the inactivity watchdog. Called from `handleEvent` ONLY for
|
|
361
|
+
* meaningful progress events (see `isMeaningfulPromptActivity`), so a
|
|
362
|
+
* long-running prompt that is actively streaming text / calling tools is
|
|
363
|
+
* never killed by the inactivity timeout — but churn (e.g. bash stdout
|
|
364
|
+
* chunks) no longer keeps a stuck session alive forever.
|
|
348
365
|
*/
|
|
349
366
|
private bumpPromptActivity;
|
|
350
367
|
private clearPromptTimers;
|
|
368
|
+
/**
|
|
369
|
+
* Start the per-turn tool execution backstop. Only the first
|
|
370
|
+
* `tool_execution_start` of a turn arms it; nested tool starts reuse the
|
|
371
|
+
* running timer. Cleared on `tool_execution_end` / `agent_end` and on
|
|
372
|
+
* teardown via `clearPromptTimers`.
|
|
373
|
+
*/
|
|
374
|
+
private startToolExecutionTimer;
|
|
375
|
+
private clearToolExecutionTimer;
|
|
351
376
|
compact(options?: string | AgentBridgeCompactionOptions): Promise<CompactionResult>;
|
|
352
377
|
dispose(): void;
|
|
353
378
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAaH,OAAO,EAQL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EAEtB,MAAM,8BAA8B,CAAC;AAYtC,OAAO,EAEL,KAAK,aAAa,EACnB,MAAM,8BAA8B,CAAC;AAatC,OAAO,EACL,kBAAkB,IAAI,yBAAyB,EAEhD,MAAM,0BAA0B,CAAC;AAClC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,KAAK,EAAE,eAAe,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAG3E,UAAU,4BAA4B;IACpC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;CACpC;AAkBD,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,OAAO,yBAAyB,CAAC;AAmCpE,MAAM,WAAW,kBAAkB;IACjC,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qFAAqF;IACrF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,qFAAqF;IACrF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;OAKG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,IAAI,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACnC;;;;;OAKG;IACH,0BAA0B,EAAE,CAAC,GAAG,EAAE;QAChC,SAAS,EAAE,MAAM,CAAC;QAClB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,6EAA6E;QAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;KAC3B,KAAK,IAAI,CAAC;IACX;;;OAGG;IACH,yBAAyB,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,CAAC;IAC/B;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;IACxB;;;;OAIG;IACH,cAAc,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IAC9C;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C;;;;;OAKG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;CAC/B;
|
|
1
|
+
{"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"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"}
|
|
@@ -535,16 +535,36 @@ function toRestoredMemorySnapshot(snapshot) {
|
|
|
535
535
|
}
|
|
536
536
|
/**
|
|
537
537
|
* Inactivity-based prompt hang watchdog. Unlike a fixed wall-clock deadline,
|
|
538
|
-
* this only fires when the agent session has emitted NO
|
|
539
|
-
* tool calls, tool results, thinking, message boundaries)
|
|
540
|
-
* window. Long-running but active prompts (multi-turn tool
|
|
541
|
-
* subagents, streaming) are NOT killed — only truly stuck
|
|
538
|
+
* this only fires when the agent session has emitted NO *meaningful* progress
|
|
539
|
+
* events (text deltas, tool calls, tool results, thinking, message boundaries)
|
|
540
|
+
* for the configured window. Long-running but active prompts (multi-turn tool
|
|
541
|
+
* use, builds, subagents, streaming) are NOT killed — only truly stuck
|
|
542
|
+
* sessions are.
|
|
542
543
|
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
544
|
+
* Meaningful events are an explicit allow-list defined by
|
|
545
|
+
* `isMeaningfulPromptActivity(ev)` in `handleEvent`. High-frequency churn
|
|
546
|
+
* events — `tool_execution_update` (e.g. bash stdout chunks), `compaction_delta`,
|
|
547
|
+
* `token_usage` — do NOT reset the watchdog, because they can stream
|
|
548
|
+
* indefinitely without real agent progress (a hung grandchild writing bytes
|
|
549
|
+
* to stdout would otherwise keep the watchdog alive forever).
|
|
550
|
+
*
|
|
551
|
+
* Default 120s of total silence. Hard ceiling removed for meaningful work —
|
|
552
|
+
* working agents (including subagents) must never be killed by this watchdog.
|
|
546
553
|
*/
|
|
547
554
|
const PROMPT_INACTIVITY_TIMEOUT_MS = Number(process.env.SPECTRAL_PROMPT_INACTIVITY_MS) || 2 * 60 * 1000;
|
|
555
|
+
/**
|
|
556
|
+
* Per-turn ceiling for a single tool execution. Started on the first
|
|
557
|
+
* `tool_execution_start` of a turn and cleared on `tool_execution_end` /
|
|
558
|
+
* `agent_end`. Protects against a single tool call (e.g. a bash grandchild
|
|
559
|
+
* streaming stdout chunks via `tool_execution_update`) running forever: even
|
|
560
|
+
* though those chunks no longer reset the inactivity watchdog, this timer
|
|
561
|
+
* provides an absolute backstop per tool invocation.
|
|
562
|
+
*
|
|
563
|
+
* Default 30 minutes, overridable via `SPECTRAL_TOOL_EXECUTION_TIMEOUT_MS`.
|
|
564
|
+
* On expiry it forces a prompt reject (same abort path as the inactivity
|
|
565
|
+
* watchdog) with a descriptive message.
|
|
566
|
+
*/
|
|
567
|
+
const TOOL_EXECUTION_TIMEOUT_MS = Number(process.env.SPECTRAL_TOOL_EXECUTION_TIMEOUT_MS) || 30 * 60 * 1000;
|
|
548
568
|
export class AgentBridge {
|
|
549
569
|
session;
|
|
550
570
|
suppressNextAgentEndForGeneration;
|
|
@@ -576,6 +596,8 @@ export class AgentBridge {
|
|
|
576
596
|
lastAppliedModelId;
|
|
577
597
|
promptActivityTimer;
|
|
578
598
|
promptReject;
|
|
599
|
+
/** Per-turn tool execution backstop timer (see TOOL_EXECUTION_TIMEOUT_MS). */
|
|
600
|
+
toolExecutionTimer;
|
|
579
601
|
/** Current model's credit rates (from availableAgentModels), used for token_usage. */
|
|
580
602
|
activeCreditRates = null;
|
|
581
603
|
memoryPhase = "idle";
|
|
@@ -720,6 +742,14 @@ export class AgentBridge {
|
|
|
720
742
|
arguments: tc.arguments,
|
|
721
743
|
});
|
|
722
744
|
}
|
|
745
|
+
// Detect orphaned tool calls: CLI was killed mid-tool-call, so some
|
|
746
|
+
// tool_use blocks have no matching tool_result in the persisted events.
|
|
747
|
+
// Mark as "error" so transform-messages inserts synthetic "No result provided"
|
|
748
|
+
// + isError=true, letting the LLM retry the action after a CLI restart.
|
|
749
|
+
const orphanedToolCallIds = toolCalls
|
|
750
|
+
.filter((tc) => !toolResults.some((tr) => tr.id === tc.id))
|
|
751
|
+
.map((tc) => tc.id);
|
|
752
|
+
const hasOrphanedToolCalls = orphanedToolCallIds.length > 0;
|
|
723
753
|
sessionManager.appendMessage({
|
|
724
754
|
role: "assistant",
|
|
725
755
|
content,
|
|
@@ -741,7 +771,9 @@ export class AgentBridge {
|
|
|
741
771
|
},
|
|
742
772
|
},
|
|
743
773
|
stopReason: toolCalls.length > 0
|
|
744
|
-
?
|
|
774
|
+
? (hasOrphanedToolCalls
|
|
775
|
+
? "error"
|
|
776
|
+
: "toolUse")
|
|
745
777
|
: "stop",
|
|
746
778
|
timestamp: msg.createdAt,
|
|
747
779
|
});
|
|
@@ -768,6 +800,9 @@ export class AgentBridge {
|
|
|
768
800
|
timestamp: msg.createdAt,
|
|
769
801
|
});
|
|
770
802
|
}
|
|
803
|
+
if (hasOrphanedToolCalls) {
|
|
804
|
+
console.warn(`[AgentBridge] Rehydrated message ${msg.id} marked as error (orphaned tool calls after CLI restart: ${orphanedToolCallIds.join(", ")})`);
|
|
805
|
+
}
|
|
771
806
|
}
|
|
772
807
|
// system messages are informational only; skip for LLM context
|
|
773
808
|
}
|
|
@@ -1384,10 +1419,10 @@ export class AgentBridge {
|
|
|
1384
1419
|
* observations can stay relevant to the current task.
|
|
1385
1420
|
*/
|
|
1386
1421
|
async withPromptTimeout(prompt) {
|
|
1387
|
-
// Activity-based watchdog: reset on
|
|
1388
|
-
// `bumpPromptActivity()
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1422
|
+
// Activity-based watchdog: reset on meaningful session events only via
|
|
1423
|
+
// `bumpPromptActivity()` (churn like tool_execution_update excluded). A
|
|
1424
|
+
// separate per-tool TOOL_EXECUTION_TIMEOUT_MS backstop guards single
|
|
1425
|
+
// tool calls. Triggers on total silence, NOT on long active prompts.
|
|
1391
1426
|
const inactivityPromise = new Promise((_, reject) => {
|
|
1392
1427
|
this.promptReject = reject;
|
|
1393
1428
|
this.promptActivityTimer = setTimeout(() => {
|
|
@@ -1402,9 +1437,39 @@ export class AgentBridge {
|
|
|
1402
1437
|
}
|
|
1403
1438
|
}
|
|
1404
1439
|
/**
|
|
1405
|
-
*
|
|
1406
|
-
*
|
|
1407
|
-
*
|
|
1440
|
+
* Whether `ev` represents real agent progress and should reset the
|
|
1441
|
+
* inactivity watchdog. High-frequency churn events that can stream
|
|
1442
|
+
* indefinitely without progress are excluded:
|
|
1443
|
+
* - `tool_execution_update` (e.g. bash stdout chunks from a grandchild)
|
|
1444
|
+
* - `compaction_delta`
|
|
1445
|
+
* - `token_usage` (wire-only; not a session event, excluded for clarity)
|
|
1446
|
+
*
|
|
1447
|
+
* Meaningful events map the task's allow-list (message_start, text/thinking
|
|
1448
|
+
* deltas via message_update, message_end, agent_end, tool_execution_start,
|
|
1449
|
+
* tool_execution_end → tool_result / subagent_end).
|
|
1450
|
+
*/
|
|
1451
|
+
isMeaningfulPromptActivity(ev) {
|
|
1452
|
+
switch (ev.type) {
|
|
1453
|
+
case "message_start":
|
|
1454
|
+
case "message_update":
|
|
1455
|
+
case "message_end":
|
|
1456
|
+
case "agent_end":
|
|
1457
|
+
case "tool_execution_start":
|
|
1458
|
+
case "tool_execution_end":
|
|
1459
|
+
return true;
|
|
1460
|
+
default:
|
|
1461
|
+
// tool_execution_update, compaction_*, prompt_mutation_status,
|
|
1462
|
+
// queue_update, auto_retry_*, length_continuation, session_info_changed,
|
|
1463
|
+
// thinking_level_changed — churn / non-progress, do NOT reset watchdog.
|
|
1464
|
+
return false;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Reset the inactivity watchdog. Called from `handleEvent` ONLY for
|
|
1469
|
+
* meaningful progress events (see `isMeaningfulPromptActivity`), so a
|
|
1470
|
+
* long-running prompt that is actively streaming text / calling tools is
|
|
1471
|
+
* never killed by the inactivity timeout — but churn (e.g. bash stdout
|
|
1472
|
+
* chunks) no longer keeps a stuck session alive forever.
|
|
1408
1473
|
*/
|
|
1409
1474
|
bumpPromptActivity() {
|
|
1410
1475
|
if (!this.promptActivityTimer)
|
|
@@ -1419,8 +1484,32 @@ export class AgentBridge {
|
|
|
1419
1484
|
clearTimeout(this.promptActivityTimer);
|
|
1420
1485
|
this.promptActivityTimer = undefined;
|
|
1421
1486
|
}
|
|
1487
|
+
this.clearToolExecutionTimer();
|
|
1422
1488
|
this.promptReject = undefined;
|
|
1423
1489
|
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Start the per-turn tool execution backstop. Only the first
|
|
1492
|
+
* `tool_execution_start` of a turn arms it; nested tool starts reuse the
|
|
1493
|
+
* running timer. Cleared on `tool_execution_end` / `agent_end` and on
|
|
1494
|
+
* teardown via `clearPromptTimers`.
|
|
1495
|
+
*/
|
|
1496
|
+
startToolExecutionTimer() {
|
|
1497
|
+
if (this.toolExecutionTimer)
|
|
1498
|
+
return;
|
|
1499
|
+
this.toolExecutionTimer = setTimeout(() => {
|
|
1500
|
+
this.toolExecutionTimer = undefined;
|
|
1501
|
+
// Mirror bumpPromptActivity: reject the inactivity race so the existing
|
|
1502
|
+
// prompt() catch path runs session.abort() + emits error/agent_end.
|
|
1503
|
+
// The withPromptTimeout finally block clears all timers afterwards.
|
|
1504
|
+
this.promptReject?.(new Error(`Tool execution exceeded ${TOOL_EXECUTION_TIMEOUT_MS / 60000} minutes`));
|
|
1505
|
+
}, TOOL_EXECUTION_TIMEOUT_MS);
|
|
1506
|
+
}
|
|
1507
|
+
clearToolExecutionTimer() {
|
|
1508
|
+
if (this.toolExecutionTimer) {
|
|
1509
|
+
clearTimeout(this.toolExecutionTimer);
|
|
1510
|
+
this.toolExecutionTimer = undefined;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1424
1513
|
async compact(options) {
|
|
1425
1514
|
if (!this.session)
|
|
1426
1515
|
throw new Error("AgentBridge.start() not called");
|
|
@@ -1546,9 +1635,16 @@ export class AgentBridge {
|
|
|
1546
1635
|
handleEvent(ev) {
|
|
1547
1636
|
if (this.disposed)
|
|
1548
1637
|
return;
|
|
1549
|
-
// Reset inactivity watchdog on
|
|
1550
|
-
//
|
|
1551
|
-
|
|
1638
|
+
// Reset inactivity watchdog ONLY on meaningful agent progress events
|
|
1639
|
+
// (text/thinking deltas, message boundaries, tool start/end, agent_end).
|
|
1640
|
+
// Churn events like tool_execution_update (bash stdout chunks) and
|
|
1641
|
+
// compaction_delta are intentionally excluded so a stuck grandchild
|
|
1642
|
+
// streaming bytes cannot keep the watchdog alive forever. A separate
|
|
1643
|
+
// per-tool TOOL_EXECUTION_TIMEOUT_MS (30 min) backstop covers a single
|
|
1644
|
+
// tool call running too long.
|
|
1645
|
+
if (this.isMeaningfulPromptActivity(ev)) {
|
|
1646
|
+
this.bumpPromptActivity();
|
|
1647
|
+
}
|
|
1552
1648
|
switch (ev.type) {
|
|
1553
1649
|
case "message_start": {
|
|
1554
1650
|
// Only assistant messages get our message_start frame on the wire.
|
|
@@ -1613,6 +1709,7 @@ export class AgentBridge {
|
|
|
1613
1709
|
return;
|
|
1614
1710
|
}
|
|
1615
1711
|
case "tool_execution_start": {
|
|
1712
|
+
this.startToolExecutionTimer();
|
|
1616
1713
|
// Emit subagent_start for subagent tool invocations (Option B frontend panels).
|
|
1617
1714
|
if (ev.toolName === "subagent") {
|
|
1618
1715
|
const args = ev.args;
|
|
@@ -1699,6 +1796,7 @@ export class AgentBridge {
|
|
|
1699
1796
|
return;
|
|
1700
1797
|
}
|
|
1701
1798
|
case "tool_execution_end": {
|
|
1799
|
+
this.clearToolExecutionTimer();
|
|
1702
1800
|
// Emit subagent_end for subagent tool completions.
|
|
1703
1801
|
if (ev.toolName === "subagent") {
|
|
1704
1802
|
const result = ev.result;
|
|
@@ -1848,6 +1946,7 @@ export class AgentBridge {
|
|
|
1848
1946
|
return;
|
|
1849
1947
|
}
|
|
1850
1948
|
case "agent_end": {
|
|
1949
|
+
this.clearToolExecutionTimer();
|
|
1851
1950
|
if (this.suppressNextAgentEndForGeneration !== undefined) {
|
|
1852
1951
|
const isSameTurn = this.suppressNextAgentEndForGeneration === this.turnGeneration;
|
|
1853
1952
|
this.suppressNextAgentEndForGeneration = undefined;
|