killeros 1.4.3 → 1.4.5
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 +10 -0
- package/README.md +7 -7
- package/agents/debugger.md +0 -1
- package/agents/documenter.md +0 -1
- package/agents/planner.md +0 -1
- package/agents/reviewer.md +1 -2
- package/agents/scout.md +2 -3
- package/agents/security.md +1 -2
- package/agents/tester.md +0 -1
- package/agents/worker.md +0 -1
- package/package.json +1 -2
- package/subagent-process.ts +30 -31
- package/subagent-ui.ts +26 -9
- package/subagents.ts +44 -71
- package/subagent-budget.ts +0 -65
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to KillerOS are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.4.5] - 2026-08-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Removed the child-budget extension, its read-tool budget, and the default 250,000-token/$5 quota.
|
|
10
|
+
- Removed default child wall-time, JSONL-line, trace, stderr, returned-output, and model-output-length stops; role `timeoutMs` and other child guards are now opt-in.
|
|
11
|
+
- Removed forced early-report prompt text so roles can finish their assigned work naturally.
|
|
12
|
+
- Treat model stop reason `length` as a completed child process instead of inventing a KillerOS `limited` result.
|
|
13
|
+
- Documented the child lifecycle contract: children complete naturally; explicit user interruptions, configured guards, and real child-process failures remain visible.
|
|
14
|
+
|
|
5
15
|
## [1.4.3] - 2026-08-01
|
|
6
16
|
|
|
7
17
|
### Fixed
|
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ pi install git:github.com/KyrosHendrix/pi-KillerOS
|
|
|
33
33
|
Pin an install to a release:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.
|
|
36
|
+
pi install git:github.com/KyrosHendrix/pi-KillerOS@v1.4.5
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Add `-l` to either command for a project-only install. Restart Pi after installing.
|
|
@@ -47,7 +47,7 @@ Add `-l` to either command for a project-only install. Restart Pi after installi
|
|
|
47
47
|
- Responsive footer with polished model/provider identity, plain-language context, and active goal state remaining; reasoning, Git branch, elapsed time, cost, and path cut down by available width
|
|
48
48
|
- `/variants` selector and direct reasoning-level arguments
|
|
49
49
|
- Codex-style `/goal` for durable long-running objectives with pause, resume, edit, clear, automatic continuation, and explicit completion
|
|
50
|
-
- Pi-native `subagent` tool with named, inspectable child threads, Markdown roles, explicit read/write boundaries, parent controls,
|
|
50
|
+
- Pi-native `subagent` tool with named, inspectable child threads, Markdown roles, explicit read/write boundaries, parent controls, natural completion, and cancellation propagation
|
|
51
51
|
- Claude Code-style `/init` that scans the repository and generates a concise root `AGENTS.md` without setup questions
|
|
52
52
|
- `question` tool with filtering, proposal previews, keyboard selection, custom answers, history, cancellation, and resize-safe rendering
|
|
53
53
|
- Mid-prompt slash completion with current Pi `0.82.1` commands, extensions, prompts, and skills
|
|
@@ -89,7 +89,7 @@ KillerOS ships `planner`, `reviewer`, `scout`, and `security` as read-only roles
|
|
|
89
89
|
| `scout` | read | Map unfamiliar code and return an evidence trail |
|
|
90
90
|
| `security` | read | Audit trust boundaries and report concrete security findings |
|
|
91
91
|
| `tester` | write | Add focused coverage and run deterministic verification |
|
|
92
|
-
| `worker` | write | Execute
|
|
92
|
+
| `worker` | write | Execute the assigned repository change |
|
|
93
93
|
|
|
94
94
|
1. Bundled: `<killeros>/agents/*.md`
|
|
95
95
|
2. Personal: `~/.pi/agent/agents/*.md`
|
|
@@ -103,7 +103,7 @@ The tool supports a single `agent` + `task`, parallel `tasks`, or a sequential `
|
|
|
103
103
|
{"agent":"reviewer","task":"Review the change","model":"provider/model","thinking":"high"}
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as ephemeral `pi --mode json -p --no-session` processes with explicit local tools plus `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Each child explicitly loads `npm:pi-web-access
|
|
106
|
+
Use the separate `model` and `thinking` fields for new configuration. The older `provider/model:thinking` model form remains accepted. Children run as ephemeral `pi --mode json -p --no-session` processes with explicit local tools plus `web_search`, `source_check`, `fetch_content`, and `get_search_content`. Each child explicitly loads `npm:pi-web-access`, discovers available skills, and keeps arbitrary extensions and prompt templates disabled; project-local skills load only when the parent project is trusted. Every bundled role is instructed to load the most relevant `SKILL.md` and report useful evidence. Children have no default token, dollar, turn, tool-call, research, wall-time, JSONL-line, trace, stderr, or returned-output quota. The parent still limits each request to eight tasks and four parallel readers and bounds role files, task input, and combined parent output. An embedding caller may opt into named child resource guards. Esc cancellation terminates active children and escalates after five seconds.
|
|
107
107
|
|
|
108
108
|
### Thread lifecycle
|
|
109
109
|
|
|
@@ -113,7 +113,7 @@ Threads move through `queued`, `active`, `done`, `failed`, `stopped`, and `close
|
|
|
113
113
|
|
|
114
114
|
The parent can inspect a thread’s prompt, role, model, tools, trace, usage, and handoff; steer an active thread with one bounded follow-up; interrupt one child or all active children; collect a concise handoff into parent context; and close a finished or stopped thread. An interrupt preserves the partial trace, states the reason, and reports the handoff as partial rather than successful.
|
|
115
115
|
|
|
116
|
-
A child completes naturally when it returns a final answer.
|
|
116
|
+
A child completes naturally when it returns a final answer. The default path has no per-child execution quota. Explicit embedding options can add wall-time, output, trace, stderr, JSONL, token, or cost guards; those guards report their cause and return partial work clearly. The parent still bounds task count, reader concurrency, role files, task input, and combined parent output. Explicit user interruptions and real child-process failures remain visible. Esc cancellation terminates active children and escalates after five seconds.
|
|
117
117
|
|
|
118
118
|
The replacement lifecycle has nine phases:
|
|
119
119
|
|
|
@@ -123,7 +123,7 @@ The replacement lifecycle has nine phases:
|
|
|
123
123
|
4. **Steer:** append a bounded parent follow-up to an active thread.
|
|
124
124
|
5. **Interrupt:** stop one or all active children while preserving partial work.
|
|
125
125
|
6. **Collect:** return a concise handoff while retaining the expanded trace.
|
|
126
|
-
7. **
|
|
126
|
+
7. **Guard:** honor only explicitly configured child resource guards; do not impose a routine turn stop.
|
|
127
127
|
8. **Close:** remove a finished or stopped thread from the workspace without deleting its result record.
|
|
128
128
|
9. **Prove:** test identity, visibility, controls, natural completion, guards, partial handoffs, and closure.
|
|
129
129
|
|
|
@@ -163,7 +163,7 @@ The package manifest lists Pi’s built-in modules as peer dependencies, so npm
|
|
|
163
163
|
|
|
164
164
|
The [`pi-package`](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) keyword makes a published npm release visible in Pi’s package catalog.
|
|
165
165
|
|
|
166
|
-
For
|
|
166
|
+
For release `1.4.5`, publish after the validation checks pass:
|
|
167
167
|
|
|
168
168
|
```bash
|
|
169
169
|
npm login
|
package/agents/debugger.md
CHANGED
package/agents/documenter.md
CHANGED
package/agents/planner.md
CHANGED
package/agents/reviewer.md
CHANGED
|
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
|
|
|
6
6
|
# Replace inherit with provider/model to pin this role; set thinking separately when needed.
|
|
7
7
|
model: inherit
|
|
8
8
|
thinking: inherit
|
|
9
|
-
timeoutMs: 300000
|
|
10
9
|
---
|
|
11
10
|
|
|
12
11
|
# Role
|
|
@@ -39,7 +38,7 @@ If no concrete issue is found, report the reviewed scope, protections checked, a
|
|
|
39
38
|
|
|
40
39
|
## Boundaries
|
|
41
40
|
|
|
42
|
-
Do not edit files or fix findings yourself. Do not report stylistic preferences as defects. Do not claim a vulnerability, regression, or test gap without repository evidence.
|
|
41
|
+
Do not edit files or fix findings yourself. Do not report stylistic preferences as defects. Do not claim a vulnerability, regression, or test gap without repository evidence. Finish the assigned review when the evidence resolves its scope, report the findings or clean result, and do not chase unrelated work.
|
|
43
42
|
|
|
44
43
|
## Skills and web research
|
|
45
44
|
|
package/agents/scout.md
CHANGED
|
@@ -6,12 +6,11 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
|
|
|
6
6
|
# Replace inherit with provider/model to pin this role; set thinking separately when needed.
|
|
7
7
|
model: inherit
|
|
8
8
|
thinking: inherit
|
|
9
|
-
timeoutMs: 300000
|
|
10
9
|
---
|
|
11
10
|
|
|
12
11
|
# Role
|
|
13
12
|
|
|
14
|
-
You are the `scout` role, a
|
|
13
|
+
You are the `scout` role, a focused repository investigator. Your job is to make an unfamiliar codebase legible to the parent agent, not to become an unrequested implementer or produce an exhaustive directory tour.
|
|
15
14
|
|
|
16
15
|
## Mission
|
|
17
16
|
|
|
@@ -23,7 +22,7 @@ Begin with the user’s actual question and define what evidence would answer it
|
|
|
23
22
|
2. **Trace.** Follow the real path from input or command to state, side effect, and output. Follow callers and callees only when the current evidence requires it.
|
|
24
23
|
3. **Compare.** Check neighboring implementations, overrides, fixtures, generated files, and documentation when they could change the conclusion.
|
|
25
24
|
4. **Verify.** Record exact paths, symbols, commands, and conventions. Distinguish observed facts from inferences and unresolved questions.
|
|
26
|
-
5. **
|
|
25
|
+
5. **Finish.** Once evidence closes the parent’s question, report the findings and stop. Do not chase unrelated files, generic architecture advice, or speculative fixes.
|
|
27
26
|
|
|
28
27
|
## Report
|
|
29
28
|
|
package/agents/security.md
CHANGED
|
@@ -6,7 +6,6 @@ tools: read, grep, find, ls, web_search, source_check, fetch_content, get_search
|
|
|
6
6
|
# Replace inherit with provider/model to pin this role; set thinking separately when needed.
|
|
7
7
|
model: inherit
|
|
8
8
|
thinking: inherit
|
|
9
|
-
timeoutMs: 300000
|
|
10
9
|
---
|
|
11
10
|
|
|
12
11
|
# Role
|
|
@@ -35,7 +34,7 @@ Relevant lenses may include:
|
|
|
35
34
|
|
|
36
35
|
## Finding standard
|
|
37
36
|
|
|
38
|
-
Every confirmed finding must name the severity, precondition or trigger, affected path or symbol, evidence, security impact, and smallest safe correction. Separate questions and blind spots from findings. If no concrete issue is found, state the scope, lenses applied, controls verified, and meaningful limitations.
|
|
37
|
+
Every confirmed finding must name the severity, precondition or trigger, affected path or symbol, evidence, security impact, and smallest safe correction. Separate questions and blind spots from findings. If no concrete issue is found, state the scope, lenses applied, controls verified, and meaningful limitations. Finish the assigned review when the evidence resolves its scope, report the findings or clean result, and do not chase unrelated work.
|
|
39
38
|
|
|
40
39
|
## Skills and web research
|
|
41
40
|
|
package/agents/tester.md
CHANGED
package/agents/worker.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "killeros",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.5",
|
|
4
4
|
"description": "A production-hardened TUI and workflow extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
"subagents.ts",
|
|
25
25
|
"subagent-lifecycle.ts",
|
|
26
26
|
"subagent-process.ts",
|
|
27
|
-
"subagent-budget.ts",
|
|
28
27
|
"subagent-ui.ts",
|
|
29
28
|
"agents/*.md",
|
|
30
29
|
"themes/killeros.json",
|
package/subagent-process.ts
CHANGED
|
@@ -3,13 +3,6 @@ import { statSync } from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
export const SUBAGENT_PROCESS_LIMITS = {
|
|
6
|
-
wallTimeMs: 600_000,
|
|
7
|
-
jsonlLineBytes: 32 * 1024 * 1024,
|
|
8
|
-
traceBytes: 2 * 1024 * 1024,
|
|
9
|
-
stderrBytes: 64 * 1024,
|
|
10
|
-
outputBytes: 50 * 1024,
|
|
11
|
-
quotaTokens: 250_000,
|
|
12
|
-
quotaUsd: 5,
|
|
13
6
|
killGraceMs: 5_000,
|
|
14
7
|
} as const;
|
|
15
8
|
|
|
@@ -75,13 +68,13 @@ export interface SubagentProcessOptions {
|
|
|
75
68
|
}
|
|
76
69
|
|
|
77
70
|
export interface SubagentProcessLimits {
|
|
78
|
-
wallTimeMs
|
|
79
|
-
jsonlLineBytes
|
|
80
|
-
traceBytes
|
|
81
|
-
stderrBytes
|
|
82
|
-
outputBytes
|
|
83
|
-
quotaTokens
|
|
84
|
-
quotaUsd
|
|
71
|
+
wallTimeMs?: number;
|
|
72
|
+
jsonlLineBytes?: number;
|
|
73
|
+
traceBytes?: number;
|
|
74
|
+
stderrBytes?: number;
|
|
75
|
+
outputBytes?: number;
|
|
76
|
+
quotaTokens?: number;
|
|
77
|
+
quotaUsd?: number;
|
|
85
78
|
killGraceMs: number;
|
|
86
79
|
}
|
|
87
80
|
|
|
@@ -178,11 +171,11 @@ function traceMessage(message: any): string[] {
|
|
|
178
171
|
return entries;
|
|
179
172
|
}
|
|
180
173
|
|
|
181
|
-
function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number): boolean {
|
|
174
|
+
function appendTrace(result: SubagentProcessResult, entries: string[], maxBytes: number | undefined): boolean {
|
|
182
175
|
let truncated = false;
|
|
183
176
|
for (const entry of entries) {
|
|
184
177
|
const entryBytes = Buffer.byteLength(entry, "utf8");
|
|
185
|
-
const retained = truncateUtf8(entry, Math.max(0, maxBytes - result.traceBytes));
|
|
178
|
+
const retained = truncateUtf8(entry, maxBytes === undefined ? entryBytes : Math.max(0, maxBytes - result.traceBytes));
|
|
186
179
|
if (retained.text) result.trace.push(retained.text);
|
|
187
180
|
result.traceBytes += Buffer.byteLength(retained.text, "utf8");
|
|
188
181
|
result.traceTruncatedBytes += entryBytes - Buffer.byteLength(retained.text, "utf8");
|
|
@@ -199,11 +192,11 @@ function normalizeLimits(overrides: Partial<SubagentProcessLimits> | undefined):
|
|
|
199
192
|
const limits = { ...SUBAGENT_PROCESS_LIMITS, ...overrides };
|
|
200
193
|
for (const name of ["wallTimeMs", "jsonlLineBytes", "traceBytes", "stderrBytes", "outputBytes", "killGraceMs"] as const) {
|
|
201
194
|
const value = limits[name];
|
|
202
|
-
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
|
|
195
|
+
if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw new RangeError(`${name} must be a positive safe integer`);
|
|
203
196
|
}
|
|
204
197
|
for (const name of ["quotaTokens", "quotaUsd"] as const) {
|
|
205
198
|
const value = limits[name];
|
|
206
|
-
if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be a positive finite number`);
|
|
199
|
+
if (value !== undefined && (!Number.isFinite(value) || value <= 0)) throw new RangeError(`${name} must be a positive finite number`);
|
|
207
200
|
}
|
|
208
201
|
return limits;
|
|
209
202
|
}
|
|
@@ -270,7 +263,7 @@ function terminateProcess(child: SubagentProcessChild, force: boolean): void {
|
|
|
270
263
|
|
|
271
264
|
/**
|
|
272
265
|
* Run one isolated Pi JSON process. The caller owns all Pi arguments, including
|
|
273
|
-
* model, tools, prompt, and extension flags.
|
|
266
|
+
* model, tools, prompt, and extension flags. Resource limits are opt-in.
|
|
274
267
|
*/
|
|
275
268
|
export function runSubagentProcess(options: SubagentProcessOptions): SubagentProcessHandle {
|
|
276
269
|
const args = [...options.args];
|
|
@@ -331,9 +324,12 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
331
324
|
if (requestedStatus) {
|
|
332
325
|
state.status = requestedStatus;
|
|
333
326
|
state.terminationReason = requestedReason;
|
|
334
|
-
} else if (code !== 0 || state.errorMessage || state.stopReason
|
|
335
|
-
state.status = "failed";
|
|
327
|
+
} else if (code !== 0 || state.errorMessage || state.stopReason === "error" || state.stopReason === "aborted") {
|
|
328
|
+
state.status = state.stopReason === "aborted" ? "cancelled" : "failed";
|
|
336
329
|
state.terminationReason ??= code === null ? "process_closed" : `exit_${code}`;
|
|
330
|
+
} else if (state.stopReason !== undefined && !["stop", "toolUse", "length"].includes(state.stopReason)) {
|
|
331
|
+
state.status = "failed";
|
|
332
|
+
state.terminationReason = state.stopReason;
|
|
337
333
|
} else if (state.usage.turns === 0) {
|
|
338
334
|
state.status = "failed";
|
|
339
335
|
state.terminationReason = "missing_assistant_message";
|
|
@@ -382,9 +378,9 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
382
378
|
state.usage.turns += 1;
|
|
383
379
|
addUsage(state.usage, { ...message.usage, turns: 0 });
|
|
384
380
|
state.toolCallCount += toolCallCount(message);
|
|
385
|
-
if (state.usage.totalTokens > limits.quotaTokens) {
|
|
381
|
+
if (limits.quotaTokens !== undefined && state.usage.totalTokens > limits.quotaTokens) {
|
|
386
382
|
requestTermination("limited", "quota_tokens", `Child token usage exceeds ${limits.quotaTokens}`);
|
|
387
|
-
} else if (state.usage.cost.total > limits.quotaUsd) {
|
|
383
|
+
} else if (limits.quotaUsd !== undefined && state.usage.cost.total > limits.quotaUsd) {
|
|
388
384
|
requestTermination("limited", "quota_cost", `Child cost exceeds $${limits.quotaUsd}`);
|
|
389
385
|
}
|
|
390
386
|
if (appendTrace(state, traceMessage(message), limits.traceBytes)) {
|
|
@@ -392,13 +388,13 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
392
388
|
}
|
|
393
389
|
const output = textContent(message);
|
|
394
390
|
if (output) {
|
|
395
|
-
const capped = truncateUtf8(output, limits.outputBytes);
|
|
391
|
+
const capped = truncateUtf8(output, limits.outputBytes ?? Buffer.byteLength(output, "utf8"));
|
|
396
392
|
state.output = capped.text;
|
|
397
393
|
state.outputTruncatedBytes = capped.omittedBytes;
|
|
398
394
|
outputBytesSeen += Buffer.byteLength(output, "utf8");
|
|
399
395
|
state.outputBytes = outputBytesSeen;
|
|
400
|
-
state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, outputBytesSeen - limits.outputBytes);
|
|
401
|
-
if (outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
|
|
396
|
+
state.outputTruncatedBytes = Math.max(state.outputTruncatedBytes, limits.outputBytes === undefined ? 0 : outputBytesSeen - limits.outputBytes);
|
|
397
|
+
if (limits.outputBytes !== undefined && outputBytesSeen > limits.outputBytes) requestTermination("limited", "output_limit", `Child output exceeds ${limits.outputBytes} bytes`);
|
|
402
398
|
}
|
|
403
399
|
if (typeof message.model === "string") state.model = message.provider ? `${message.provider}/${message.model}` : message.model;
|
|
404
400
|
if (typeof message.stopReason === "string") {
|
|
@@ -407,7 +403,6 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
407
403
|
else state.terminationReason = message.stopReason;
|
|
408
404
|
}
|
|
409
405
|
if (typeof message.errorMessage === "string") state.errorMessage = message.errorMessage;
|
|
410
|
-
if (message.stopReason === "length") requestTermination("limited", "model_output_limit");
|
|
411
406
|
publish();
|
|
412
407
|
} else if (event?.type === "tool_result_end" && event.message) {
|
|
413
408
|
const name = typeof event.message.toolName === "string" ? event.message.toolName : "tool";
|
|
@@ -419,12 +414,14 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
419
414
|
};
|
|
420
415
|
const appendStdout = (fragment: Buffer): boolean => {
|
|
421
416
|
const nextBytes = stdoutLineBytes + fragment.length;
|
|
422
|
-
if (nextBytes > limits.jsonlLineBytes) {
|
|
417
|
+
if (limits.jsonlLineBytes !== undefined && nextBytes > limits.jsonlLineBytes) {
|
|
423
418
|
requestTermination("limited", "jsonl_line_limit", `Child JSONL line exceeds ${limits.jsonlLineBytes} bytes`);
|
|
424
419
|
return false;
|
|
425
420
|
}
|
|
426
421
|
if (nextBytes > stdoutLine.length) {
|
|
427
|
-
const nextCapacity =
|
|
422
|
+
const nextCapacity = limits.jsonlLineBytes === undefined
|
|
423
|
+
? Math.max(nextBytes, stdoutLine.length * 2, 4_096)
|
|
424
|
+
: Math.min(limits.jsonlLineBytes, Math.max(nextBytes, stdoutLine.length * 2, 4_096));
|
|
428
425
|
const expanded = Buffer.allocUnsafe(nextCapacity);
|
|
429
426
|
stdoutLine.copy(expanded, 0, 0, stdoutLineBytes);
|
|
430
427
|
stdoutLine = expanded;
|
|
@@ -461,14 +458,16 @@ export function runSubagentProcess(options: SubagentProcessOptions): SubagentPro
|
|
|
461
458
|
});
|
|
462
459
|
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
463
460
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
464
|
-
const retained =
|
|
461
|
+
const retained = limits.stderrBytes === undefined
|
|
462
|
+
? buffer
|
|
463
|
+
: buffer.subarray(0, Math.max(0, limits.stderrBytes - stderr.length));
|
|
465
464
|
if (retained.length) stderr = Buffer.concat([stderr, retained]);
|
|
466
465
|
state.stderrTruncatedBytes += buffer.length - retained.length;
|
|
467
466
|
if (buffer.length > retained.length) requestTermination("limited", "stderr_limit", `Child stderr exceeds ${limits.stderrBytes} bytes`);
|
|
468
467
|
});
|
|
469
468
|
child.on("error", (error) => requestTermination("failed", "spawn_error", error.message));
|
|
470
469
|
child.once("close", finish);
|
|
471
|
-
timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
|
|
470
|
+
if (limits.wallTimeMs !== undefined) timeoutTimer = setTimeout(() => requestTermination("limited", "wall_time_limit"), limits.wallTimeMs);
|
|
472
471
|
options.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
473
472
|
if (options.signal?.aborted) abortHandler();
|
|
474
473
|
} catch (error) {
|
package/subagent-ui.ts
CHANGED
|
@@ -120,14 +120,31 @@ function toRecord(item: ThreadListItem): ThreadRecord {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
function threadStatus(thread: Pick<ThreadRecord, "status" | "terminationReason">): ThreadStatus {
|
|
124
124
|
const reason = thread.terminationReason;
|
|
125
|
-
if (thread.status === "
|
|
126
|
-
if (thread.status === "
|
|
127
|
-
if (thread.status === "
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
if (thread.status === "failed" || reason === "error" || reason === "spawn_error" || reason === "process_closed" || reason === "missing_assistant_message" || reason === "malformed_jsonl" || reason === "invalid_usage" || reason?.startsWith("exit_")) return "failed";
|
|
126
|
+
if (thread.status === "cancelled" || reason === "abort" || reason === "interrupt") return "cancelled";
|
|
127
|
+
if (thread.status === "complete" || reason === "completed") return "complete";
|
|
128
|
+
return thread.status;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function threadReason(thread: Pick<ThreadRecord, "terminationReason" | "errorMessage">, status: ThreadStatus): string | undefined {
|
|
132
|
+
if (status === "cancelled" && thread.terminationReason === "interrupt") return "Interrupted by user.";
|
|
133
|
+
if (status === "cancelled" && thread.terminationReason === "abort") return "Cancelled by parent.";
|
|
134
|
+
if (status === "failed" && thread.errorMessage && thread.terminationReason) return `${thread.terminationReason}: ${thread.errorMessage}`;
|
|
135
|
+
if (status === "failed") return thread.errorMessage ?? thread.terminationReason ?? "Child process failed.";
|
|
136
|
+
return thread.terminationReason;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatThreadState(thread: Pick<ThreadRecord, "status" | "terminationReason" | "errorMessage">): ThreadStateView {
|
|
140
|
+
const status = threadStatus(thread);
|
|
141
|
+
const reason = threadReason(thread, status);
|
|
142
|
+
if (status === "complete") return { status, label: "Complete" };
|
|
143
|
+
if (status === "failed") return { status, label: "Failed", reason, partialWork: "Failed before completion. Any saved output is partial work." };
|
|
144
|
+
if (status === "cancelled") return { status, label: "Stopped", reason, partialWork: "Stopped before completion. Any saved output is partial work." };
|
|
145
|
+
if (status === "limited") return { status, label: "Limited", reason, partialWork: "Stopped at a limit before completion. Any saved output is partial work." };
|
|
146
|
+
if (status === "running") return { status, label: "Running", reason };
|
|
147
|
+
return { status, label: "Queued", reason };
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
export function formatThreadUsage(usage: ThreadUsage): ThreadUsageView {
|
|
@@ -148,9 +165,9 @@ export function formatThreadTrace(thread: Pick<ThreadRecord, "trace" | "traceTru
|
|
|
148
165
|
};
|
|
149
166
|
}
|
|
150
167
|
|
|
151
|
-
export function formatThreadHandoff(thread: Pick<ThreadRecord, "status" | "handoff" | "output">): ThreadHandoffView {
|
|
168
|
+
export function formatThreadHandoff(thread: Pick<ThreadRecord, "status" | "terminationReason" | "handoff" | "output">): ThreadHandoffView {
|
|
152
169
|
const text = thread.handoff ?? thread.output ?? "No handoff yet.";
|
|
153
|
-
return { label: "Handoff", text, isPartial: thread
|
|
170
|
+
return { label: "Handoff", text, isPartial: threadStatus(thread) !== "complete" };
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
export function formatThreadControls(status: ThreadStatus): readonly ThreadBoardControl[] {
|
package/subagents.ts
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type } from "typebox";
|
|
18
|
-
import { CHILD_TOOL_BUDGET_ENV, type ChildToolBudget } from "./subagent-budget.ts";
|
|
19
18
|
import { SubagentThreadRegistry, type SubagentThread, type SubagentThreadId, type SubagentThreadState } from "./subagent-lifecycle.ts";
|
|
20
19
|
import { runSubagentProcess, type SubagentProcessHandle, type SubagentProcessResult } from "./subagent-process.ts";
|
|
21
20
|
import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadBoardRecord } from "./subagent-ui.ts";
|
|
@@ -23,17 +22,7 @@ import { formatThreadBoard, formatThreadInspection, type ThreadRecord as ThreadB
|
|
|
23
22
|
export const SUBAGENT_LIMITS = {
|
|
24
23
|
maxTasks: 8,
|
|
25
24
|
maxReadConcurrency: 4,
|
|
26
|
-
defaultTimeoutMs: 300_000,
|
|
27
|
-
maxTimeoutMs: 600_000,
|
|
28
|
-
jsonlLineBytes: 32 * 1024 * 1024,
|
|
29
|
-
traceBytes: 2 * 1024 * 1024,
|
|
30
|
-
stderrBytes: 64 * 1024,
|
|
31
|
-
taskOutputBytes: 50 * 1024,
|
|
32
25
|
toolOutputBytes: 50 * 1024,
|
|
33
|
-
quotaTokens: 250_000,
|
|
34
|
-
quotaUsd: 5,
|
|
35
|
-
readToolBudgetSoft: 24,
|
|
36
|
-
readToolBudgetHard: 32,
|
|
37
26
|
roleFileBytes: 64 * 1024,
|
|
38
27
|
taskCharacters: 20_000,
|
|
39
28
|
killGraceMs: 5_000,
|
|
@@ -44,16 +33,9 @@ const READ_TOOLS = new Set(["read", "grep", "find", "ls", ...WEB_TOOLS]);
|
|
|
44
33
|
const WRITE_TOOLS = new Set(["bash", "edit", "write"]);
|
|
45
34
|
const KNOWN_TOOLS = new Set([...READ_TOOLS, ...WRITE_TOOLS]);
|
|
46
35
|
const SUBAGENT_WEB_EXTENSION = "npm:pi-web-access";
|
|
47
|
-
const SUBAGENT_BUDGET_EXTENSION = fileURLToPath(new URL("./subagent-budget.ts", import.meta.url));
|
|
48
36
|
const INHERIT_SETTING = "inherit";
|
|
49
37
|
const MAX_RUNTIME_STEERING_MESSAGES = 20;
|
|
50
38
|
const ROLE_FIELDS = new Set(["name", "description", "access", "tools", "model", "thinking", "timeoutMs"]);
|
|
51
|
-
const CHILD_REPORT_PROTOCOL = [
|
|
52
|
-
"## Child report protocol",
|
|
53
|
-
"Work in bounded passes. After the first useful evidence, write a concise report with findings, exact files, checks run, and remaining work.",
|
|
54
|
-
"Do not keep opening files or searching after you have enough evidence to answer the task.",
|
|
55
|
-
"If a tool budget notice or blocked-tool message appears, stop research and report from the context you have. A partial report is better than no report.",
|
|
56
|
-
].join("\n");
|
|
57
39
|
|
|
58
40
|
type ThinkingLevel = ModelThinkingLevel;
|
|
59
41
|
export type AgentAccess = "read" | "write";
|
|
@@ -68,7 +50,7 @@ export interface AgentRole {
|
|
|
68
50
|
tools: string[];
|
|
69
51
|
model?: string;
|
|
70
52
|
thinking?: string;
|
|
71
|
-
timeoutMs
|
|
53
|
+
timeoutMs?: number;
|
|
72
54
|
prompt: string;
|
|
73
55
|
source: AgentSource;
|
|
74
56
|
filePath: string;
|
|
@@ -160,7 +142,15 @@ interface SpawnedProcess {
|
|
|
160
142
|
once(event: "close", listener: (code: number | null) => void): this;
|
|
161
143
|
}
|
|
162
144
|
|
|
163
|
-
type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number }
|
|
145
|
+
type SubagentLimits = { [Key in keyof typeof SUBAGENT_LIMITS]: number } & {
|
|
146
|
+
wallTimeMs?: number;
|
|
147
|
+
jsonlLineBytes?: number;
|
|
148
|
+
traceBytes?: number;
|
|
149
|
+
stderrBytes?: number;
|
|
150
|
+
taskOutputBytes?: number;
|
|
151
|
+
quotaTokens?: number;
|
|
152
|
+
quotaUsd?: number;
|
|
153
|
+
};
|
|
164
154
|
|
|
165
155
|
export interface SubagentRuntimeOptions {
|
|
166
156
|
bundledAgentsDir?: string;
|
|
@@ -243,14 +233,15 @@ function optionalPositiveInteger(
|
|
|
243
233
|
frontmatter: Record<string, unknown>,
|
|
244
234
|
filePath: string,
|
|
245
235
|
field: string,
|
|
246
|
-
fallback
|
|
247
|
-
maximum
|
|
248
|
-
): number {
|
|
236
|
+
fallback?: number,
|
|
237
|
+
maximum?: number,
|
|
238
|
+
): number | undefined {
|
|
249
239
|
const value = frontmatter[field];
|
|
250
240
|
if (value === undefined || value === "") return fallback;
|
|
251
241
|
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
252
|
-
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > maximum) {
|
|
253
|
-
|
|
242
|
+
if (!Number.isInteger(parsed) || parsed <= 0 || maximum !== undefined && parsed > maximum) {
|
|
243
|
+
const bound = maximum === undefined ? "" : " no greater than " + maximum;
|
|
244
|
+
throw new AgentConfigurationError(filePath, field, `must be a positive integer${bound}`);
|
|
254
245
|
}
|
|
255
246
|
return parsed;
|
|
256
247
|
}
|
|
@@ -307,7 +298,7 @@ function parseAgentFile(filePath: string, source: AgentSource, limits: SubagentL
|
|
|
307
298
|
tools,
|
|
308
299
|
model: typeof modelValue === "string" ? modelValue.trim() : undefined,
|
|
309
300
|
thinking: typeof thinkingValue === "string" ? thinkingValue.trim() : undefined,
|
|
310
|
-
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs"
|
|
301
|
+
timeoutMs: optionalPositiveInteger(frontmatter, filePath, "timeoutMs"),
|
|
311
302
|
prompt,
|
|
312
303
|
source,
|
|
313
304
|
filePath,
|
|
@@ -520,14 +511,14 @@ function cloneResult(result: SubagentTaskResult): SubagentTaskResult {
|
|
|
520
511
|
};
|
|
521
512
|
}
|
|
522
513
|
|
|
523
|
-
function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes
|
|
514
|
+
function mergeTaskResults(previous: SubagentTaskResult | undefined, next: SubagentTaskResult, maxTraceBytes?: number): SubagentTaskResult {
|
|
524
515
|
if (!previous) return cloneResult(next);
|
|
525
516
|
const merged = cloneResult(next);
|
|
526
517
|
const trace: string[] = [];
|
|
527
518
|
let traceBytes = 0;
|
|
528
519
|
let traceTruncatedBytes = previous.traceTruncatedBytes + next.traceTruncatedBytes;
|
|
529
520
|
for (const entry of [...previous.trace, ...next.trace]) {
|
|
530
|
-
const retained = truncateUtf8(entry, Math.max(0, maxTraceBytes - traceBytes));
|
|
521
|
+
const retained = truncateUtf8(entry, maxTraceBytes === undefined ? Buffer.byteLength(entry, "utf8") : Math.max(0, maxTraceBytes - traceBytes));
|
|
531
522
|
if (retained.text) trace.push(retained.text);
|
|
532
523
|
const retainedBytes = Buffer.byteLength(retained.text, "utf8");
|
|
533
524
|
traceBytes += retainedBytes;
|
|
@@ -558,7 +549,7 @@ function cloneDetails(mode: SubagentDetails["mode"], scope: AgentScope, projectA
|
|
|
558
549
|
async function writeRolePrompt(agent: AgentRole): Promise<{ directory: string; filePath: string }> {
|
|
559
550
|
const directory = await mkdtemp(path.join(os.tmpdir(), "killeros-subagent-"));
|
|
560
551
|
const filePath = path.join(directory, `${agent.name.replace(/[^A-Za-z0-9_.-]/gu, "_")}.md`);
|
|
561
|
-
await writeFile(filePath,
|
|
552
|
+
await writeFile(filePath, agent.prompt, { encoding: "utf8", mode: 0o600 });
|
|
562
553
|
return { directory, filePath };
|
|
563
554
|
}
|
|
564
555
|
|
|
@@ -577,7 +568,6 @@ interface RunTaskOptions {
|
|
|
577
568
|
timeoutMs?: number;
|
|
578
569
|
onChange: (result: SubagentTaskResult) => void;
|
|
579
570
|
onHandle?: (handle: SubagentProcessHandle) => void;
|
|
580
|
-
toolBudget?: ChildToolBudget;
|
|
581
571
|
}
|
|
582
572
|
|
|
583
573
|
function applyProcessResult(
|
|
@@ -644,7 +634,6 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
644
634
|
"--no-session",
|
|
645
635
|
"--no-extensions",
|
|
646
636
|
"--extension", options.webExtension ?? SUBAGENT_WEB_EXTENSION,
|
|
647
|
-
"--extension", SUBAGENT_BUDGET_EXTENSION,
|
|
648
637
|
"--no-prompt-templates",
|
|
649
638
|
options.projectTrusted ? "--approve" : "--no-approve",
|
|
650
639
|
"--model", options.model.model,
|
|
@@ -659,18 +648,15 @@ async function runTask(options: RunTaskOptions): Promise<SubagentTaskResult> {
|
|
|
659
648
|
signal: options.signal,
|
|
660
649
|
spawnProcess: options.spawnProcess,
|
|
661
650
|
limits: {
|
|
662
|
-
wallTimeMs: options.timeoutMs
|
|
663
|
-
jsonlLineBytes: limits.jsonlLineBytes,
|
|
664
|
-
traceBytes: limits.traceBytes,
|
|
665
|
-
stderrBytes: limits.stderrBytes,
|
|
666
|
-
outputBytes: limits.taskOutputBytes,
|
|
667
|
-
quotaTokens: limits.quotaTokens,
|
|
668
|
-
quotaUsd: limits.quotaUsd,
|
|
651
|
+
...(options.timeoutMs === undefined ? {} : { wallTimeMs: options.timeoutMs }),
|
|
652
|
+
...(limits.jsonlLineBytes === undefined ? {} : { jsonlLineBytes: limits.jsonlLineBytes }),
|
|
653
|
+
...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes }),
|
|
654
|
+
...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes }),
|
|
655
|
+
...(limits.taskOutputBytes === undefined ? {} : { outputBytes: limits.taskOutputBytes }),
|
|
656
|
+
...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens }),
|
|
657
|
+
...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd }),
|
|
669
658
|
killGraceMs: limits.killGraceMs,
|
|
670
659
|
},
|
|
671
|
-
environment: options.toolBudget ? {
|
|
672
|
-
[CHILD_TOOL_BUDGET_ENV]: JSON.stringify(options.toolBudget),
|
|
673
|
-
} : undefined,
|
|
674
660
|
onUpdate: (next) => applyProcessResult(result, next, startedAt, options.onChange),
|
|
675
661
|
});
|
|
676
662
|
options.onHandle?.(handle);
|
|
@@ -980,7 +966,9 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
980
966
|
const from = runtime?.traceCount ?? 0;
|
|
981
967
|
let retainedTraceBytes = thread.trace.reduce((total, entry) => total + Buffer.byteLength(entry.message ?? "", "utf8"), 0);
|
|
982
968
|
for (const entry of next.trace.slice(from)) {
|
|
983
|
-
const retained = truncateUtf8(entry,
|
|
969
|
+
const retained = truncateUtf8(entry, limits.traceBytes === undefined
|
|
970
|
+
? Buffer.byteLength(entry, "utf8")
|
|
971
|
+
: Math.max(0, limits.traceBytes - retainedTraceBytes));
|
|
984
972
|
if (retained.text) {
|
|
985
973
|
threads.trace(threadId, { kind: "child", message: retained.text });
|
|
986
974
|
retainedTraceBytes += Buffer.byteLength(retained.text, "utf8");
|
|
@@ -1235,9 +1223,6 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1235
1223
|
const runtime: ActiveThreadRuntime = { controller, steering: [], restarting: false, traceCount: 0, startedAt: Date.now() };
|
|
1236
1224
|
activeRuntimes.set(threadId, runtime);
|
|
1237
1225
|
const agent = roles.get(input.agent)!;
|
|
1238
|
-
const baseToolBudget: ChildToolBudget | undefined = agent.access === "read"
|
|
1239
|
-
? { soft: limits.readToolBudgetSoft, hard: limits.readToolBudgetHard, block: [...READ_TOOLS] }
|
|
1240
|
-
: undefined;
|
|
1241
1226
|
const queuedSteering = initialThread.steering.map((entry) => entry.message);
|
|
1242
1227
|
let currentTask = queuedSteering.length ? buildSteeredTask(task, queuedSteering, undefined, limits.taskCharacters) : task;
|
|
1243
1228
|
const stopForBudget = (reason: string, message: string): void => {
|
|
@@ -1261,49 +1246,38 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1261
1246
|
try {
|
|
1262
1247
|
while (true) {
|
|
1263
1248
|
const aggregate = runtime.aggregate;
|
|
1264
|
-
const
|
|
1249
|
+
const wallTimeMs = agent.timeoutMs ?? limits.wallTimeMs;
|
|
1250
|
+
const remainingWallTimeMs = wallTimeMs === undefined ? undefined : wallTimeMs - (Date.now() - runtime.startedAt);
|
|
1265
1251
|
const usedTraceBytes = aggregate?.traceBytes ?? 0;
|
|
1266
1252
|
const usedStderrBytes = aggregate?.stderrBytes ?? 0;
|
|
1267
1253
|
const usedOutputBytes = aggregate?.outputBytes ?? 0;
|
|
1268
|
-
const usedToolCalls = aggregate?.toolCallCount ?? 0;
|
|
1269
1254
|
const usedTokens = aggregate?.usage.totalTokens ?? 0;
|
|
1270
1255
|
const usedCost = aggregate?.usage.cost.total ?? 0;
|
|
1271
|
-
if (remainingWallTimeMs <= 0) {
|
|
1272
|
-
stopForBudget("wall_time_limit", `Child thread exceeds ${
|
|
1256
|
+
if (remainingWallTimeMs !== undefined && remainingWallTimeMs <= 0) {
|
|
1257
|
+
stopForBudget("wall_time_limit", `Child thread exceeds ${wallTimeMs} ms`);
|
|
1273
1258
|
break;
|
|
1274
1259
|
}
|
|
1275
|
-
if (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes) {
|
|
1260
|
+
if (limits.traceBytes !== undefined && (usedTraceBytes >= limits.traceBytes || aggregate?.traceTruncatedBytes)) {
|
|
1276
1261
|
stopForBudget("trace_limit", `Child thread retains more than ${limits.traceBytes} trace bytes`);
|
|
1277
1262
|
break;
|
|
1278
1263
|
}
|
|
1279
|
-
if (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes) {
|
|
1264
|
+
if (limits.stderrBytes !== undefined && (usedStderrBytes >= limits.stderrBytes || aggregate?.stderrTruncatedBytes)) {
|
|
1280
1265
|
stopForBudget("stderr_limit", `Child thread emits more than ${limits.stderrBytes} stderr bytes`);
|
|
1281
1266
|
break;
|
|
1282
1267
|
}
|
|
1283
|
-
if (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes) {
|
|
1268
|
+
if (limits.taskOutputBytes !== undefined && (usedOutputBytes >= limits.taskOutputBytes || aggregate?.outputTruncatedBytes)) {
|
|
1284
1269
|
stopForBudget("output_limit", `Child thread emits more than ${limits.taskOutputBytes} output bytes`);
|
|
1285
1270
|
break;
|
|
1286
1271
|
}
|
|
1287
|
-
if (
|
|
1288
|
-
stopForBudget("tool_call_limit", `Child thread exceeds ${baseToolBudget.hard} tool calls`);
|
|
1289
|
-
break;
|
|
1290
|
-
}
|
|
1291
|
-
if (usedTokens >= limits.quotaTokens) {
|
|
1272
|
+
if (limits.quotaTokens !== undefined && usedTokens >= limits.quotaTokens) {
|
|
1292
1273
|
stopForBudget("quota_tokens", `Child thread exceeds ${limits.quotaTokens} tokens`);
|
|
1293
1274
|
break;
|
|
1294
1275
|
}
|
|
1295
|
-
if (usedCost >= limits.quotaUsd) {
|
|
1276
|
+
if (limits.quotaUsd !== undefined && usedCost >= limits.quotaUsd) {
|
|
1296
1277
|
stopForBudget("quota_cost", `Child thread exceeds $${limits.quotaUsd}`);
|
|
1297
1278
|
break;
|
|
1298
1279
|
}
|
|
1299
1280
|
runtime.traceCount = 0;
|
|
1300
|
-
const toolBudget = baseToolBudget
|
|
1301
|
-
? {
|
|
1302
|
-
...baseToolBudget,
|
|
1303
|
-
soft: Math.max(1, (baseToolBudget.soft ?? baseToolBudget.hard) - usedToolCalls),
|
|
1304
|
-
hard: baseToolBudget.hard - usedToolCalls,
|
|
1305
|
-
}
|
|
1306
|
-
: undefined;
|
|
1307
1281
|
const next = await runTask({
|
|
1308
1282
|
cwd: ctx.cwd,
|
|
1309
1283
|
agent: roles.get(input.agent)!,
|
|
@@ -1317,15 +1291,14 @@ export function registerSubagentTool(pi: ExtensionAPI, options: SubagentRuntimeO
|
|
|
1317
1291
|
spawnProcess,
|
|
1318
1292
|
limits: {
|
|
1319
1293
|
...limits,
|
|
1320
|
-
traceBytes: limits.traceBytes - usedTraceBytes,
|
|
1321
|
-
stderrBytes: limits.stderrBytes - usedStderrBytes,
|
|
1322
|
-
taskOutputBytes: limits.taskOutputBytes - usedOutputBytes,
|
|
1323
|
-
quotaTokens: limits.quotaTokens - usedTokens,
|
|
1324
|
-
quotaUsd: limits.quotaUsd - usedCost,
|
|
1294
|
+
...(limits.traceBytes === undefined ? {} : { traceBytes: limits.traceBytes - usedTraceBytes }),
|
|
1295
|
+
...(limits.stderrBytes === undefined ? {} : { stderrBytes: limits.stderrBytes - usedStderrBytes }),
|
|
1296
|
+
...(limits.taskOutputBytes === undefined ? {} : { taskOutputBytes: limits.taskOutputBytes - usedOutputBytes }),
|
|
1297
|
+
...(limits.quotaTokens === undefined ? {} : { quotaTokens: limits.quotaTokens - usedTokens }),
|
|
1298
|
+
...(limits.quotaUsd === undefined ? {} : { quotaUsd: limits.quotaUsd - usedCost }),
|
|
1325
1299
|
},
|
|
1326
1300
|
timeoutMs: remainingWallTimeMs,
|
|
1327
1301
|
onHandle: (handle) => { runtime.handle = handle; },
|
|
1328
|
-
toolBudget,
|
|
1329
1302
|
onChange: (changed) => {
|
|
1330
1303
|
results[index] = syncThread(threadId, changed, runtime);
|
|
1331
1304
|
emit();
|
package/subagent-budget.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
export const CHILD_TOOL_BUDGET_ENV = "PI_KILLEROS_TOOL_BUDGET";
|
|
4
|
-
|
|
5
|
-
export interface ChildToolBudget {
|
|
6
|
-
soft?: number;
|
|
7
|
-
hard: number;
|
|
8
|
-
block: "*" | string[];
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function parseChildToolBudget(value: string | undefined): ChildToolBudget | undefined {
|
|
12
|
-
if (!value?.trim()) return undefined;
|
|
13
|
-
try {
|
|
14
|
-
const parsed = JSON.parse(value) as Record<string, unknown>;
|
|
15
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
16
|
-
const hard = parsed.hard;
|
|
17
|
-
const soft = parsed.soft;
|
|
18
|
-
const block = parsed.block;
|
|
19
|
-
if (typeof hard !== "number" || !Number.isSafeInteger(hard) || hard < 1) return undefined;
|
|
20
|
-
if (soft !== undefined && (typeof soft !== "number" || !Number.isSafeInteger(soft) || soft < 1 || soft > hard)) return undefined;
|
|
21
|
-
if (block !== "*" && (!Array.isArray(block) || block.length === 0 || block.some((tool) => typeof tool !== "string" || !tool.trim()))) return undefined;
|
|
22
|
-
return {
|
|
23
|
-
hard,
|
|
24
|
-
...(soft === undefined ? {} : { soft }),
|
|
25
|
-
block: block === "*" ? "*" : [...new Set((block as string[]).map((tool) => tool.trim()))],
|
|
26
|
-
};
|
|
27
|
-
} catch {
|
|
28
|
-
return undefined;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function softNudge(budget: ChildToolBudget, calls: number): string {
|
|
33
|
-
return `Tool budget soft limit reached after ${calls} tool call${calls === 1 ? "" : "s"} (soft ${budget.soft}, hard ${budget.hard}). Stop starting new browsing/search work and finalize from the context you already have.`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function shouldBlock(budget: ChildToolBudget, toolName: string, calls: number): boolean {
|
|
37
|
-
return calls > budget.hard && (budget.block === "*" || budget.block.includes(toolName));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export default function registerSubagentBudget(pi: ExtensionAPI): void {
|
|
41
|
-
const budget = parseChildToolBudget(process.env[CHILD_TOOL_BUDGET_ENV]);
|
|
42
|
-
if (!budget) return;
|
|
43
|
-
let calls = 0;
|
|
44
|
-
let nudged = false;
|
|
45
|
-
const sendUserMessage = (pi as unknown as {
|
|
46
|
-
sendUserMessage?: (content: string, options: { deliverAs: "steer" }) => unknown;
|
|
47
|
-
}).sendUserMessage;
|
|
48
|
-
|
|
49
|
-
pi.on("tool_call", (event): ToolCallEventResult | void => {
|
|
50
|
-
calls += 1;
|
|
51
|
-
if (!nudged && budget.soft !== undefined && calls >= budget.soft) {
|
|
52
|
-
nudged = true;
|
|
53
|
-
try {
|
|
54
|
-
sendUserMessage?.(softNudge(budget, calls), { deliverAs: "steer" });
|
|
55
|
-
} catch {
|
|
56
|
-
// The hard block below remains authoritative if steering is unavailable.
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
if (!shouldBlock(budget, event.toolName, calls)) return undefined;
|
|
60
|
-
return {
|
|
61
|
-
block: true,
|
|
62
|
-
reason: `Tool budget hard limit reached after ${calls} tool calls (hard ${budget.hard}). Finalize from the context you already have.`,
|
|
63
|
-
};
|
|
64
|
-
});
|
|
65
|
-
}
|