@kushalkhemka/opensec-pi-subagents 0.20.5 → 0.20.6
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 +25 -0
- package/README.md +1 -0
- package/dist/agent-runner.d.ts +6 -2
- package/dist/agent-runner.js +41 -12
- package/dist/codemode-guard.d.ts +44 -0
- package/dist/codemode-guard.js +189 -0
- package/dist/index.js +12 -1
- package/dist/session-handoff.d.ts +1 -1
- package/dist/session-handoff.js +1 -1
- package/dist/settings.d.ts +6 -0
- package/dist/settings.js +8 -0
- package/dist/workflow/worker-source.js +13 -9
- package/package.json +1 -1
- package/src/agent-runner.ts +37 -10
- package/src/codemode-guard.ts +200 -0
- package/src/index.ts +11 -1
- package/src/session-handoff.ts +1 -1
- package/src/settings.ts +15 -0
- package/src/workflow/worker-source.ts +13 -9
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.20.6] - 2026-10-17
|
|
11
|
+
|
|
12
|
+
Restores four fixes the Bun build had in 0.19.5-bun.6 to bun.8 (and 0.19.4-bun.0) that were lost when 0.20.x was
|
|
13
|
+
cut from the Node sources. Both builds have them again.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Codemode scripts get a deadline.** Pi runs a `codemode` script with no time limit unless its first line sets
|
|
18
|
+
`timeout_ms`, so one stuck call held the agent forever. Scripts that set none now get `codemodeTimeoutSeconds`
|
|
19
|
+
(setting; default 1800, 0 = off), in the main session and in subagents; a deadline the script sets is kept.
|
|
20
|
+
- **Codemode audit trail.** Every call a codemode script makes is written to
|
|
21
|
+
`$PI_CODEMODE_AUDIT_DIR/<session>/<call id>.log` (default `<agent dir>/opensec/codemode/`): the structured output
|
|
22
|
+
the script received (bash: up to 1 MiB), the exit code and the full-output path, capped per script by
|
|
23
|
+
`PI_CODEMODE_AUDIT_MAX_BYTES` (default 16 MiB). The script's result names the file and says that its filtered
|
|
24
|
+
output is not proof that something is absent; the script's structured result is kept.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **A subagent's `bash` calls made by another tool are bounded too.** The subagent tool timeout only reached calls
|
|
29
|
+
the model made directly; calls a codemode script, `ctx.executeTool()` or a deferred tool made ran without a limit.
|
|
30
|
+
Since 0.20.3 loads codemode in every subagent, that was most of them. The subagent's tool-scope guard now sets the
|
|
31
|
+
timeout on every call path.
|
|
32
|
+
- **Workflow scripts report the right line numbers on Bun.** Bun ignores a negative `lineOffset`, so every stack
|
|
33
|
+
frame pointed one line below the line the author wrote. The wrapper now needs no offset on either runtime.
|
|
34
|
+
|
|
10
35
|
## [0.20.5] - 2026-10-16
|
|
11
36
|
|
|
12
37
|
Orchestration results the main agent can act on: failed runs are error results with what they produced, unfinished
|
package/README.md
CHANGED
|
@@ -313,6 +313,7 @@ To have them follow Pi's project trust decision instead, set `"projectTrust": "p
|
|
|
313
313
|
| `backgroundKey` (`subagents.json`) | `ctrl+b` | Key that sends running foreground `Agent` calls to the background (see [Sending a foreground agent to the background](#sending-a-foreground-agent-to-the-background)); `"off"` disables it. |
|
|
314
314
|
| Model override (`/agents → Settings`, `allowModelOverride`) | `false` | Whether a spawn's own `model` / `thinking` (Agent tool, nested tool, workflow `agent()`, scheduled jobs, RPC) may choose the subagent's model and thinking level. Off: subagents run on the parent's current model and level unless their agent file sets one, and the tools do not offer the parameters. |
|
|
315
315
|
| Tool timeout (`/agents → Settings`, `toolTimeoutSeconds`) | `1200` | Seconds one subagent tool call may run (`0` = no limit). A `bash` call is killed at the limit and the agent sees the timeout; any other tool still running a minute later is stopped and the agent is told to continue another way (up to 3 times per run). The main agent's `bash` calls get the same timeout when they set none; a timeout the main agent asks for is kept. |
|
|
316
|
+
| `codemodeTimeoutSeconds` (`subagents.json`) | `1800` | Deadline in seconds for a Pi `codemode` script whose first line sets no `timeout_ms` (`0` = none), in the main agent and in subagents; a deadline the script sets is kept. Every call a script makes is logged raw to `$PI_CODEMODE_AUDIT_DIR/<session>/<call id>.log` (default `<agent dir>/opensec/codemode/`, capped at `PI_CODEMODE_AUDIT_MAX_BYTES`, default 16 MiB), and the script's result names the file. |
|
|
316
317
|
|
|
317
318
|
## Notes
|
|
318
319
|
|
package/dist/agent-runner.d.ts
CHANGED
|
@@ -90,7 +90,8 @@ export interface ToolScopeGuard {
|
|
|
90
90
|
setScope(inScope: () => Set<string>): void;
|
|
91
91
|
}
|
|
92
92
|
/**
|
|
93
|
-
* A `tool_call` handler that blocks out-of-scope tools for every call path
|
|
93
|
+
* A `tool_call` handler that blocks out-of-scope tools for every call path, and
|
|
94
|
+
* gives in-scope `bash` calls the tool timeout (see `toolTimeoutSeconds`).
|
|
94
95
|
*
|
|
95
96
|
* Replacing `session.agent.beforeToolCall` only covers calls the model makes
|
|
96
97
|
* directly. Pi 1.0 also lets tools call tools (`ctx.executeTool()`, used by
|
|
@@ -203,7 +204,10 @@ export declare function boundBashTimeout(args: unknown, limitSeconds: number, ca
|
|
|
203
204
|
/**
|
|
204
205
|
* Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
|
|
205
206
|
*
|
|
206
|
-
* `bash` calls without a timeout, or with a longer one, get the limit
|
|
207
|
+
* `bash` calls without a timeout, or with a longer one, get the limit: through
|
|
208
|
+
* the inline `tool_call` guard when the session loaded extensions (every call
|
|
209
|
+
* path), otherwise by wrapping `session.agent.beforeToolCall` (a session without
|
|
210
|
+
* extensions loads no built-ins either, so no tool calls tools). The
|
|
207
211
|
* watchdog covers every tool: past the limit (plus slack) it aborts the run,
|
|
208
212
|
* which kills a running command's process tree, and `takeHung()` then reports
|
|
209
213
|
* the call so the caller can resume the agent with `hungToolPrompt()`. When the
|
package/dist/agent-runner.js
CHANGED
|
@@ -7,6 +7,7 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
7
7
|
import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
9
9
|
import { runInChildSessionContext } from "./child-context.js";
|
|
10
|
+
import { boundCodemodeTimeout, CODEMODE_TOOL_NAME, createCodemodeAudit, getCodemodeTimeoutSeconds } from "./codemode-guard.js";
|
|
10
11
|
import { buildParentContext, extractText } from "./context.js";
|
|
11
12
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
12
13
|
import { detectEnv } from "./env.js";
|
|
@@ -227,7 +228,8 @@ const TOOL_SCOPE_GUARD_NAME = "subagent-tool-scope";
|
|
|
227
228
|
/** The loader path pi gives that inline extension. */
|
|
228
229
|
export const TOOL_SCOPE_GUARD_PATH = `<inline:${TOOL_SCOPE_GUARD_NAME}>`;
|
|
229
230
|
/**
|
|
230
|
-
* A `tool_call` handler that blocks out-of-scope tools for every call path
|
|
231
|
+
* A `tool_call` handler that blocks out-of-scope tools for every call path, and
|
|
232
|
+
* gives in-scope `bash` calls the tool timeout (see `toolTimeoutSeconds`).
|
|
231
233
|
*
|
|
232
234
|
* Replacing `session.agent.beforeToolCall` only covers calls the model makes
|
|
233
235
|
* directly. Pi 1.0 also lets tools call tools (`ctx.executeTool()`, used by
|
|
@@ -243,11 +245,23 @@ export function createToolScopeGuard() {
|
|
|
243
245
|
name: TOOL_SCOPE_GUARD_NAME,
|
|
244
246
|
hidden: true,
|
|
245
247
|
factory: (pi) => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
const audit = createCodemodeAudit();
|
|
249
|
+
pi.on("tool_call", (event, ctx) => {
|
|
250
|
+
if (inScope && !inScope().has(event.toolName)) {
|
|
251
|
+
return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
|
|
252
|
+
}
|
|
253
|
+
// Pi executes the same input object, so this bounds the running call,
|
|
254
|
+
// including calls another tool makes (codemode, ctx.executeTool), which
|
|
255
|
+
// never pass through session.agent.beforeToolCall.
|
|
256
|
+
if (event.toolName === "bash")
|
|
257
|
+
boundBashTimeout(event.input, toolTimeoutSeconds, true);
|
|
258
|
+
if (event.toolName === CODEMODE_TOOL_NAME)
|
|
259
|
+
boundCodemodeTimeout(event.input, getCodemodeTimeoutSeconds());
|
|
260
|
+
audit.recordCall(event, ctx);
|
|
261
|
+
return undefined;
|
|
250
262
|
});
|
|
263
|
+
// Keeps the raw output of every call a codemode script makes; see codemode-guard.ts.
|
|
264
|
+
pi.on("tool_result", (event) => audit.onToolResult(event));
|
|
251
265
|
},
|
|
252
266
|
},
|
|
253
267
|
setScope(fn) {
|
|
@@ -470,10 +484,19 @@ export function boundBashTimeout(args, limitSeconds, cap) {
|
|
|
470
484
|
call.timeout = limitSeconds;
|
|
471
485
|
}
|
|
472
486
|
}
|
|
487
|
+
/**
|
|
488
|
+
* Sessions whose `bash` calls are already bounded by the inline `tool_call`
|
|
489
|
+
* guard. Those must not also get the `beforeToolCall` patch: the guard covers
|
|
490
|
+
* nested calls too, and pi's own `beforeToolCall` is what runs the guard.
|
|
491
|
+
*/
|
|
492
|
+
const toolCallBoundSessions = new WeakSet();
|
|
473
493
|
/**
|
|
474
494
|
* Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
|
|
475
495
|
*
|
|
476
|
-
* `bash` calls without a timeout, or with a longer one, get the limit
|
|
496
|
+
* `bash` calls without a timeout, or with a longer one, get the limit: through
|
|
497
|
+
* the inline `tool_call` guard when the session loaded extensions (every call
|
|
498
|
+
* path), otherwise by wrapping `session.agent.beforeToolCall` (a session without
|
|
499
|
+
* extensions loads no built-ins either, so no tool calls tools). The
|
|
477
500
|
* watchdog covers every tool: past the limit (plus slack) it aborts the run,
|
|
478
501
|
* which kills a running command's process tree, and `takeHung()` then reports
|
|
479
502
|
* the call so the caller can resume the agent with `hungToolPrompt()`. When the
|
|
@@ -485,12 +508,15 @@ export function installToolTimeouts(session, limitSeconds = toolTimeoutSeconds)
|
|
|
485
508
|
return { takeHung: () => undefined, stuck: new Promise(() => { }), dispose: () => { } };
|
|
486
509
|
let resolveStuck;
|
|
487
510
|
const stuck = new Promise(resolve => { resolveStuck = resolve; });
|
|
511
|
+
const patchBeforeToolCall = !toolCallBoundSessions.has(session);
|
|
488
512
|
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
513
|
+
if (patchBeforeToolCall) {
|
|
514
|
+
session.agent.beforeToolCall = async (context, signal) => {
|
|
515
|
+
if (context.toolCall.name === "bash")
|
|
516
|
+
boundBashTimeout(context.args, limitSeconds, true);
|
|
517
|
+
return priorBeforeToolCall?.(context, signal);
|
|
518
|
+
};
|
|
519
|
+
}
|
|
494
520
|
const running = new Map();
|
|
495
521
|
let hung;
|
|
496
522
|
let hungAt = 0;
|
|
@@ -535,7 +561,8 @@ export function installToolTimeouts(session, limitSeconds = toolTimeoutSeconds)
|
|
|
535
561
|
dispose() {
|
|
536
562
|
clearInterval(timer);
|
|
537
563
|
unsubscribe();
|
|
538
|
-
|
|
564
|
+
if (patchBeforeToolCall)
|
|
565
|
+
session.agent.beforeToolCall = priorBeforeToolCall;
|
|
539
566
|
},
|
|
540
567
|
};
|
|
541
568
|
}
|
|
@@ -1084,6 +1111,8 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
1084
1111
|
sessionOpts.thinkingLevel = thinkingLevel;
|
|
1085
1112
|
}
|
|
1086
1113
|
const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
|
|
1114
|
+
if (toolScopeGuard)
|
|
1115
|
+
toolCallBoundSessions.add(session);
|
|
1087
1116
|
const baseSessionName = agentConfig?.name ?? type;
|
|
1088
1117
|
session.setSessionName(options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName);
|
|
1089
1118
|
// Bind extensions so that session_start fires and extensions can initialize
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type ToolResultEvent, type ToolResultEventResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
export declare const CODEMODE_TOOL_NAME = "codemode";
|
|
3
|
+
export declare const CODEMODE_TIMEOUT_CEILING = 86400;
|
|
4
|
+
/** Deadline in seconds given to codemode scripts that set none (0 = leave them unbounded). */
|
|
5
|
+
export declare function getCodemodeTimeoutSeconds(): number;
|
|
6
|
+
export declare function setCodemodeTimeoutSeconds(n: number): void;
|
|
7
|
+
/**
|
|
8
|
+
* Give a codemode call a `timeout_ms` when its script sets none. Pi executes the same input
|
|
9
|
+
* object the `tool_call` handlers receive, so this changes the running script.
|
|
10
|
+
*
|
|
11
|
+
* - No options line: one is added. Line numbers in script errors then count from 2.
|
|
12
|
+
* - An options line without `timeout_ms`: the field is added to it.
|
|
13
|
+
* - An options line that sets `timeout_ms`, or that is not a JSON object: left as it is
|
|
14
|
+
* (codemode itself reports a malformed line to the model).
|
|
15
|
+
*/
|
|
16
|
+
export declare function boundCodemodeTimeout(input: unknown, seconds: number): void;
|
|
17
|
+
/** Default cap on one script's audit log; set `PI_CODEMODE_AUDIT_MAX_BYTES` to change it. */
|
|
18
|
+
export declare const DEFAULT_AUDIT_MAX_BYTES: number;
|
|
19
|
+
/** Where raw outputs of codemode script calls are kept: `PI_CODEMODE_AUDIT_DIR`, else `<agent dir>/opensec/codemode`. */
|
|
20
|
+
export declare function codemodeAuditRoot(): string;
|
|
21
|
+
interface ToolCallLike {
|
|
22
|
+
toolName: string;
|
|
23
|
+
toolCallId: string;
|
|
24
|
+
}
|
|
25
|
+
interface HandlerCtx {
|
|
26
|
+
sessionManager?: {
|
|
27
|
+
getSessionId?: () => string;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export interface CodemodeAudit {
|
|
31
|
+
/** Call from the session's (single) `tool_call` handler for every call that is allowed to run. */
|
|
32
|
+
recordCall(event: ToolCallLike, ctx?: HandlerCtx): void;
|
|
33
|
+
/** Register as a `tool_result` handler. */
|
|
34
|
+
onToolResult(event: ToolResultEvent): ToolResultEventResult | undefined;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The audit trail for one session. Every call a codemode script makes (a `tool_result` with a
|
|
38
|
+
* `parentToolCallId`) is appended to `<root>/<session>/<script call id>.log`, and the script's own
|
|
39
|
+
* result gets a line naming that file. Each script's log is capped (`PI_CODEMODE_AUDIT_MAX_BYTES`,
|
|
40
|
+
* default 16 MiB); past the cap only call headers are written. A write failure never fails a
|
|
41
|
+
* tool call; the note then says the raw outputs could not be saved.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createCodemodeAudit(): CodemodeAudit;
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Guards for pi's built-in `codemode` tool (a model-written script that calls other tools).
|
|
2
|
+
//
|
|
3
|
+
// - A deadline: codemode scripts run with no time limit unless the script's first line sets
|
|
4
|
+
// `timeout_ms` (`// @options: {"timeout_ms": …}`), so one stuck call held the agent forever.
|
|
5
|
+
// Scripts that set none get `codemodeTimeoutSeconds` (default 30 minutes).
|
|
6
|
+
// - An audit trail: only the script's own output reaches the model, so a wrong filter can make
|
|
7
|
+
// a real hit look like "0 matches" with no trace. The raw output of every call a script makes
|
|
8
|
+
// is written to disk, and the script's result says where, and that it is not proof of absence.
|
|
9
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
export const CODEMODE_TOOL_NAME = "codemode";
|
|
13
|
+
const OPTIONS_PREFIX = "// @options:";
|
|
14
|
+
/** Largest `timeout_ms` codemode accepts (the `setTimeout` limit). */
|
|
15
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
16
|
+
export const CODEMODE_TIMEOUT_CEILING = 86_400;
|
|
17
|
+
let codemodeTimeoutSeconds = 1800;
|
|
18
|
+
/** Deadline in seconds given to codemode scripts that set none (0 = leave them unbounded). */
|
|
19
|
+
export function getCodemodeTimeoutSeconds() { return codemodeTimeoutSeconds; }
|
|
20
|
+
export function setCodemodeTimeoutSeconds(n) {
|
|
21
|
+
codemodeTimeoutSeconds = Math.min(CODEMODE_TIMEOUT_CEILING, Math.max(0, Math.floor(n)));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Give a codemode call a `timeout_ms` when its script sets none. Pi executes the same input
|
|
25
|
+
* object the `tool_call` handlers receive, so this changes the running script.
|
|
26
|
+
*
|
|
27
|
+
* - No options line: one is added. Line numbers in script errors then count from 2.
|
|
28
|
+
* - An options line without `timeout_ms`: the field is added to it.
|
|
29
|
+
* - An options line that sets `timeout_ms`, or that is not a JSON object: left as it is
|
|
30
|
+
* (codemode itself reports a malformed line to the model).
|
|
31
|
+
*/
|
|
32
|
+
export function boundCodemodeTimeout(input, seconds) {
|
|
33
|
+
if (seconds <= 0 || !input || typeof input !== "object")
|
|
34
|
+
return;
|
|
35
|
+
const call = input;
|
|
36
|
+
if (typeof call.code !== "string" || call.code.trim() === "")
|
|
37
|
+
return;
|
|
38
|
+
const timeoutMs = Math.min(MAX_TIMEOUT_MS, seconds * 1000);
|
|
39
|
+
const code = call.code;
|
|
40
|
+
const newline = code.indexOf("\n");
|
|
41
|
+
const firstLine = (newline === -1 ? code : code.slice(0, newline)).replace(/\r$/, "");
|
|
42
|
+
const trimmed = firstLine.trimStart();
|
|
43
|
+
if (!trimmed.startsWith(OPTIONS_PREFIX)) {
|
|
44
|
+
call.code = `${OPTIONS_PREFIX} ${JSON.stringify({ timeout_ms: timeoutMs })}\n${code}`;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let options;
|
|
48
|
+
try {
|
|
49
|
+
options = JSON.parse(trimmed.slice(OPTIONS_PREFIX.length).trim());
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!options || typeof options !== "object" || Array.isArray(options))
|
|
55
|
+
return;
|
|
56
|
+
if (options.timeout_ms !== undefined)
|
|
57
|
+
return;
|
|
58
|
+
const rest = newline === -1 ? "" : code.slice(newline);
|
|
59
|
+
call.code = `${OPTIONS_PREFIX} ${JSON.stringify({ ...options, timeout_ms: timeoutMs })}${rest}`;
|
|
60
|
+
}
|
|
61
|
+
/** Default cap on one script's audit log; set `PI_CODEMODE_AUDIT_MAX_BYTES` to change it. */
|
|
62
|
+
export const DEFAULT_AUDIT_MAX_BYTES = 16 * 1024 * 1024;
|
|
63
|
+
function auditMaxBytes() {
|
|
64
|
+
const n = Number(process.env.PI_CODEMODE_AUDIT_MAX_BYTES);
|
|
65
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_AUDIT_MAX_BYTES;
|
|
66
|
+
}
|
|
67
|
+
/** Where raw outputs of codemode script calls are kept: `PI_CODEMODE_AUDIT_DIR`, else `<agent dir>/opensec/codemode`. */
|
|
68
|
+
export function codemodeAuditRoot() {
|
|
69
|
+
const configured = process.env.PI_CODEMODE_AUDIT_DIR?.trim();
|
|
70
|
+
return configured ? resolve(configured) : join(getAgentDir(), "opensec", "codemode");
|
|
71
|
+
}
|
|
72
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
73
|
+
/**
|
|
74
|
+
* What a script received from one call. A script gets a tool's structured result when it has one:
|
|
75
|
+
* for `bash`, `structuredContent.output` holds up to 1 MiB, where the text the model would see from
|
|
76
|
+
* a direct call stops at 50 KB / 2,000 lines. So log the structured output when there is one, and
|
|
77
|
+
* any path pi wrote the complete output to.
|
|
78
|
+
*/
|
|
79
|
+
function rawOutput(event) {
|
|
80
|
+
const meta = [];
|
|
81
|
+
const structured = event.structuredContent;
|
|
82
|
+
let body;
|
|
83
|
+
if (structured && typeof structured === "object" && !Array.isArray(structured)) {
|
|
84
|
+
const record = structured;
|
|
85
|
+
if (typeof record.output === "string") {
|
|
86
|
+
body = record.output;
|
|
87
|
+
if (record.exit_code !== undefined)
|
|
88
|
+
meta.push(`exit_code: ${String(record.exit_code)}`);
|
|
89
|
+
if (record.truncated === true)
|
|
90
|
+
meta.push("truncated: true (the script got the first and last 512 KiB)");
|
|
91
|
+
if (typeof record.full_output_path === "string")
|
|
92
|
+
meta.push(`full output: ${record.full_output_path}`);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
meta.push(`structured: ${JSON.stringify(structured)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (structured !== undefined) {
|
|
99
|
+
meta.push(`structured: ${JSON.stringify(structured)}`);
|
|
100
|
+
}
|
|
101
|
+
const details = event.details;
|
|
102
|
+
if (details && typeof details === "object") {
|
|
103
|
+
const fullOutputPath = details.fullOutputPath;
|
|
104
|
+
if (typeof fullOutputPath === "string" && !meta.some((m) => m.endsWith(fullOutputPath))) {
|
|
105
|
+
meta.push(`full output: ${fullOutputPath}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (body === undefined) {
|
|
109
|
+
body = (event.content ?? []).map((c) => (c.type === "text" ? c.text : `[${c.type}]`)).join("\n");
|
|
110
|
+
}
|
|
111
|
+
return { body, meta };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The audit trail for one session. Every call a codemode script makes (a `tool_result` with a
|
|
115
|
+
* `parentToolCallId`) is appended to `<root>/<session>/<script call id>.log`, and the script's own
|
|
116
|
+
* result gets a line naming that file. Each script's log is capped (`PI_CODEMODE_AUDIT_MAX_BYTES`,
|
|
117
|
+
* default 16 MiB); past the cap only call headers are written. A write failure never fails a
|
|
118
|
+
* tool call; the note then says the raw outputs could not be saved.
|
|
119
|
+
*/
|
|
120
|
+
export function createCodemodeAudit() {
|
|
121
|
+
const scripts = new Map();
|
|
122
|
+
const recordCall = (event, ctx) => {
|
|
123
|
+
if (event.toolName !== CODEMODE_TOOL_NAME)
|
|
124
|
+
return;
|
|
125
|
+
const session = safe(ctx?.sessionManager?.getSessionId?.() ?? "session");
|
|
126
|
+
scripts.set(event.toolCallId, {
|
|
127
|
+
file: join(codemodeAuditRoot(), session, `${safe(event.toolCallId)}.log`),
|
|
128
|
+
calls: 0,
|
|
129
|
+
bytes: 0,
|
|
130
|
+
capped: false,
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const onToolResult = (event) => {
|
|
134
|
+
if (event.parentToolCallId) {
|
|
135
|
+
const script = scripts.get(event.parentToolCallId);
|
|
136
|
+
if (!script)
|
|
137
|
+
return undefined;
|
|
138
|
+
script.calls += 1;
|
|
139
|
+
const { body, meta } = rawOutput(event);
|
|
140
|
+
const head = [
|
|
141
|
+
`===== call ${script.calls}: ${event.toolName} (${event.toolCallId})${event.isError ? " ERROR" : ""}`,
|
|
142
|
+
`input: ${JSON.stringify(event.input ?? {})}`,
|
|
143
|
+
...meta,
|
|
144
|
+
].join("\n");
|
|
145
|
+
const max = auditMaxBytes();
|
|
146
|
+
let entry;
|
|
147
|
+
if (script.capped) {
|
|
148
|
+
entry = `${head}\n[output not saved: log cap of ${max} bytes reached]\n\n`;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
entry = `${head}\n${body}\n\n`;
|
|
152
|
+
const size = Buffer.byteLength(entry);
|
|
153
|
+
if (script.bytes + size > max) {
|
|
154
|
+
const room = Math.max(0, max - script.bytes - Buffer.byteLength(head) - 200);
|
|
155
|
+
const kept = Buffer.from(body).subarray(0, room).toString();
|
|
156
|
+
entry = `${head}\n${kept}\n[log cap of ${max} bytes reached: the rest of this output and later outputs are not saved]\n\n`;
|
|
157
|
+
script.capped = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
script.bytes += Buffer.byteLength(entry);
|
|
161
|
+
try {
|
|
162
|
+
mkdirSync(join(script.file, ".."), { recursive: true, mode: 0o700 });
|
|
163
|
+
appendFileSync(script.file, entry, { mode: 0o600 });
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
script.failed ??= String(error?.code ?? error);
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
if (event.toolName !== CODEMODE_TOOL_NAME)
|
|
171
|
+
return undefined;
|
|
172
|
+
const script = scripts.get(event.toolCallId);
|
|
173
|
+
scripts.delete(event.toolCallId);
|
|
174
|
+
if (!script || script.calls === 0)
|
|
175
|
+
return undefined;
|
|
176
|
+
const where = script.failed
|
|
177
|
+
? `could not be saved (${script.failed})`
|
|
178
|
+
: `are in ${script.file}${script.capped ? " (cut off at the log size cap)" : ""}`;
|
|
179
|
+
const note = `[Raw outputs of this script's ${script.calls} tool call${script.calls === 1 ? "" : "s"} ${where}. `
|
|
180
|
+
+ "The script's printed output is filtered: it is not proof that something is absent. "
|
|
181
|
+
+ "Before concluding absence, check the raw outputs or print the pattern, scope and count.]";
|
|
182
|
+
return {
|
|
183
|
+
content: [...(event.content ?? []), { type: "text", text: note }],
|
|
184
|
+
// Replacing content alone would drop the script's structured result.
|
|
185
|
+
...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
return { recordCall, onToolResult };
|
|
189
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { AgentManager, isTopLevelAgent, managerServices, RECORD_ENTRY_TYPE, RESU
|
|
|
21
21
|
import { boundBashTimeout, getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
|
|
22
22
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
23
23
|
import { inChildSessionContext } from "./child-context.js";
|
|
24
|
+
import { boundCodemodeTimeout, CODEMODE_TOOL_NAME, createCodemodeAudit, getCodemodeTimeoutSeconds, setCodemodeTimeoutSeconds } from "./codemode-guard.js";
|
|
24
25
|
import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js";
|
|
25
26
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
26
27
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
@@ -1998,11 +1999,19 @@ export default function (pi) {
|
|
|
1998
1999
|
// so an unbounded `grep -r … /` or a server started in the foreground would
|
|
1999
2000
|
// otherwise hold the turn until someone notices. (Subagents are also capped —
|
|
2000
2001
|
// see installToolTimeouts in agent-runner.ts.)
|
|
2001
|
-
|
|
2002
|
+
const codemodeAudit = createCodemodeAudit();
|
|
2003
|
+
pi.on("tool_call", (event, ctx) => {
|
|
2002
2004
|
if (event.toolName === "bash")
|
|
2003
2005
|
boundBashTimeout(event.input, getToolTimeoutSeconds(), false);
|
|
2006
|
+
// Pi's codemode scripts have no deadline unless they set one; give those that don't
|
|
2007
|
+
// `codemodeTimeoutSeconds` (default 30 minutes). A deadline the script sets is kept.
|
|
2008
|
+
if (event.toolName === CODEMODE_TOOL_NAME)
|
|
2009
|
+
boundCodemodeTimeout(event.input, getCodemodeTimeoutSeconds());
|
|
2010
|
+
codemodeAudit.recordCall(event, ctx);
|
|
2004
2011
|
return undefined;
|
|
2005
2012
|
});
|
|
2013
|
+
// Keeps the raw output of every call a codemode script makes; see codemode-guard.ts.
|
|
2014
|
+
pi.on("tool_result", (event) => codemodeAudit.onToolResult(event));
|
|
2006
2015
|
// The parent's turn is over: spend that arrived after its last subagent tool
|
|
2007
2016
|
// result (background agents, workflows) reaches the session stats now.
|
|
2008
2017
|
pi.on("agent_end", (_event, ctx) => {
|
|
@@ -2061,6 +2070,7 @@ export default function (pi) {
|
|
|
2061
2070
|
setDefaultMaxTurns,
|
|
2062
2071
|
setGraceTurns,
|
|
2063
2072
|
setToolTimeoutSeconds,
|
|
2073
|
+
setCodemodeTimeoutSeconds,
|
|
2064
2074
|
setDefaultJoinMode,
|
|
2065
2075
|
setBackgroundByDefault,
|
|
2066
2076
|
setSchedulingEnabled,
|
|
@@ -4237,6 +4247,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4237
4247
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
4238
4248
|
graceTurns: getGraceTurns(),
|
|
4239
4249
|
toolTimeoutSeconds: getToolTimeoutSeconds(),
|
|
4250
|
+
codemodeTimeoutSeconds: getCodemodeTimeoutSeconds(),
|
|
4240
4251
|
defaultJoinMode: getDefaultJoinMode(),
|
|
4241
4252
|
backgroundByDefault: getBackgroundByDefault(),
|
|
4242
4253
|
schedulingEnabled: isSchedulingEnabled(),
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
37
37
|
/** The package version this code ships in. `test/session-handoff.test.ts` keeps it equal to package.json. */
|
|
38
|
-
export declare const PACKAGE_VERSION = "0.20.
|
|
38
|
+
export declare const PACKAGE_VERSION = "0.20.6";
|
|
39
39
|
/** Bumped when the shape of a parked entry changes within one version (never in a release). */
|
|
40
40
|
export declare const HANDOFF_PROTOCOL = 1;
|
|
41
41
|
/** Writes an entry into the session `owner` if the caller can (it is the active one there); false otherwise. */
|
package/dist/session-handoff.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
import { existsSync } from "node:fs";
|
|
37
37
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
38
38
|
/** The package version this code ships in. `test/session-handoff.test.ts` keeps it equal to package.json. */
|
|
39
|
-
export const PACKAGE_VERSION = "0.20.
|
|
39
|
+
export const PACKAGE_VERSION = "0.20.6";
|
|
40
40
|
/** Bumped when the shape of a parked entry changes within one version (never in a release). */
|
|
41
41
|
export const HANDOFF_PROTOCOL = 1;
|
|
42
42
|
const HANDOFF_KEY = Symbol.for("pi-subagents:handoff:@kushalkhemka/opensec-pi-subagents");
|
package/dist/settings.d.ts
CHANGED
|
@@ -36,6 +36,11 @@ export interface SubagentsSettings {
|
|
|
36
36
|
* default 1200). See `toolTimeoutSeconds` in agent-runner.ts.
|
|
37
37
|
*/
|
|
38
38
|
toolTimeoutSeconds?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Deadline in seconds for pi's `codemode` scripts that set no `timeout_ms`
|
|
41
|
+
* (0 = leave them unbounded, default 1800). See codemode-guard.ts.
|
|
42
|
+
*/
|
|
43
|
+
codemodeTimeoutSeconds?: number;
|
|
39
44
|
defaultJoinMode?: JoinMode;
|
|
40
45
|
/**
|
|
41
46
|
* Whether a top-level `Agent` spawn that doesn't say runs detached.
|
|
@@ -330,6 +335,7 @@ export interface SettingsAppliers {
|
|
|
330
335
|
setDefaultMaxTurns: (n: number) => void;
|
|
331
336
|
setGraceTurns: (n: number) => void;
|
|
332
337
|
setToolTimeoutSeconds: (n: number) => void;
|
|
338
|
+
setCodemodeTimeoutSeconds: (n: number) => void;
|
|
333
339
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
334
340
|
setBackgroundByDefault: (b: boolean) => void;
|
|
335
341
|
setSchedulingEnabled: (b: boolean) => void;
|
package/dist/settings.js
CHANGED
|
@@ -17,6 +17,7 @@ const MAX_CONCURRENT_CEILING = 1024;
|
|
|
17
17
|
const MAX_TURNS_CEILING = 10_000;
|
|
18
18
|
const GRACE_TURNS_CEILING = 1_000;
|
|
19
19
|
const TOOL_TIMEOUT_CEILING = 86_400;
|
|
20
|
+
const CODEMODE_TIMEOUT_CEILING = 86_400;
|
|
20
21
|
const SUBAGENT_DEPTH_CEILING = 16;
|
|
21
22
|
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
|
|
22
23
|
function sanitize(raw) {
|
|
@@ -51,6 +52,11 @@ function sanitize(raw) {
|
|
|
51
52
|
r.toolTimeoutSeconds <= TOOL_TIMEOUT_CEILING) {
|
|
52
53
|
out.toolTimeoutSeconds = r.toolTimeoutSeconds;
|
|
53
54
|
}
|
|
55
|
+
if (Number.isInteger(r.codemodeTimeoutSeconds) &&
|
|
56
|
+
r.codemodeTimeoutSeconds >= 0 &&
|
|
57
|
+
r.codemodeTimeoutSeconds <= CODEMODE_TIMEOUT_CEILING) {
|
|
58
|
+
out.codemodeTimeoutSeconds = r.codemodeTimeoutSeconds;
|
|
59
|
+
}
|
|
54
60
|
if (Number.isInteger(r.maxSubagentDepth) &&
|
|
55
61
|
r.maxSubagentDepth >= 0 &&
|
|
56
62
|
r.maxSubagentDepth <= SUBAGENT_DEPTH_CEILING) {
|
|
@@ -200,6 +206,8 @@ export function applySettings(s, appliers) {
|
|
|
200
206
|
appliers.setGraceTurns(s.graceTurns);
|
|
201
207
|
if (typeof s.toolTimeoutSeconds === "number")
|
|
202
208
|
appliers.setToolTimeoutSeconds(s.toolTimeoutSeconds);
|
|
209
|
+
if (typeof s.codemodeTimeoutSeconds === "number")
|
|
210
|
+
appliers.setCodemodeTimeoutSeconds(s.codemodeTimeoutSeconds);
|
|
203
211
|
if (typeof s.maxSubagentDepth === "number")
|
|
204
212
|
appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
205
213
|
if (typeof s.fallbackSubagent === "string")
|
|
@@ -51,9 +51,14 @@
|
|
|
51
51
|
/**
|
|
52
52
|
* Runs inside the realm, ahead of the script body, on a single line.
|
|
53
53
|
*
|
|
54
|
-
* One line matters: the body
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* One line matters: the body starts on the same line as the wrapper and this
|
|
55
|
+
* prelude, so the script's line N is the compiled source's line N and reported
|
|
56
|
+
* line numbers match the file the author wrote. Any newline in here shifts every
|
|
57
|
+
* stack frame in every workflow script.
|
|
58
|
+
*
|
|
59
|
+
* Same line rather than `\n` + `lineOffset: -1`: Bun's `vm.Script` honours a
|
|
60
|
+
* positive `lineOffset` but ignores a negative one, which put every frame one
|
|
61
|
+
* line below the line the author wrote.
|
|
57
62
|
*/
|
|
58
63
|
const DETERMINISM_PRELUDE = "const Date = (function () {" +
|
|
59
64
|
" const RealDate = globalThis.Date;" +
|
|
@@ -729,8 +734,8 @@ async function workflowIn(scope, nameOrRef, args) {
|
|
|
729
734
|
// \`meta\` is deliberately not a parameter: the body still opens with its
|
|
730
735
|
// own \`const meta = { ... }\` (extractMeta strips only the \`export\`), so a
|
|
731
736
|
// parameter of that name would collide with it.
|
|
732
|
-
"(async (agent, phase, log, workflow, console, args) => {" + PRELUDE +
|
|
733
|
-
{ filename: "workflow:" + loaded.name + ".js"
|
|
737
|
+
"(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + loaded.body + "\\n})",
|
|
738
|
+
{ filename: "workflow:" + loaded.name + ".js" }
|
|
734
739
|
);
|
|
735
740
|
run = compiled.runInContext(realmContext);
|
|
736
741
|
} catch (error) {
|
|
@@ -802,11 +807,10 @@ async function main() {
|
|
|
802
807
|
sandbox.meta = realmParse(workerData.metaJson);
|
|
803
808
|
sandbox.args = workerData.argsJson === undefined ? undefined : realmParse(workerData.argsJson);
|
|
804
809
|
|
|
805
|
-
|
|
810
|
+
// The wrapper and prelude share the body's first line (see PRELUDE), so a
|
|
811
|
+
// thrown error points at the line the author wrote with no lineOffset.
|
|
812
|
+
const script = new vm.Script("(async () => {" + PRELUDE + workerData.body + "\\n})()", {
|
|
806
813
|
filename: "workflow.js",
|
|
807
|
-
// The wrapper adds exactly one line above the body; undo it so a thrown
|
|
808
|
-
// error points at the line the author wrote.
|
|
809
|
-
lineOffset: -1,
|
|
810
814
|
});
|
|
811
815
|
|
|
812
816
|
const value = await script.runInContext(context);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kushalkhemka/opensec-pi-subagents",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.6",
|
|
4
4
|
"description": "OpenSec Subagents for Pi: a fork of @tintinweb/pi-subagents with parent model and thinking-level inheritance and cached agent discovery.",
|
|
5
5
|
"author": "OpenSec",
|
|
6
6
|
"contributors": [
|
package/src/agent-runner.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
25
25
|
import { runInChildSessionContext } from "./child-context.js";
|
|
26
|
+
import { boundCodemodeTimeout, CODEMODE_TOOL_NAME, createCodemodeAudit, getCodemodeTimeoutSeconds } from "./codemode-guard.js";
|
|
26
27
|
import { buildParentContext, extractText } from "./context.js";
|
|
27
28
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
28
29
|
import { detectEnv } from "./env.js";
|
|
@@ -263,7 +264,8 @@ export interface ToolScopeGuard {
|
|
|
263
264
|
}
|
|
264
265
|
|
|
265
266
|
/**
|
|
266
|
-
* A `tool_call` handler that blocks out-of-scope tools for every call path
|
|
267
|
+
* A `tool_call` handler that blocks out-of-scope tools for every call path, and
|
|
268
|
+
* gives in-scope `bash` calls the tool timeout (see `toolTimeoutSeconds`).
|
|
267
269
|
*
|
|
268
270
|
* Replacing `session.agent.beforeToolCall` only covers calls the model makes
|
|
269
271
|
* directly. Pi 1.0 also lets tools call tools (`ctx.executeTool()`, used by
|
|
@@ -279,10 +281,21 @@ export function createToolScopeGuard(): ToolScopeGuard {
|
|
|
279
281
|
name: TOOL_SCOPE_GUARD_NAME,
|
|
280
282
|
hidden: true,
|
|
281
283
|
factory: (pi) => {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
284
|
+
const audit = createCodemodeAudit();
|
|
285
|
+
pi.on("tool_call", (event, ctx) => {
|
|
286
|
+
if (inScope && !inScope().has(event.toolName)) {
|
|
287
|
+
return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
|
|
288
|
+
}
|
|
289
|
+
// Pi executes the same input object, so this bounds the running call,
|
|
290
|
+
// including calls another tool makes (codemode, ctx.executeTool), which
|
|
291
|
+
// never pass through session.agent.beforeToolCall.
|
|
292
|
+
if (event.toolName === "bash") boundBashTimeout(event.input, toolTimeoutSeconds, true);
|
|
293
|
+
if (event.toolName === CODEMODE_TOOL_NAME) boundCodemodeTimeout(event.input, getCodemodeTimeoutSeconds());
|
|
294
|
+
audit.recordCall(event, ctx);
|
|
295
|
+
return undefined;
|
|
285
296
|
});
|
|
297
|
+
// Keeps the raw output of every call a codemode script makes; see codemode-guard.ts.
|
|
298
|
+
pi.on("tool_result", (event) => audit.onToolResult(event));
|
|
286
299
|
},
|
|
287
300
|
},
|
|
288
301
|
setScope(fn) {
|
|
@@ -540,10 +553,20 @@ export function boundBashTimeout(args: unknown, limitSeconds: number, cap: boole
|
|
|
540
553
|
}
|
|
541
554
|
}
|
|
542
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Sessions whose `bash` calls are already bounded by the inline `tool_call`
|
|
558
|
+
* guard. Those must not also get the `beforeToolCall` patch: the guard covers
|
|
559
|
+
* nested calls too, and pi's own `beforeToolCall` is what runs the guard.
|
|
560
|
+
*/
|
|
561
|
+
const toolCallBoundSessions = new WeakSet<object>();
|
|
562
|
+
|
|
543
563
|
/**
|
|
544
564
|
* Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
|
|
545
565
|
*
|
|
546
|
-
* `bash` calls without a timeout, or with a longer one, get the limit
|
|
566
|
+
* `bash` calls without a timeout, or with a longer one, get the limit: through
|
|
567
|
+
* the inline `tool_call` guard when the session loaded extensions (every call
|
|
568
|
+
* path), otherwise by wrapping `session.agent.beforeToolCall` (a session without
|
|
569
|
+
* extensions loads no built-ins either, so no tool calls tools). The
|
|
547
570
|
* watchdog covers every tool: past the limit (plus slack) it aborts the run,
|
|
548
571
|
* which kills a running command's process tree, and `takeHung()` then reports
|
|
549
572
|
* the call so the caller can resume the agent with `hungToolPrompt()`. When the
|
|
@@ -560,11 +583,14 @@ export function installToolTimeouts(session: AgentSession, limitSeconds = toolTi
|
|
|
560
583
|
let resolveStuck!: () => void;
|
|
561
584
|
const stuck = new Promise<void>(resolve => { resolveStuck = resolve; });
|
|
562
585
|
|
|
586
|
+
const patchBeforeToolCall = !toolCallBoundSessions.has(session);
|
|
563
587
|
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
588
|
+
if (patchBeforeToolCall) {
|
|
589
|
+
session.agent.beforeToolCall = async (context, signal) => {
|
|
590
|
+
if (context.toolCall.name === "bash") boundBashTimeout(context.args, limitSeconds, true);
|
|
591
|
+
return priorBeforeToolCall?.(context, signal);
|
|
592
|
+
};
|
|
593
|
+
}
|
|
568
594
|
|
|
569
595
|
const running = new Map<string, { toolName: string; started: number }>();
|
|
570
596
|
let hung: HungTool | undefined;
|
|
@@ -608,7 +634,7 @@ export function installToolTimeouts(session: AgentSession, limitSeconds = toolTi
|
|
|
608
634
|
dispose() {
|
|
609
635
|
clearInterval(timer);
|
|
610
636
|
unsubscribe();
|
|
611
|
-
session.agent.beforeToolCall = priorBeforeToolCall;
|
|
637
|
+
if (patchBeforeToolCall) session.agent.beforeToolCall = priorBeforeToolCall;
|
|
612
638
|
},
|
|
613
639
|
};
|
|
614
640
|
}
|
|
@@ -1342,6 +1368,7 @@ export async function runAgent(
|
|
|
1342
1368
|
}
|
|
1343
1369
|
|
|
1344
1370
|
const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
|
|
1371
|
+
if (toolScopeGuard) toolCallBoundSessions.add(session);
|
|
1345
1372
|
|
|
1346
1373
|
const baseSessionName = agentConfig?.name ?? type;
|
|
1347
1374
|
session.setSessionName(
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Guards for pi's built-in `codemode` tool (a model-written script that calls other tools).
|
|
2
|
+
//
|
|
3
|
+
// - A deadline: codemode scripts run with no time limit unless the script's first line sets
|
|
4
|
+
// `timeout_ms` (`// @options: {"timeout_ms": …}`), so one stuck call held the agent forever.
|
|
5
|
+
// Scripts that set none get `codemodeTimeoutSeconds` (default 30 minutes).
|
|
6
|
+
// - An audit trail: only the script's own output reaches the model, so a wrong filter can make
|
|
7
|
+
// a real hit look like "0 matches" with no trace. The raw output of every call a script makes
|
|
8
|
+
// is written to disk, and the script's result says where, and that it is not proof of absence.
|
|
9
|
+
|
|
10
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import { getAgentDir, type ToolResultEvent, type ToolResultEventResult } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
export const CODEMODE_TOOL_NAME = "codemode";
|
|
15
|
+
const OPTIONS_PREFIX = "// @options:";
|
|
16
|
+
/** Largest `timeout_ms` codemode accepts (the `setTimeout` limit). */
|
|
17
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
18
|
+
export const CODEMODE_TIMEOUT_CEILING = 86_400;
|
|
19
|
+
|
|
20
|
+
let codemodeTimeoutSeconds = 1800;
|
|
21
|
+
|
|
22
|
+
/** Deadline in seconds given to codemode scripts that set none (0 = leave them unbounded). */
|
|
23
|
+
export function getCodemodeTimeoutSeconds(): number { return codemodeTimeoutSeconds; }
|
|
24
|
+
export function setCodemodeTimeoutSeconds(n: number): void {
|
|
25
|
+
codemodeTimeoutSeconds = Math.min(CODEMODE_TIMEOUT_CEILING, Math.max(0, Math.floor(n)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Give a codemode call a `timeout_ms` when its script sets none. Pi executes the same input
|
|
30
|
+
* object the `tool_call` handlers receive, so this changes the running script.
|
|
31
|
+
*
|
|
32
|
+
* - No options line: one is added. Line numbers in script errors then count from 2.
|
|
33
|
+
* - An options line without `timeout_ms`: the field is added to it.
|
|
34
|
+
* - An options line that sets `timeout_ms`, or that is not a JSON object: left as it is
|
|
35
|
+
* (codemode itself reports a malformed line to the model).
|
|
36
|
+
*/
|
|
37
|
+
export function boundCodemodeTimeout(input: unknown, seconds: number): void {
|
|
38
|
+
if (seconds <= 0 || !input || typeof input !== "object") return;
|
|
39
|
+
const call = input as { code?: unknown };
|
|
40
|
+
if (typeof call.code !== "string" || call.code.trim() === "") return;
|
|
41
|
+
const timeoutMs = Math.min(MAX_TIMEOUT_MS, seconds * 1000);
|
|
42
|
+
const code = call.code;
|
|
43
|
+
const newline = code.indexOf("\n");
|
|
44
|
+
const firstLine = (newline === -1 ? code : code.slice(0, newline)).replace(/\r$/, "");
|
|
45
|
+
const trimmed = firstLine.trimStart();
|
|
46
|
+
if (!trimmed.startsWith(OPTIONS_PREFIX)) {
|
|
47
|
+
call.code = `${OPTIONS_PREFIX} ${JSON.stringify({ timeout_ms: timeoutMs })}\n${code}`;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
let options: unknown;
|
|
51
|
+
try {
|
|
52
|
+
options = JSON.parse(trimmed.slice(OPTIONS_PREFIX.length).trim());
|
|
53
|
+
} catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) return;
|
|
57
|
+
if ((options as Record<string, unknown>).timeout_ms !== undefined) return;
|
|
58
|
+
const rest = newline === -1 ? "" : code.slice(newline);
|
|
59
|
+
call.code = `${OPTIONS_PREFIX} ${JSON.stringify({ ...(options as object), timeout_ms: timeoutMs })}${rest}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Default cap on one script's audit log; set `PI_CODEMODE_AUDIT_MAX_BYTES` to change it. */
|
|
63
|
+
export const DEFAULT_AUDIT_MAX_BYTES = 16 * 1024 * 1024;
|
|
64
|
+
|
|
65
|
+
function auditMaxBytes(): number {
|
|
66
|
+
const n = Number(process.env.PI_CODEMODE_AUDIT_MAX_BYTES);
|
|
67
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_AUDIT_MAX_BYTES;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where raw outputs of codemode script calls are kept: `PI_CODEMODE_AUDIT_DIR`, else `<agent dir>/opensec/codemode`. */
|
|
71
|
+
export function codemodeAuditRoot(): string {
|
|
72
|
+
const configured = process.env.PI_CODEMODE_AUDIT_DIR?.trim();
|
|
73
|
+
return configured ? resolve(configured) : join(getAgentDir(), "opensec", "codemode");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const safe = (s: string) => s.replace(/[^A-Za-z0-9._-]/g, "-");
|
|
77
|
+
|
|
78
|
+
interface ToolCallLike {
|
|
79
|
+
toolName: string;
|
|
80
|
+
toolCallId: string;
|
|
81
|
+
}
|
|
82
|
+
interface HandlerCtx { sessionManager?: { getSessionId?: () => string } }
|
|
83
|
+
|
|
84
|
+
export interface CodemodeAudit {
|
|
85
|
+
/** Call from the session's (single) `tool_call` handler for every call that is allowed to run. */
|
|
86
|
+
recordCall(event: ToolCallLike, ctx?: HandlerCtx): void;
|
|
87
|
+
/** Register as a `tool_result` handler. */
|
|
88
|
+
onToolResult(event: ToolResultEvent): ToolResultEventResult | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a script received from one call. A script gets a tool's structured result when it has one:
|
|
93
|
+
* for `bash`, `structuredContent.output` holds up to 1 MiB, where the text the model would see from
|
|
94
|
+
* a direct call stops at 50 KB / 2,000 lines. So log the structured output when there is one, and
|
|
95
|
+
* any path pi wrote the complete output to.
|
|
96
|
+
*/
|
|
97
|
+
function rawOutput(event: ToolResultEvent): { body: string; meta: string[] } {
|
|
98
|
+
const meta: string[] = [];
|
|
99
|
+
const structured = event.structuredContent;
|
|
100
|
+
let body: string | undefined;
|
|
101
|
+
if (structured && typeof structured === "object" && !Array.isArray(structured)) {
|
|
102
|
+
const record = structured as Record<string, unknown>;
|
|
103
|
+
if (typeof record.output === "string") {
|
|
104
|
+
body = record.output;
|
|
105
|
+
if (record.exit_code !== undefined) meta.push(`exit_code: ${String(record.exit_code)}`);
|
|
106
|
+
if (record.truncated === true) meta.push("truncated: true (the script got the first and last 512 KiB)");
|
|
107
|
+
if (typeof record.full_output_path === "string") meta.push(`full output: ${record.full_output_path}`);
|
|
108
|
+
} else {
|
|
109
|
+
meta.push(`structured: ${JSON.stringify(structured)}`);
|
|
110
|
+
}
|
|
111
|
+
} else if (structured !== undefined) {
|
|
112
|
+
meta.push(`structured: ${JSON.stringify(structured)}`);
|
|
113
|
+
}
|
|
114
|
+
const details = (event as { details?: unknown }).details;
|
|
115
|
+
if (details && typeof details === "object") {
|
|
116
|
+
const fullOutputPath = (details as { fullOutputPath?: unknown }).fullOutputPath;
|
|
117
|
+
if (typeof fullOutputPath === "string" && !meta.some((m) => m.endsWith(fullOutputPath))) {
|
|
118
|
+
meta.push(`full output: ${fullOutputPath}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (body === undefined) {
|
|
122
|
+
body = (event.content ?? []).map((c) => (c.type === "text" ? c.text : `[${c.type}]`)).join("\n");
|
|
123
|
+
}
|
|
124
|
+
return { body, meta };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The audit trail for one session. Every call a codemode script makes (a `tool_result` with a
|
|
129
|
+
* `parentToolCallId`) is appended to `<root>/<session>/<script call id>.log`, and the script's own
|
|
130
|
+
* result gets a line naming that file. Each script's log is capped (`PI_CODEMODE_AUDIT_MAX_BYTES`,
|
|
131
|
+
* default 16 MiB); past the cap only call headers are written. A write failure never fails a
|
|
132
|
+
* tool call; the note then says the raw outputs could not be saved.
|
|
133
|
+
*/
|
|
134
|
+
export function createCodemodeAudit(): CodemodeAudit {
|
|
135
|
+
const scripts = new Map<string, { file: string; calls: number; bytes: number; capped: boolean; failed?: string }>();
|
|
136
|
+
|
|
137
|
+
const recordCall = (event: ToolCallLike, ctx?: HandlerCtx): void => {
|
|
138
|
+
if (event.toolName !== CODEMODE_TOOL_NAME) return;
|
|
139
|
+
const session = safe(ctx?.sessionManager?.getSessionId?.() ?? "session");
|
|
140
|
+
scripts.set(event.toolCallId, {
|
|
141
|
+
file: join(codemodeAuditRoot(), session, `${safe(event.toolCallId)}.log`),
|
|
142
|
+
calls: 0,
|
|
143
|
+
bytes: 0,
|
|
144
|
+
capped: false,
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onToolResult = (event: ToolResultEvent): ToolResultEventResult | undefined => {
|
|
149
|
+
if (event.parentToolCallId) {
|
|
150
|
+
const script = scripts.get(event.parentToolCallId);
|
|
151
|
+
if (!script) return undefined;
|
|
152
|
+
script.calls += 1;
|
|
153
|
+
const { body, meta } = rawOutput(event);
|
|
154
|
+
const head = [
|
|
155
|
+
`===== call ${script.calls}: ${event.toolName} (${event.toolCallId})${event.isError ? " ERROR" : ""}`,
|
|
156
|
+
`input: ${JSON.stringify(event.input ?? {})}`,
|
|
157
|
+
...meta,
|
|
158
|
+
].join("\n");
|
|
159
|
+
const max = auditMaxBytes();
|
|
160
|
+
let entry: string;
|
|
161
|
+
if (script.capped) {
|
|
162
|
+
entry = `${head}\n[output not saved: log cap of ${max} bytes reached]\n\n`;
|
|
163
|
+
} else {
|
|
164
|
+
entry = `${head}\n${body}\n\n`;
|
|
165
|
+
const size = Buffer.byteLength(entry);
|
|
166
|
+
if (script.bytes + size > max) {
|
|
167
|
+
const room = Math.max(0, max - script.bytes - Buffer.byteLength(head) - 200);
|
|
168
|
+
const kept = Buffer.from(body).subarray(0, room).toString();
|
|
169
|
+
entry = `${head}\n${kept}\n[log cap of ${max} bytes reached: the rest of this output and later outputs are not saved]\n\n`;
|
|
170
|
+
script.capped = true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
script.bytes += Buffer.byteLength(entry);
|
|
174
|
+
try {
|
|
175
|
+
mkdirSync(join(script.file, ".."), { recursive: true, mode: 0o700 });
|
|
176
|
+
appendFileSync(script.file, entry, { mode: 0o600 });
|
|
177
|
+
} catch (error) {
|
|
178
|
+
script.failed ??= String((error as { code?: string })?.code ?? error);
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
if (event.toolName !== CODEMODE_TOOL_NAME) return undefined;
|
|
183
|
+
const script = scripts.get(event.toolCallId);
|
|
184
|
+
scripts.delete(event.toolCallId);
|
|
185
|
+
if (!script || script.calls === 0) return undefined;
|
|
186
|
+
const where = script.failed
|
|
187
|
+
? `could not be saved (${script.failed})`
|
|
188
|
+
: `are in ${script.file}${script.capped ? " (cut off at the log size cap)" : ""}`;
|
|
189
|
+
const note = `[Raw outputs of this script's ${script.calls} tool call${script.calls === 1 ? "" : "s"} ${where}. `
|
|
190
|
+
+ "The script's printed output is filtered: it is not proof that something is absent. "
|
|
191
|
+
+ "Before concluding absence, check the raw outputs or print the pattern, scope and count.]";
|
|
192
|
+
return {
|
|
193
|
+
content: [...(event.content ?? []), { type: "text", text: note }],
|
|
194
|
+
// Replacing content alone would drop the script's structured result.
|
|
195
|
+
...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return { recordCall, onToolResult };
|
|
200
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { AgentManager, type CompactionInfo, isTopLevelAgent, type ManagerObserve
|
|
|
22
22
|
import { boundBashTimeout, getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
|
|
23
23
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
|
24
24
|
import { inChildSessionContext } from "./child-context.js";
|
|
25
|
+
import { boundCodemodeTimeout, CODEMODE_TOOL_NAME, createCodemodeAudit, getCodemodeTimeoutSeconds, setCodemodeTimeoutSeconds } from "./codemode-guard.js";
|
|
25
26
|
import { CompletionOutbox, NOTIFICATION_HOLD_MS } from "./completion-outbox.js";
|
|
26
27
|
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
27
28
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
@@ -2098,10 +2099,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
2098
2099
|
// so an unbounded `grep -r … /` or a server started in the foreground would
|
|
2099
2100
|
// otherwise hold the turn until someone notices. (Subagents are also capped —
|
|
2100
2101
|
// see installToolTimeouts in agent-runner.ts.)
|
|
2101
|
-
|
|
2102
|
+
const codemodeAudit = createCodemodeAudit();
|
|
2103
|
+
pi.on("tool_call", (event, ctx) => {
|
|
2102
2104
|
if (event.toolName === "bash") boundBashTimeout(event.input, getToolTimeoutSeconds(), false);
|
|
2105
|
+
// Pi's codemode scripts have no deadline unless they set one; give those that don't
|
|
2106
|
+
// `codemodeTimeoutSeconds` (default 30 minutes). A deadline the script sets is kept.
|
|
2107
|
+
if (event.toolName === CODEMODE_TOOL_NAME) boundCodemodeTimeout(event.input, getCodemodeTimeoutSeconds());
|
|
2108
|
+
codemodeAudit.recordCall(event, ctx);
|
|
2103
2109
|
return undefined;
|
|
2104
2110
|
});
|
|
2111
|
+
// Keeps the raw output of every call a codemode script makes; see codemode-guard.ts.
|
|
2112
|
+
pi.on("tool_result", (event) => codemodeAudit.onToolResult(event));
|
|
2105
2113
|
|
|
2106
2114
|
// The parent's turn is over: spend that arrived after its last subagent tool
|
|
2107
2115
|
// result (background agents, workflows) reaches the session stats now.
|
|
@@ -2172,6 +2180,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2172
2180
|
setDefaultMaxTurns,
|
|
2173
2181
|
setGraceTurns,
|
|
2174
2182
|
setToolTimeoutSeconds,
|
|
2183
|
+
setCodemodeTimeoutSeconds,
|
|
2175
2184
|
setDefaultJoinMode,
|
|
2176
2185
|
setBackgroundByDefault,
|
|
2177
2186
|
setSchedulingEnabled,
|
|
@@ -4500,6 +4509,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
4500
4509
|
defaultMaxTurns: getDefaultMaxTurns() ?? 0,
|
|
4501
4510
|
graceTurns: getGraceTurns(),
|
|
4502
4511
|
toolTimeoutSeconds: getToolTimeoutSeconds(),
|
|
4512
|
+
codemodeTimeoutSeconds: getCodemodeTimeoutSeconds(),
|
|
4503
4513
|
defaultJoinMode: getDefaultJoinMode(),
|
|
4504
4514
|
backgroundByDefault: getBackgroundByDefault(),
|
|
4505
4515
|
schedulingEnabled: isSchedulingEnabled(),
|
package/src/session-handoff.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { existsSync } from "node:fs";
|
|
|
38
38
|
import { type ExtensionContext, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
39
39
|
|
|
40
40
|
/** The package version this code ships in. `test/session-handoff.test.ts` keeps it equal to package.json. */
|
|
41
|
-
export const PACKAGE_VERSION = "0.20.
|
|
41
|
+
export const PACKAGE_VERSION = "0.20.6";
|
|
42
42
|
|
|
43
43
|
/** Bumped when the shape of a parked entry changes within one version (never in a release). */
|
|
44
44
|
export const HANDOFF_PROTOCOL = 1;
|
package/src/settings.ts
CHANGED
|
@@ -47,6 +47,11 @@ export interface SubagentsSettings {
|
|
|
47
47
|
* default 1200). See `toolTimeoutSeconds` in agent-runner.ts.
|
|
48
48
|
*/
|
|
49
49
|
toolTimeoutSeconds?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Deadline in seconds for pi's `codemode` scripts that set no `timeout_ms`
|
|
52
|
+
* (0 = leave them unbounded, default 1800). See codemode-guard.ts.
|
|
53
|
+
*/
|
|
54
|
+
codemodeTimeoutSeconds?: number;
|
|
50
55
|
defaultJoinMode?: JoinMode;
|
|
51
56
|
/**
|
|
52
57
|
* Whether a top-level `Agent` spawn that doesn't say runs detached.
|
|
@@ -344,6 +349,7 @@ export interface SettingsAppliers {
|
|
|
344
349
|
setDefaultMaxTurns: (n: number) => void;
|
|
345
350
|
setGraceTurns: (n: number) => void;
|
|
346
351
|
setToolTimeoutSeconds: (n: number) => void;
|
|
352
|
+
setCodemodeTimeoutSeconds: (n: number) => void;
|
|
347
353
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
348
354
|
setBackgroundByDefault: (b: boolean) => void;
|
|
349
355
|
setSchedulingEnabled: (b: boolean) => void;
|
|
@@ -382,6 +388,7 @@ const MAX_CONCURRENT_CEILING = 1024;
|
|
|
382
388
|
const MAX_TURNS_CEILING = 10_000;
|
|
383
389
|
const GRACE_TURNS_CEILING = 1_000;
|
|
384
390
|
const TOOL_TIMEOUT_CEILING = 86_400;
|
|
391
|
+
const CODEMODE_TIMEOUT_CEILING = 86_400;
|
|
385
392
|
const SUBAGENT_DEPTH_CEILING = 16;
|
|
386
393
|
|
|
387
394
|
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
|
|
@@ -426,6 +433,13 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
426
433
|
) {
|
|
427
434
|
out.toolTimeoutSeconds = r.toolTimeoutSeconds as number;
|
|
428
435
|
}
|
|
436
|
+
if (
|
|
437
|
+
Number.isInteger(r.codemodeTimeoutSeconds) &&
|
|
438
|
+
(r.codemodeTimeoutSeconds as number) >= 0 &&
|
|
439
|
+
(r.codemodeTimeoutSeconds as number) <= CODEMODE_TIMEOUT_CEILING
|
|
440
|
+
) {
|
|
441
|
+
out.codemodeTimeoutSeconds = r.codemodeTimeoutSeconds as number;
|
|
442
|
+
}
|
|
429
443
|
if (
|
|
430
444
|
Number.isInteger(r.maxSubagentDepth) &&
|
|
431
445
|
(r.maxSubagentDepth as number) >= 0 &&
|
|
@@ -579,6 +593,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
579
593
|
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
580
594
|
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
581
595
|
if (typeof s.toolTimeoutSeconds === "number") appliers.setToolTimeoutSeconds(s.toolTimeoutSeconds);
|
|
596
|
+
if (typeof s.codemodeTimeoutSeconds === "number") appliers.setCodemodeTimeoutSeconds(s.codemodeTimeoutSeconds);
|
|
582
597
|
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
583
598
|
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
|
|
584
599
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
@@ -52,9 +52,14 @@
|
|
|
52
52
|
/**
|
|
53
53
|
* Runs inside the realm, ahead of the script body, on a single line.
|
|
54
54
|
*
|
|
55
|
-
* One line matters: the body
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* One line matters: the body starts on the same line as the wrapper and this
|
|
56
|
+
* prelude, so the script's line N is the compiled source's line N and reported
|
|
57
|
+
* line numbers match the file the author wrote. Any newline in here shifts every
|
|
58
|
+
* stack frame in every workflow script.
|
|
59
|
+
*
|
|
60
|
+
* Same line rather than `\n` + `lineOffset: -1`: Bun's `vm.Script` honours a
|
|
61
|
+
* positive `lineOffset` but ignores a negative one, which put every frame one
|
|
62
|
+
* line below the line the author wrote.
|
|
58
63
|
*/
|
|
59
64
|
const DETERMINISM_PRELUDE =
|
|
60
65
|
"const Date = (function () {" +
|
|
@@ -732,8 +737,8 @@ async function workflowIn(scope, nameOrRef, args) {
|
|
|
732
737
|
// \`meta\` is deliberately not a parameter: the body still opens with its
|
|
733
738
|
// own \`const meta = { ... }\` (extractMeta strips only the \`export\`), so a
|
|
734
739
|
// parameter of that name would collide with it.
|
|
735
|
-
"(async (agent, phase, log, workflow, console, args) => {" + PRELUDE +
|
|
736
|
-
{ filename: "workflow:" + loaded.name + ".js"
|
|
740
|
+
"(async (agent, phase, log, workflow, console, args) => {" + PRELUDE + loaded.body + "\\n})",
|
|
741
|
+
{ filename: "workflow:" + loaded.name + ".js" }
|
|
737
742
|
);
|
|
738
743
|
run = compiled.runInContext(realmContext);
|
|
739
744
|
} catch (error) {
|
|
@@ -805,11 +810,10 @@ async function main() {
|
|
|
805
810
|
sandbox.meta = realmParse(workerData.metaJson);
|
|
806
811
|
sandbox.args = workerData.argsJson === undefined ? undefined : realmParse(workerData.argsJson);
|
|
807
812
|
|
|
808
|
-
|
|
813
|
+
// The wrapper and prelude share the body's first line (see PRELUDE), so a
|
|
814
|
+
// thrown error points at the line the author wrote with no lineOffset.
|
|
815
|
+
const script = new vm.Script("(async () => {" + PRELUDE + workerData.body + "\\n})()", {
|
|
809
816
|
filename: "workflow.js",
|
|
810
|
-
// The wrapper adds exactly one line above the body; undo it so a thrown
|
|
811
|
-
// error points at the line the author wrote.
|
|
812
|
-
lineOffset: -1,
|
|
813
817
|
});
|
|
814
818
|
|
|
815
819
|
const value = await script.runInContext(context);
|