@mystilleef/pi-subagent 0.11.0 → 0.12.0
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/README.md +82 -10
- package/package.json +8 -8
- package/src/agent/agents.ts +44 -3
- package/src/child/process-utils.ts +11 -0
- package/src/child/process.ts +105 -24
- package/src/child/prompt-contract.ts +13 -9
- package/src/shared/limits.ts +81 -0
- package/src/shared/message-utils.ts +56 -0
- package/src/shared/resource-resolution.ts +289 -0
- package/src/shared/utils.ts +31 -214
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ pi -e npm:@mystilleef/pi-subagent
|
|
|
25
25
|
|
|
26
26
|
## Features
|
|
27
27
|
|
|
28
|
-
- **Asynchronous:** Agents run in the background.
|
|
28
|
+
- **Asynchronous:** Agents always run in the background.
|
|
29
29
|
- **Parallel:** Run many agents simultaneously.
|
|
30
30
|
- **Isolated:** Each delegated task receives a separate context window.
|
|
31
31
|
- **Nested:** `Subagents` can spawn other `subagents`.
|
|
@@ -108,9 +108,54 @@ YAML `frontmatter` and a Markdown system prompt body.
|
|
|
108
108
|
|
|
109
109
|
### Discovery locations
|
|
110
110
|
|
|
111
|
-
- User-global agents: `~/.pi/agents/*.md`
|
|
111
|
+
- User-global agents: `~/.pi/agent/agents/*.md`
|
|
112
112
|
- Project-local agents: nearest `.pi/agents/*.md`
|
|
113
113
|
|
|
114
|
+
### Example of an agent file
|
|
115
|
+
|
|
116
|
+
**PATH:** _~/.pi/agent/agents/lifehacks.md_
|
|
117
|
+
|
|
118
|
+
<!-- prettier-ignore-start -->
|
|
119
|
+
```md
|
|
120
|
+
---
|
|
121
|
+
name: lifehacks
|
|
122
|
+
description: Daily lifehacks
|
|
123
|
+
replace_prompt: true
|
|
124
|
+
context: false
|
|
125
|
+
thinking: xhigh
|
|
126
|
+
skills: false
|
|
127
|
+
extensions: pi-mcp-adapter
|
|
128
|
+
tools: read, grep, find, ls, mcp
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
# Role
|
|
132
|
+
|
|
133
|
+
Embody an expert philosopher and life coach. Your specialize in
|
|
134
|
+
_lifehacks_.
|
|
135
|
+
|
|
136
|
+
## Workflow
|
|
137
|
+
|
|
138
|
+
- Research and deliver 3 _profound_ and _transformative lifehacks_.
|
|
139
|
+
- Expound upon each of them.
|
|
140
|
+
- Emit unmodified result to the calling agent.
|
|
141
|
+
|
|
142
|
+
## Directives
|
|
143
|
+
|
|
144
|
+
- Forbid `copular` verb forms.
|
|
145
|
+
- **Minimum** words. **Maximum** signal.
|
|
146
|
+
- Keep prose vivid but terse.
|
|
147
|
+
- Optimize prose for token and context efficiency.
|
|
148
|
+
- Use lists and sub-lists over paragraphs and long sentences.
|
|
149
|
+
- Use elegant, well-structured, idiomatic markdown.
|
|
150
|
+
|
|
151
|
+
## Constraints
|
|
152
|
+
|
|
153
|
+
- Operate in read-only mode.
|
|
154
|
+
- Forbid all write operations.
|
|
155
|
+
- **NEVER** wrap the entire result in a code block.
|
|
156
|
+
```
|
|
157
|
+
<!-- prettier-ignore-end -->
|
|
158
|
+
|
|
114
159
|
### Required front matter
|
|
115
160
|
|
|
116
161
|
```yaml
|
|
@@ -123,21 +168,48 @@ description: Review code for correctness and maintainability.
|
|
|
123
168
|
```yaml
|
|
124
169
|
tools: read, bash, edit
|
|
125
170
|
skills: code-review
|
|
171
|
+
extensions: git-summary
|
|
172
|
+
context: false
|
|
126
173
|
thinking: medium
|
|
127
174
|
provider: deepseek
|
|
128
175
|
model: deepseek-v4-flash
|
|
129
176
|
temperature: 0.7
|
|
130
177
|
top_p: 0.9
|
|
178
|
+
replace_prompt: true
|
|
131
179
|
```
|
|
132
180
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
- `
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
- `
|
|
139
|
-
|
|
140
|
-
|
|
181
|
+
- `tools`: Specifies a comma-separated list of enabled tools. Omission
|
|
182
|
+
defaults to the child process default `toolset`.
|
|
183
|
+
- `skills`: Specifies a comma-separated list of enabled skills. Setting
|
|
184
|
+
`false` disables all skills. Omission inherits the active workspace
|
|
185
|
+
skills.
|
|
186
|
+
- `extensions`: Specifies a comma-separated list of enabled extensions.
|
|
187
|
+
Setting `false` disables all extensions except core subagent
|
|
188
|
+
extensions. Omission inherits the active workspace extensions.
|
|
189
|
+
- `context`: Controls the inclusion of project context files,
|
|
190
|
+
`AGENTS.md`. Setting `false` excludes default workspace files from the
|
|
191
|
+
child context window.
|
|
192
|
+
- `thinking`: Controls the model thinking level (values: `off`,
|
|
193
|
+
`minimal`, `low`, `medium`, `high`, `xhigh`). The child runner clamps
|
|
194
|
+
unsupported levels to supported values and prints warnings.
|
|
195
|
+
- `provider`: Specifies the model provider. Requires setting the `model`
|
|
196
|
+
field. Omission of the `model` field when defining a `provider`
|
|
197
|
+
invalidates the agent configuration.
|
|
198
|
+
- `model`: Specifies the model identifier. Agent-level model settings
|
|
199
|
+
override parent settings.
|
|
200
|
+
- `temperature`: Sets the sampling temperature. Accepts numeric values
|
|
201
|
+
between `0.0` and `1.0` inclusive. Incorrect values trigger warnings
|
|
202
|
+
during discovery and the system ignores them.
|
|
203
|
+
- `top_p`: Sets the sampling top-p value. Accepts numeric values between
|
|
204
|
+
`0.0` and `1.0` inclusive. Incorrect values trigger warnings during
|
|
205
|
+
discovery and the system ignores them.
|
|
206
|
+
- `replace_prompt`: Set `true` to replace the child system prompt base
|
|
207
|
+
with the agent body. Omitted or `false` appends the body to the
|
|
208
|
+
existing system prompt, `SYSTEM.md`, preserving current behavior.
|
|
209
|
+
Requires a non-empty agent body; `replace_prompt: true` with an empty
|
|
210
|
+
or whitespace-only body fails discovery. **Warning:** `true` replaces
|
|
211
|
+
pi built-in defaults and any project/global `SYSTEM.md`, including
|
|
212
|
+
their safety and behavior guardrails.
|
|
141
213
|
|
|
142
214
|
---
|
|
143
215
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mystilleef/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Pi subagent for the SPAE Framework",
|
|
5
5
|
"author": "Lateef Alabi-Oki <mystilleef@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -64,14 +64,14 @@
|
|
|
64
64
|
"typebox": "*"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@biomejs/biome": "^2.5.
|
|
68
|
-
"@earendil-works/pi-agent-core": "^0.80.
|
|
69
|
-
"@earendil-works/pi-ai": "^0.80.
|
|
70
|
-
"@earendil-works/pi-coding-agent": "^0.80.
|
|
71
|
-
"@earendil-works/pi-tui": "^0.80.
|
|
67
|
+
"@biomejs/biome": "^2.5.2",
|
|
68
|
+
"@earendil-works/pi-agent-core": "^0.80.3",
|
|
69
|
+
"@earendil-works/pi-ai": "^0.80.3",
|
|
70
|
+
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
71
|
+
"@earendil-works/pi-tui": "^0.80.3",
|
|
72
72
|
"@types/bun": "^1.3.14",
|
|
73
|
-
"@types/node": "^26.0
|
|
74
|
-
"typebox": "^1.3.
|
|
73
|
+
"@types/node": "^26.1.0",
|
|
74
|
+
"typebox": "^1.3.4",
|
|
75
75
|
"typescript": "^6.0.3"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/src/agent/agents.ts
CHANGED
|
@@ -21,13 +21,16 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
|
21
21
|
export interface AgentConfig {
|
|
22
22
|
name: string;
|
|
23
23
|
description: string;
|
|
24
|
+
context?: false | undefined;
|
|
24
25
|
tools?: string[] | undefined;
|
|
25
|
-
skills?: string[] | undefined;
|
|
26
|
+
skills?: string[] | false | undefined;
|
|
27
|
+
extensions?: string[] | undefined;
|
|
26
28
|
thinking?: ThinkingLevel | undefined;
|
|
27
29
|
model?: string | undefined;
|
|
28
30
|
provider?: string | undefined;
|
|
29
31
|
temperature?: number | undefined;
|
|
30
32
|
topP?: number | undefined;
|
|
33
|
+
replacePrompt?: true | undefined;
|
|
31
34
|
systemPrompt: string;
|
|
32
35
|
source: AgentSource;
|
|
33
36
|
filePath: string;
|
|
@@ -71,6 +74,21 @@ function parseCommaList(raw: unknown): string[] | undefined {
|
|
|
71
74
|
return items.length > 0 ? items : undefined;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
function parseExtensions(raw: unknown): string[] | undefined {
|
|
78
|
+
if (raw === undefined) return undefined;
|
|
79
|
+
if (raw === false || Array.isArray(raw)) return [];
|
|
80
|
+
if (typeof raw === "string") {
|
|
81
|
+
const trimmed = raw.trim();
|
|
82
|
+
if (trimmed.length === 0) return [];
|
|
83
|
+
const items = trimmed
|
|
84
|
+
.split(",")
|
|
85
|
+
.map((s) => s.trim())
|
|
86
|
+
.filter(Boolean);
|
|
87
|
+
return items.length > 0 ? items : [];
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
function parseThinkingLevel(raw: unknown): ThinkingLevel | undefined {
|
|
75
93
|
if (typeof raw !== "string") return undefined;
|
|
76
94
|
const normalized = raw.trim().toLowerCase();
|
|
@@ -124,29 +142,49 @@ function parseAgentConfig(
|
|
|
124
142
|
const {
|
|
125
143
|
name,
|
|
126
144
|
description,
|
|
145
|
+
context: rawContext,
|
|
127
146
|
tools: rawTools,
|
|
128
147
|
skills: rawSkills,
|
|
148
|
+
extensions: rawExtensions,
|
|
129
149
|
thinking: rawThinking,
|
|
130
150
|
model: rawModel,
|
|
131
151
|
provider: rawProvider,
|
|
132
152
|
temperature: rawTemperature,
|
|
133
153
|
top_p: rawTopP,
|
|
154
|
+
replace_prompt: rawReplacePrompt,
|
|
134
155
|
} = frontmatter;
|
|
135
156
|
if (typeof name !== "string" || typeof description !== "string") return null;
|
|
157
|
+
if (rawContext !== undefined && typeof rawContext !== "boolean") return null;
|
|
158
|
+
if (rawReplacePrompt !== undefined && typeof rawReplacePrompt !== "boolean")
|
|
159
|
+
return null;
|
|
136
160
|
if (isNonStringOptional(rawTools)) return null;
|
|
137
|
-
if (
|
|
161
|
+
if (rawSkills != null && typeof rawSkills !== "string" && rawSkills !== false)
|
|
162
|
+
return null;
|
|
163
|
+
if (
|
|
164
|
+
rawExtensions !== undefined &&
|
|
165
|
+
rawExtensions !== false &&
|
|
166
|
+
typeof rawExtensions !== "string" &&
|
|
167
|
+
!(Array.isArray(rawExtensions) && rawExtensions.length === 0)
|
|
168
|
+
)
|
|
169
|
+
return null;
|
|
138
170
|
if (isNonStringOptional(rawThinking)) return null;
|
|
139
171
|
if (isNonStringOptional(rawModel)) return null;
|
|
140
172
|
if (isNonStringOptional(rawProvider)) return null;
|
|
141
173
|
const tools = parseCommaList(rawTools);
|
|
142
174
|
const skills =
|
|
143
|
-
rawSkills
|
|
175
|
+
rawSkills === false
|
|
176
|
+
? false
|
|
177
|
+
: rawSkills !== undefined
|
|
178
|
+
? (parseCommaList(rawSkills) ?? [])
|
|
179
|
+
: undefined;
|
|
144
180
|
const thinking = parseThinkingLevel(rawThinking);
|
|
145
181
|
const model = parseOptionalString(rawModel);
|
|
146
182
|
const provider = parseOptionalString(rawProvider);
|
|
147
183
|
if (provider !== undefined && model === undefined) return null;
|
|
148
184
|
const temperature = parseSamplingValue(rawTemperature, "temperature", name);
|
|
149
185
|
const topP = parseSamplingValue(rawTopP, "top_p", name);
|
|
186
|
+
const extensions = parseExtensions(rawExtensions);
|
|
187
|
+
if (rawReplacePrompt === true && body.trim().length === 0) return null;
|
|
150
188
|
return {
|
|
151
189
|
name,
|
|
152
190
|
description,
|
|
@@ -158,8 +196,11 @@ function parseAgentConfig(
|
|
|
158
196
|
systemPrompt: body,
|
|
159
197
|
source,
|
|
160
198
|
filePath,
|
|
199
|
+
...(rawContext === false && { context: false }),
|
|
161
200
|
...(temperature !== undefined && { temperature }),
|
|
162
201
|
...(topP !== undefined && { topP }),
|
|
202
|
+
...(extensions !== undefined && { extensions }),
|
|
203
|
+
...(rawReplacePrompt === true && { replacePrompt: true }),
|
|
163
204
|
};
|
|
164
205
|
}
|
|
165
206
|
|
|
@@ -43,6 +43,17 @@ export function resolveSamplingExtensionPath(dir?: string): string {
|
|
|
43
43
|
return resolveExtensionPath("sampling-extension", dir);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Resolves the absolute path to the pi-subagent package entry extension file.
|
|
48
|
+
* This is the main index that registers the subagent tool and commands.
|
|
49
|
+
* Prefers the extension matching the current runtime, then falls back to the
|
|
50
|
+
* alternate extension. Throws if neither exists.
|
|
51
|
+
* Pass `dir` in tests to use a controlled directory.
|
|
52
|
+
*/
|
|
53
|
+
export function resolvePackageExtensionPath(dir?: string): string {
|
|
54
|
+
return resolveExtensionPath("../index", dir);
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
/**
|
|
47
58
|
* Appends data to a string while enforcing a maximum byte limit.
|
|
48
59
|
* Handles both string and Buffer inputs, truncating at valid UTF-8 boundaries.
|
package/src/child/process.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
getPiInvocation,
|
|
22
22
|
getSubagentDepth,
|
|
23
23
|
getSubagentRuntimeLimits,
|
|
24
|
+
resolveAgentExtensionPaths,
|
|
24
25
|
resolveAgentSkillArgs,
|
|
25
26
|
subagentDepthEnv,
|
|
26
27
|
} from "../shared/utils.js";
|
|
@@ -40,9 +41,10 @@ import {
|
|
|
40
41
|
import {
|
|
41
42
|
appendWithByteLimit,
|
|
42
43
|
resolveCompleteExtensionPath,
|
|
44
|
+
resolvePackageExtensionPath,
|
|
43
45
|
resolveSamplingExtensionPath,
|
|
44
46
|
} from "./process-utils.js";
|
|
45
|
-
import {
|
|
47
|
+
import { SUBAGENT_RESULT_CONTRACT } from "./prompt-contract.js";
|
|
46
48
|
import {
|
|
47
49
|
beginPromptSetup,
|
|
48
50
|
cleanupPromptSetupResult,
|
|
@@ -69,6 +71,7 @@ import {
|
|
|
69
71
|
|
|
70
72
|
const COMPLETE_EXTENSION_PATH = resolveCompleteExtensionPath();
|
|
71
73
|
const SAMPLING_EXTENSION_PATH = resolveSamplingExtensionPath();
|
|
74
|
+
const PACKAGE_EXTENSION_PATH = resolvePackageExtensionPath();
|
|
72
75
|
|
|
73
76
|
export { resolveThinkingLevel } from "./model-resolution.js";
|
|
74
77
|
export { makeEmitUpdate } from "./streaming-progress.js";
|
|
@@ -365,15 +368,40 @@ function buildSamplingEnv(agent: AgentConfig): string | undefined {
|
|
|
365
368
|
});
|
|
366
369
|
}
|
|
367
370
|
|
|
368
|
-
|
|
369
|
-
agent: AgentConfig
|
|
370
|
-
task: string
|
|
371
|
-
effectiveModel: ChildModelSettings
|
|
372
|
-
thinking: ThinkingLevel
|
|
373
|
-
resolvedSkills: { args: string[] }
|
|
374
|
-
tmpPrompt: { filePath: string } | null
|
|
375
|
-
|
|
376
|
-
|
|
371
|
+
export interface BuildPiArgsConfig {
|
|
372
|
+
agent: AgentConfig;
|
|
373
|
+
task: string;
|
|
374
|
+
effectiveModel: ChildModelSettings;
|
|
375
|
+
thinking: ThinkingLevel;
|
|
376
|
+
resolvedSkills: { args: string[] };
|
|
377
|
+
tmpPrompt: { filePath: string } | null;
|
|
378
|
+
resolvedExtensionPaths?: string[] | undefined;
|
|
379
|
+
samplingEnv?: string | undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function buildPiArgs(config: BuildPiArgsConfig): string[] {
|
|
383
|
+
const {
|
|
384
|
+
agent,
|
|
385
|
+
task,
|
|
386
|
+
effectiveModel,
|
|
387
|
+
thinking,
|
|
388
|
+
resolvedSkills,
|
|
389
|
+
tmpPrompt,
|
|
390
|
+
resolvedExtensionPaths,
|
|
391
|
+
samplingEnv,
|
|
392
|
+
} = config;
|
|
393
|
+
const args: string[] = [
|
|
394
|
+
"--mode",
|
|
395
|
+
"json",
|
|
396
|
+
"-p",
|
|
397
|
+
"--no-session",
|
|
398
|
+
"--approve",
|
|
399
|
+
"--no-themes",
|
|
400
|
+
"--no-prompt-templates",
|
|
401
|
+
];
|
|
402
|
+
if (agent.extensions !== undefined) {
|
|
403
|
+
args.push("--no-extensions");
|
|
404
|
+
}
|
|
377
405
|
if (effectiveModel.provider && effectiveModel.id)
|
|
378
406
|
args.push("--provider", effectiveModel.provider);
|
|
379
407
|
if (effectiveModel.id) args.push("--model", effectiveModel.id);
|
|
@@ -383,15 +411,35 @@ function buildPiArgs(
|
|
|
383
411
|
tools.add("complete");
|
|
384
412
|
args.push("--tools", [...tools].join(","));
|
|
385
413
|
}
|
|
386
|
-
if (agent.skills
|
|
414
|
+
if (agent.skills !== undefined)
|
|
415
|
+
args.push("--no-skills", ...resolvedSkills.args);
|
|
416
|
+
if (agent.context === false) args.push("--no-context-files");
|
|
387
417
|
if (tmpPrompt) {
|
|
388
|
-
|
|
418
|
+
if (agent.replacePrompt) {
|
|
419
|
+
args.push("--system-prompt", tmpPrompt.filePath);
|
|
420
|
+
} else {
|
|
421
|
+
args.push("--append-system-prompt", tmpPrompt.filePath);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (agent.extensions !== undefined) {
|
|
425
|
+
args.push("--extension", PACKAGE_EXTENSION_PATH);
|
|
426
|
+
if (resolvedExtensionPaths && resolvedExtensionPaths.length > 0) {
|
|
427
|
+
for (const rp of resolvedExtensionPaths) {
|
|
428
|
+
if (rp !== PACKAGE_EXTENSION_PATH) {
|
|
429
|
+
args.push("--extension", rp);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
389
433
|
}
|
|
390
434
|
args.push("--extension", COMPLETE_EXTENSION_PATH);
|
|
435
|
+
if (samplingEnv) {
|
|
436
|
+
args.push("--extension", SAMPLING_EXTENSION_PATH);
|
|
437
|
+
}
|
|
438
|
+
args.push("--append-system-prompt", SUBAGENT_RESULT_CONTRACT);
|
|
391
439
|
const taskPrompt = task
|
|
392
440
|
? `Task: ${task}`
|
|
393
441
|
: "Run according to your system prompt. If no explicit task was provided, use the default context described there.";
|
|
394
|
-
args.push(
|
|
442
|
+
args.push(taskPrompt);
|
|
395
443
|
return args;
|
|
396
444
|
}
|
|
397
445
|
|
|
@@ -548,10 +596,24 @@ export async function runSingleAgent(
|
|
|
548
596
|
agent.skills
|
|
549
597
|
? resolveAgentSkillArgs(defaultCwd, agent.skills)
|
|
550
598
|
: Promise.resolve({ args: [] });
|
|
599
|
+
const extensionNames =
|
|
600
|
+
agent.extensions && agent.extensions.length > 0
|
|
601
|
+
? agent.extensions
|
|
602
|
+
: undefined;
|
|
603
|
+
const resolvedExtensionsPromise: Promise<
|
|
604
|
+
{ resolvedPaths: string[] } | { error: string }
|
|
605
|
+
> = extensionNames
|
|
606
|
+
? resolveAgentExtensionPaths(defaultCwd, extensionNames)
|
|
607
|
+
: Promise.resolve({ resolvedPaths: [] });
|
|
551
608
|
const promptSetupPromise = beginPromptSetup(agent);
|
|
552
|
-
const resolvedSkills = await
|
|
553
|
-
|
|
554
|
-
|
|
609
|
+
const [resolvedSkills, resolvedExtensions, promptSetup] = await Promise.all([
|
|
610
|
+
resolvedSkillsPromise,
|
|
611
|
+
resolvedExtensionsPromise,
|
|
612
|
+
promptSetupPromise,
|
|
613
|
+
]);
|
|
614
|
+
const abortWithError = async (
|
|
615
|
+
error: string,
|
|
616
|
+
): Promise<RunSingleAgentResult> => {
|
|
555
617
|
await cleanupPromptSetupResult(promptSetup);
|
|
556
618
|
return {
|
|
557
619
|
kind: "completed",
|
|
@@ -559,13 +621,30 @@ export async function runSingleAgent(
|
|
|
559
621
|
agentName,
|
|
560
622
|
agent.source,
|
|
561
623
|
task,
|
|
562
|
-
|
|
624
|
+
error,
|
|
625
|
+
modelDisplay,
|
|
626
|
+
),
|
|
627
|
+
};
|
|
628
|
+
};
|
|
629
|
+
if ("error" in resolvedSkills) return abortWithError(resolvedSkills.error);
|
|
630
|
+
if ("error" in resolvedExtensions)
|
|
631
|
+
return abortWithError(resolvedExtensions.error);
|
|
632
|
+
if ("error" in promptSetup) {
|
|
633
|
+
return {
|
|
634
|
+
kind: "completed",
|
|
635
|
+
result: createErrorResult(
|
|
636
|
+
agentName,
|
|
637
|
+
agent.source,
|
|
638
|
+
task,
|
|
639
|
+
`Failed to write prompt: ${
|
|
640
|
+
promptSetup.error instanceof Error
|
|
641
|
+
? promptSetup.error.message
|
|
642
|
+
: String(promptSetup.error)
|
|
643
|
+
}`,
|
|
563
644
|
modelDisplay,
|
|
564
645
|
),
|
|
565
646
|
};
|
|
566
647
|
}
|
|
567
|
-
const promptSetup = await promptSetupPromise;
|
|
568
|
-
if ("error" in promptSetup) throw promptSetup.error;
|
|
569
648
|
const startedAt = Date.now();
|
|
570
649
|
const state: SubagentState = {
|
|
571
650
|
result: initRuntimeResult(agentName, agent.source, task, modelDisplay),
|
|
@@ -576,17 +655,19 @@ export async function runSingleAgent(
|
|
|
576
655
|
const tmpPrompt = promptSetup.tmpPrompt;
|
|
577
656
|
const samplingEnv = buildSamplingEnv(agent);
|
|
578
657
|
try {
|
|
579
|
-
const args = buildPiArgs(
|
|
658
|
+
const args = buildPiArgs({
|
|
580
659
|
agent,
|
|
581
660
|
task,
|
|
582
661
|
effectiveModel,
|
|
583
662
|
thinking,
|
|
584
663
|
resolvedSkills,
|
|
585
664
|
tmpPrompt,
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
665
|
+
resolvedExtensionPaths:
|
|
666
|
+
agent.extensions !== undefined
|
|
667
|
+
? resolvedExtensions.resolvedPaths
|
|
668
|
+
: undefined,
|
|
669
|
+
samplingEnv,
|
|
670
|
+
});
|
|
590
671
|
const invocation = getPiInvocation(args);
|
|
591
672
|
const terminateOptions = {
|
|
592
673
|
tree: true,
|
|
@@ -5,16 +5,20 @@ export const SUBAGENT_RESULT_CONTRACT = `
|
|
|
5
5
|
|
|
6
6
|
### Directives
|
|
7
7
|
|
|
8
|
-
- Write the complete result as
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
- Write the complete task result as assistant text — mandatory, non-empty, and trimmed,
|
|
9
|
+
before calling \`complete\`.
|
|
10
|
+
- Call \`complete\` as the final action after the result text; \`outcome\` must be a short,
|
|
11
|
+
one-sentence progress summary only, not the full result.
|
|
12
|
+
- This contract applies after any amount of tool use, reading, editing, or multi-step work.
|
|
11
13
|
|
|
12
14
|
### Constraints
|
|
13
15
|
|
|
14
|
-
- **NEVER** omit the text response.
|
|
15
|
-
- **NEVER**
|
|
16
|
+
- **NEVER** omit the result text response.
|
|
17
|
+
- **NEVER** emit whitespace-only, empty-string, or blank result text before \`complete\`.
|
|
18
|
+
- **NEVER** wrap the entire result in a code block or code fence.
|
|
19
|
+
- **NEVER** end the assistant response with text alone after tool calls; a terminal
|
|
20
|
+
\`complete\` call is required.
|
|
21
|
+
- **NEVER** write assistant text after \`complete\`.
|
|
22
|
+
- Call \`complete\` exactly once; multiple \`complete\` calls are not allowed.
|
|
23
|
+
- \`outcome\` must be concise and contain only a progress summary.
|
|
16
24
|
`;
|
|
17
|
-
|
|
18
|
-
export function appendSubagentResultContract(prompt: string): string {
|
|
19
|
-
return `${prompt}\n\n${SUBAGENT_RESULT_CONTRACT}`;
|
|
20
|
-
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output and runtime limit configuration for subagent processes.
|
|
3
|
+
* Handles byte/line caps for child output and runtime safety thresholds.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_MAX_OUTPUT_BYTES = 50_000;
|
|
7
|
+
export const DEFAULT_MAX_OUTPUT_LINES = 500;
|
|
8
|
+
export const DEFAULT_AGENT_END_GRACE_MS = 250;
|
|
9
|
+
export const DEFAULT_MAX_STDERR_BYTES = 10_000;
|
|
10
|
+
export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
|
|
11
|
+
const MAX_SUBAGENT_DEPTH_CEILING = 10;
|
|
12
|
+
|
|
13
|
+
export interface SubagentOutputLimits {
|
|
14
|
+
maxBytes: number;
|
|
15
|
+
maxLines: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SubagentRuntimeLimits {
|
|
19
|
+
agentEndGraceMs: number;
|
|
20
|
+
maxStderrBytes: number;
|
|
21
|
+
maxDepth: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type EnvLimitConfig = Partial<Record<string, string | number | undefined>>;
|
|
25
|
+
|
|
26
|
+
function parsePositiveInteger(
|
|
27
|
+
value: string | number | undefined,
|
|
28
|
+
): number | undefined {
|
|
29
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
30
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1)
|
|
31
|
+
return undefined;
|
|
32
|
+
return parsed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getSubagentOutputLimits(
|
|
36
|
+
config: EnvLimitConfig = process.env,
|
|
37
|
+
): SubagentOutputLimits {
|
|
38
|
+
return {
|
|
39
|
+
maxBytes:
|
|
40
|
+
parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_BYTES"]) ??
|
|
41
|
+
DEFAULT_MAX_OUTPUT_BYTES,
|
|
42
|
+
maxLines:
|
|
43
|
+
parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_LINES"]) ??
|
|
44
|
+
DEFAULT_MAX_OUTPUT_LINES,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getSubagentRuntimeLimits(
|
|
49
|
+
config: EnvLimitConfig = process.env,
|
|
50
|
+
): SubagentRuntimeLimits {
|
|
51
|
+
const maxDepth =
|
|
52
|
+
parsePositiveInteger(config["PI_SUBAGENT_MAX_DEPTH"]) ??
|
|
53
|
+
DEFAULT_MAX_SUBAGENT_DEPTH;
|
|
54
|
+
return {
|
|
55
|
+
agentEndGraceMs:
|
|
56
|
+
parsePositiveInteger(config["PI_SUBAGENT_AGENT_END_GRACE_MS"]) ??
|
|
57
|
+
DEFAULT_AGENT_END_GRACE_MS,
|
|
58
|
+
maxStderrBytes:
|
|
59
|
+
parsePositiveInteger(config["PI_SUBAGENT_MAX_STDERR_BYTES"]) ??
|
|
60
|
+
DEFAULT_MAX_STDERR_BYTES,
|
|
61
|
+
maxDepth: Math.min(maxDepth, MAX_SUBAGENT_DEPTH_CEILING),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function truncateOutput(
|
|
66
|
+
text: string,
|
|
67
|
+
limits: SubagentOutputLimits = getSubagentOutputLimits(),
|
|
68
|
+
): string {
|
|
69
|
+
const lines = text.split("\n");
|
|
70
|
+
const maxBytes = Math.max(1, Math.floor(limits.maxBytes));
|
|
71
|
+
const maxLines = Math.max(1, Math.floor(limits.maxLines));
|
|
72
|
+
if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes)
|
|
73
|
+
return text;
|
|
74
|
+
let result = lines.slice(0, maxLines).join("\n");
|
|
75
|
+
if (Buffer.byteLength(result, "utf-8") > maxBytes) {
|
|
76
|
+
const buf = Buffer.from(result).subarray(0, maxBytes);
|
|
77
|
+
result = buf.toString("utf-8").replace(/\uFFFD$/, "");
|
|
78
|
+
}
|
|
79
|
+
const kept = result.split("\n").length;
|
|
80
|
+
return `[TRUNCATED: first ${kept} of ${lines.length} lines]\n${result}`;
|
|
81
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message processing utilities for subagent results.
|
|
3
|
+
* Handles assistant message extraction, error detection, and failure判定.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { SingleResult } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export function findLastAssistantTextMessage(messages: Message[]): number {
|
|
10
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
11
|
+
const msg = messages[i];
|
|
12
|
+
if (
|
|
13
|
+
msg?.role === "assistant" &&
|
|
14
|
+
Array.isArray(msg.content) &&
|
|
15
|
+
msg.content.some(
|
|
16
|
+
(c) =>
|
|
17
|
+
c.type === "text" &&
|
|
18
|
+
typeof c.text === "string" &&
|
|
19
|
+
c.text.trim().length > 0,
|
|
20
|
+
)
|
|
21
|
+
) {
|
|
22
|
+
return i;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function extractFinalOutputFromMessages(messages: Message[]): string {
|
|
29
|
+
const lastAsstIdx = findLastAssistantTextMessage(messages);
|
|
30
|
+
if (lastAsstIdx < 0) return "";
|
|
31
|
+
const content = messages[lastAsstIdx]?.content;
|
|
32
|
+
if (!Array.isArray(content)) return "";
|
|
33
|
+
const lastText = content.findLast((p) => p.type === "text");
|
|
34
|
+
return lastText?.type === "text" ? (lastText.text ?? "") : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function detectMessageError(messages: Message[]): boolean {
|
|
38
|
+
const lastAssistantIdx = findLastAssistantTextMessage(messages);
|
|
39
|
+
const from = lastAssistantIdx >= 0 ? lastAssistantIdx + 1 : 0;
|
|
40
|
+
for (let i = messages.length - 1; i >= from; i--) {
|
|
41
|
+
const msg = messages[i];
|
|
42
|
+
if (msg?.role === "toolResult" && msg.isError) return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function hasSubagentFailed(result: SingleResult): boolean {
|
|
48
|
+
if (result.outcome?.trim()) return false;
|
|
49
|
+
return (
|
|
50
|
+
result.exitCode !== 0 ||
|
|
51
|
+
result.stopReason === "error" ||
|
|
52
|
+
result.stopReason === "aborted" ||
|
|
53
|
+
Boolean(result.errorMessage?.trim()) ||
|
|
54
|
+
detectMessageError(result.messages ?? [])
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic resource resolution for skills and extensions.
|
|
3
|
+
* Handles caching, discovery via DefaultResourceLoader, and name-to-path mapping.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
DefaultResourceLoader,
|
|
10
|
+
getAgentDir,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
const RESOURCE_DISCOVERY_CACHE_TTL_MS = 300_000;
|
|
14
|
+
|
|
15
|
+
export const EXTENSION_DISCOVERY_CACHE_TTL_MS = RESOURCE_DISCOVERY_CACHE_TTL_MS;
|
|
16
|
+
|
|
17
|
+
type ResourceCacheEntry<T> = {
|
|
18
|
+
data: T;
|
|
19
|
+
ts: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
class ResourceCache<T> {
|
|
23
|
+
private store = new Map<string, ResourceCacheEntry<T>>();
|
|
24
|
+
|
|
25
|
+
get(key: string): T | undefined {
|
|
26
|
+
const entry = this.store.get(key);
|
|
27
|
+
if (entry && Date.now() - entry.ts <= RESOURCE_DISCOVERY_CACHE_TTL_MS) {
|
|
28
|
+
return entry.data;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(key: string, data: T): void {
|
|
34
|
+
this.store.set(key, { data, ts: Date.now() });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clear(): void {
|
|
38
|
+
this.store.clear();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const skillArgsCache = new ResourceCache<Map<string, string>>();
|
|
43
|
+
const extensionPathsCache = new ResourceCache<Map<string, string>>();
|
|
44
|
+
|
|
45
|
+
export function resetResolvedAgentSkillArgsCache(): void {
|
|
46
|
+
skillArgsCache.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resetResolvedAgentExtensionPathsCache(): void {
|
|
50
|
+
extensionPathsCache.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function canonicalPath(filePath: string): Promise<string> {
|
|
54
|
+
try {
|
|
55
|
+
return await fs.promises.realpath(filePath);
|
|
56
|
+
} catch {
|
|
57
|
+
/* symlinks or missing paths fall back to absolute path resolution */
|
|
58
|
+
return path.resolve(filePath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function buildResourceCacheKey(
|
|
63
|
+
cwd: string,
|
|
64
|
+
agentDir: string,
|
|
65
|
+
names: string[],
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
const sortedNames = [...names].sort();
|
|
68
|
+
return JSON.stringify({
|
|
69
|
+
cwd: await canonicalPath(cwd),
|
|
70
|
+
agentDir: await canonicalPath(agentDir),
|
|
71
|
+
names: sortedNames,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ResourceResolverResult<T> = { data: T } | { error: string };
|
|
76
|
+
|
|
77
|
+
interface ResourceResolverConfig<TResult> {
|
|
78
|
+
cache: ResourceCache<Map<string, string>>;
|
|
79
|
+
loaderOptions: Record<string, boolean>;
|
|
80
|
+
getResources: (loader: DefaultResourceLoader) => {
|
|
81
|
+
items: unknown[];
|
|
82
|
+
errors: Array<{ path: string; error: string }>;
|
|
83
|
+
};
|
|
84
|
+
buildNameToResource: (items: unknown[]) => Map<string, string>;
|
|
85
|
+
buildResult: (
|
|
86
|
+
requested: string[],
|
|
87
|
+
nameToResource: Map<string, string>,
|
|
88
|
+
) => TResult;
|
|
89
|
+
resourceType: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function resolveResources<TResult>(
|
|
93
|
+
cwd: string,
|
|
94
|
+
names: string[],
|
|
95
|
+
config: ResourceResolverConfig<TResult>,
|
|
96
|
+
): Promise<ResourceResolverResult<TResult>> {
|
|
97
|
+
const requested = Array.from(new Set(names));
|
|
98
|
+
if (requested.length === 0) {
|
|
99
|
+
return { data: config.buildResult(requested, new Map()) };
|
|
100
|
+
}
|
|
101
|
+
const agentDir = getAgentDir();
|
|
102
|
+
const cacheKey = await buildResourceCacheKey(cwd, agentDir, requested);
|
|
103
|
+
const cached = config.cache.get(cacheKey);
|
|
104
|
+
if (cached) {
|
|
105
|
+
return { data: config.buildResult(requested, cached) };
|
|
106
|
+
}
|
|
107
|
+
const loader = new DefaultResourceLoader({
|
|
108
|
+
cwd,
|
|
109
|
+
agentDir,
|
|
110
|
+
...config.loaderOptions,
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
await loader.reload();
|
|
114
|
+
} catch (error) {
|
|
115
|
+
return {
|
|
116
|
+
error: `Failed to discover ${config.resourceType}s: ${error instanceof Error ? error.message : String(error)}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const { items, errors } = config.getResources(loader);
|
|
120
|
+
if (errors.length > 0) {
|
|
121
|
+
const details = errors.map((e) => ` ${e.path}: ${e.error}`).join("\n");
|
|
122
|
+
return {
|
|
123
|
+
error: `Failed to discover ${config.resourceType}s:\n${details}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const nameToResource = config.buildNameToResource(items);
|
|
127
|
+
const missing = requested.filter((name) => !nameToResource.has(name));
|
|
128
|
+
if (missing.length > 0) {
|
|
129
|
+
const available =
|
|
130
|
+
Array.from(nameToResource.keys()).sort().join(", ") || "none";
|
|
131
|
+
return {
|
|
132
|
+
error: `Unknown ${config.resourceType}${missing.length === 1 ? "" : "s"}: ${missing
|
|
133
|
+
.map((name) => `"${name}"`)
|
|
134
|
+
.join(", ")}. Available ${config.resourceType}s: ${available}.`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const result = config.buildResult(requested, nameToResource);
|
|
138
|
+
config.cache.set(cacheKey, nameToResource);
|
|
139
|
+
return { data: result };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildSkillArgs(
|
|
143
|
+
requested: string[],
|
|
144
|
+
skillPaths: Map<string, string>,
|
|
145
|
+
): string[] {
|
|
146
|
+
return requested.flatMap((name) => ["--skill", skillPaths.get(name) ?? name]);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function resolveAgentSkillArgs(
|
|
150
|
+
cwd: string,
|
|
151
|
+
skillNames: string[],
|
|
152
|
+
): Promise<{ args: string[] } | { error: string }> {
|
|
153
|
+
const result = await resolveResources(cwd, skillNames, {
|
|
154
|
+
cache: skillArgsCache,
|
|
155
|
+
loaderOptions: {
|
|
156
|
+
noContextFiles: true,
|
|
157
|
+
noPromptTemplates: true,
|
|
158
|
+
noThemes: true,
|
|
159
|
+
},
|
|
160
|
+
getResources: (loader) => {
|
|
161
|
+
const { skills } = loader.getSkills();
|
|
162
|
+
return {
|
|
163
|
+
items: skills,
|
|
164
|
+
errors: [],
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
buildNameToResource: (items) => {
|
|
168
|
+
const skillMap = new Map<string, string>();
|
|
169
|
+
for (const item of items) {
|
|
170
|
+
const skill = item as { name: string; filePath: string };
|
|
171
|
+
skillMap.set(skill.name, skill.filePath ?? skill.name);
|
|
172
|
+
}
|
|
173
|
+
return skillMap;
|
|
174
|
+
},
|
|
175
|
+
buildResult: (requested, nameToResource) => {
|
|
176
|
+
return buildSkillArgs(requested, nameToResource);
|
|
177
|
+
},
|
|
178
|
+
resourceType: "skill",
|
|
179
|
+
});
|
|
180
|
+
return "error" in result ? result : { args: result.data };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getTerminalPackageName(source: string): string {
|
|
184
|
+
const spec = source.startsWith("npm:") ? source.slice(4) : source;
|
|
185
|
+
let name: string;
|
|
186
|
+
if (spec.startsWith("@")) {
|
|
187
|
+
const slashIdx = spec.indexOf("/");
|
|
188
|
+
if (slashIdx >= 0) {
|
|
189
|
+
name = spec.slice(slashIdx + 1);
|
|
190
|
+
} else {
|
|
191
|
+
name = spec;
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
name = spec;
|
|
195
|
+
}
|
|
196
|
+
const versionIdx = name.indexOf("@");
|
|
197
|
+
if (versionIdx >= 0) {
|
|
198
|
+
return name.slice(0, versionIdx);
|
|
199
|
+
}
|
|
200
|
+
return name;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isLocalPathSpec(source: string): boolean {
|
|
204
|
+
return (
|
|
205
|
+
source.startsWith(".") ||
|
|
206
|
+
source.startsWith("/") ||
|
|
207
|
+
source.startsWith("~") ||
|
|
208
|
+
source.startsWith("file:")
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildExtensionShortName(ext: {
|
|
213
|
+
resolvedPath: string;
|
|
214
|
+
sourceInfo: { source: string; origin: string };
|
|
215
|
+
}): string {
|
|
216
|
+
if (
|
|
217
|
+
ext.sourceInfo.origin === "package" &&
|
|
218
|
+
!isLocalPathSpec(ext.sourceInfo.source)
|
|
219
|
+
) {
|
|
220
|
+
return getTerminalPackageName(ext.sourceInfo.source);
|
|
221
|
+
}
|
|
222
|
+
const fileName = path.basename(ext.resolvedPath);
|
|
223
|
+
if (/^index\.(?:ts|js)$/.test(fileName)) {
|
|
224
|
+
const dirName = path.dirname(ext.resolvedPath);
|
|
225
|
+
const base = path.basename(dirName);
|
|
226
|
+
if (base === "src" || base === "dist") {
|
|
227
|
+
return path.basename(path.dirname(dirName));
|
|
228
|
+
}
|
|
229
|
+
return base;
|
|
230
|
+
}
|
|
231
|
+
const dotIdx = fileName.lastIndexOf(".");
|
|
232
|
+
if (dotIdx > 0) {
|
|
233
|
+
return fileName.slice(0, dotIdx);
|
|
234
|
+
}
|
|
235
|
+
return fileName;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function resolveAgentExtensionPaths(
|
|
239
|
+
cwd: string,
|
|
240
|
+
extensionNames: string[],
|
|
241
|
+
): Promise<{ resolvedPaths: string[] } | { error: string }> {
|
|
242
|
+
const result = await resolveResources(cwd, extensionNames, {
|
|
243
|
+
cache: extensionPathsCache,
|
|
244
|
+
loaderOptions: {
|
|
245
|
+
noContextFiles: true,
|
|
246
|
+
noPromptTemplates: true,
|
|
247
|
+
noThemes: true,
|
|
248
|
+
noSkills: true,
|
|
249
|
+
},
|
|
250
|
+
getResources: (loader) => {
|
|
251
|
+
const { extensions, errors } = loader.getExtensions();
|
|
252
|
+
const shortNameToExtension = new Map<
|
|
253
|
+
string,
|
|
254
|
+
(typeof extensions)[number]
|
|
255
|
+
>();
|
|
256
|
+
for (const ext of extensions) {
|
|
257
|
+
const shortName = buildExtensionShortName(ext);
|
|
258
|
+
if (!shortNameToExtension.has(shortName)) {
|
|
259
|
+
shortNameToExtension.set(shortName, ext);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
items: Array.from(shortNameToExtension.values()),
|
|
264
|
+
errors,
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
buildNameToResource: (items) => {
|
|
268
|
+
const shortNameToPath = new Map<string, string>();
|
|
269
|
+
for (const item of items) {
|
|
270
|
+
const ext = item as {
|
|
271
|
+
resolvedPath: string;
|
|
272
|
+
sourceInfo: { source: string; origin: string };
|
|
273
|
+
};
|
|
274
|
+
const shortName = buildExtensionShortName(ext);
|
|
275
|
+
if (!shortNameToPath.has(shortName)) {
|
|
276
|
+
shortNameToPath.set(shortName, ext.resolvedPath ?? shortName);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return shortNameToPath;
|
|
280
|
+
},
|
|
281
|
+
buildResult: (requested, nameToResource) => {
|
|
282
|
+
return requested
|
|
283
|
+
.map((name) => nameToResource.get(name))
|
|
284
|
+
.filter((p): p is string => typeof p === "string");
|
|
285
|
+
},
|
|
286
|
+
resourceType: "extension",
|
|
287
|
+
});
|
|
288
|
+
return "error" in result ? result : { resolvedPaths: result.data };
|
|
289
|
+
}
|
package/src/shared/utils.ts
CHANGED
|
@@ -1,89 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel export for shared utilities.
|
|
3
|
+
* Re-exports from focused modules; retains standalone utilities here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
1
6
|
import * as fs from "node:fs";
|
|
2
7
|
import * as os from "node:os";
|
|
3
8
|
import * as path from "node:path";
|
|
4
|
-
import type { Message } from "@earendil-works/pi-ai";
|
|
5
|
-
import {
|
|
6
|
-
DefaultResourceLoader,
|
|
7
|
-
getAgentDir,
|
|
8
|
-
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import type { SingleResult } from "./types.js";
|
|
10
|
-
|
|
11
|
-
export const DEFAULT_MAX_OUTPUT_BYTES = 50_000;
|
|
12
|
-
export const DEFAULT_MAX_OUTPUT_LINES = 500;
|
|
13
|
-
export const DEFAULT_AGENT_END_GRACE_MS = 250;
|
|
14
|
-
export const DEFAULT_MAX_STDERR_BYTES = 10_000;
|
|
15
|
-
export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
|
|
16
|
-
const MAX_SUBAGENT_DEPTH_CEILING = 10;
|
|
17
|
-
|
|
18
|
-
export interface SubagentOutputLimits {
|
|
19
|
-
maxBytes: number;
|
|
20
|
-
maxLines: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface SubagentRuntimeLimits {
|
|
24
|
-
agentEndGraceMs: number;
|
|
25
|
-
maxStderrBytes: number;
|
|
26
|
-
maxDepth: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
type EnvLimitConfig = Partial<Record<string, string | number | undefined>>;
|
|
30
|
-
|
|
31
|
-
function parsePositiveInteger(
|
|
32
|
-
value: string | number | undefined,
|
|
33
|
-
): number | undefined {
|
|
34
|
-
const parsed = typeof value === "number" ? value : Number(value);
|
|
35
|
-
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1)
|
|
36
|
-
return undefined;
|
|
37
|
-
return parsed;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function getSubagentOutputLimits(
|
|
41
|
-
config: EnvLimitConfig = process.env,
|
|
42
|
-
): SubagentOutputLimits {
|
|
43
|
-
return {
|
|
44
|
-
maxBytes:
|
|
45
|
-
parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_BYTES"]) ??
|
|
46
|
-
DEFAULT_MAX_OUTPUT_BYTES,
|
|
47
|
-
maxLines:
|
|
48
|
-
parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_LINES"]) ??
|
|
49
|
-
DEFAULT_MAX_OUTPUT_LINES,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
9
|
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
export
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
let result = lines.slice(0, maxLines).join("\n");
|
|
80
|
-
if (Buffer.byteLength(result, "utf-8") > maxBytes) {
|
|
81
|
-
const buf = Buffer.from(result).subarray(0, maxBytes);
|
|
82
|
-
result = buf.toString("utf-8").replace(/\uFFFD$/, "");
|
|
83
|
-
}
|
|
84
|
-
const kept = result.split("\n").length;
|
|
85
|
-
return `[TRUNCATED: first ${kept} of ${lines.length} lines]\n${result}`;
|
|
86
|
-
}
|
|
10
|
+
// Re-export all public symbols from focused modules
|
|
11
|
+
export {
|
|
12
|
+
DEFAULT_AGENT_END_GRACE_MS,
|
|
13
|
+
DEFAULT_MAX_OUTPUT_BYTES,
|
|
14
|
+
DEFAULT_MAX_OUTPUT_LINES,
|
|
15
|
+
DEFAULT_MAX_STDERR_BYTES,
|
|
16
|
+
DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
17
|
+
getSubagentOutputLimits,
|
|
18
|
+
getSubagentRuntimeLimits,
|
|
19
|
+
truncateOutput,
|
|
20
|
+
} from "./limits.js";
|
|
21
|
+
export {
|
|
22
|
+
detectMessageError,
|
|
23
|
+
extractFinalOutputFromMessages,
|
|
24
|
+
findLastAssistantTextMessage,
|
|
25
|
+
hasSubagentFailed,
|
|
26
|
+
} from "./message-utils.js";
|
|
27
|
+
export {
|
|
28
|
+
EXTENSION_DISCOVERY_CACHE_TTL_MS,
|
|
29
|
+
resetResolvedAgentExtensionPathsCache,
|
|
30
|
+
resetResolvedAgentSkillArgsCache,
|
|
31
|
+
resolveAgentExtensionPaths,
|
|
32
|
+
resolveAgentSkillArgs,
|
|
33
|
+
} from "./resource-resolution.js";
|
|
34
|
+
|
|
35
|
+
// Standalone utilities retained in this module
|
|
87
36
|
|
|
88
37
|
export async function writePromptToTempFile(
|
|
89
38
|
agentName: string,
|
|
@@ -118,89 +67,6 @@ export function getPiInvocation(args: string[]): {
|
|
|
118
67
|
return { command: "pi", args };
|
|
119
68
|
}
|
|
120
69
|
|
|
121
|
-
const SKILL_DISCOVERY_CACHE_TTL_MS = 300_000;
|
|
122
|
-
|
|
123
|
-
type ResolvedSkillArgsCacheEntry = {
|
|
124
|
-
skillPaths: Map<string, string>;
|
|
125
|
-
ts: number;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const resolvedSkillArgsCache = new Map<string, ResolvedSkillArgsCacheEntry>();
|
|
129
|
-
|
|
130
|
-
async function canonicalPath(filePath: string): Promise<string> {
|
|
131
|
-
try {
|
|
132
|
-
return await fs.promises.realpath(filePath);
|
|
133
|
-
} catch {
|
|
134
|
-
/* symlinks or missing paths fall back to absolute path resolution */
|
|
135
|
-
return path.resolve(filePath);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function buildSkillArgs(
|
|
140
|
-
requested: string[],
|
|
141
|
-
skillPaths: Map<string, string>,
|
|
142
|
-
): string[] {
|
|
143
|
-
return requested.flatMap((name) => ["--skill", skillPaths.get(name) ?? name]);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function resetResolvedAgentSkillArgsCache(): void {
|
|
147
|
-
resolvedSkillArgsCache.clear();
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export async function resolveAgentSkillArgs(
|
|
151
|
-
cwd: string,
|
|
152
|
-
skillNames: string[],
|
|
153
|
-
): Promise<{ args: string[] } | { error: string }> {
|
|
154
|
-
const requested = Array.from(new Set(skillNames));
|
|
155
|
-
if (requested.length === 0) return { args: [] };
|
|
156
|
-
const cacheIdentitySkills = [...requested].sort();
|
|
157
|
-
const agentDir = getAgentDir();
|
|
158
|
-
const cacheKey = JSON.stringify({
|
|
159
|
-
cwd: await canonicalPath(cwd),
|
|
160
|
-
agentDir: await canonicalPath(agentDir),
|
|
161
|
-
skills: cacheIdentitySkills,
|
|
162
|
-
});
|
|
163
|
-
const cached = resolvedSkillArgsCache.get(cacheKey);
|
|
164
|
-
if (cached && Date.now() - cached.ts <= SKILL_DISCOVERY_CACHE_TTL_MS) {
|
|
165
|
-
return { args: buildSkillArgs(requested, cached.skillPaths) };
|
|
166
|
-
}
|
|
167
|
-
const loader = new DefaultResourceLoader({
|
|
168
|
-
cwd,
|
|
169
|
-
agentDir,
|
|
170
|
-
noContextFiles: true,
|
|
171
|
-
noPromptTemplates: true,
|
|
172
|
-
noThemes: true,
|
|
173
|
-
});
|
|
174
|
-
try {
|
|
175
|
-
await loader.reload();
|
|
176
|
-
} catch (error) {
|
|
177
|
-
return {
|
|
178
|
-
error: `Failed to discover skills: ${error instanceof Error ? error.message : String(error)}`,
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
const { skills } = loader.getSkills();
|
|
182
|
-
const skillMap = new Map(skills.map((skill) => [skill.name, skill]));
|
|
183
|
-
const missing = requested.filter((name) => !skillMap.has(name));
|
|
184
|
-
if (missing.length > 0) {
|
|
185
|
-
const available =
|
|
186
|
-
skills
|
|
187
|
-
.map((skill) => skill.name)
|
|
188
|
-
.sort()
|
|
189
|
-
.join(", ") || "none";
|
|
190
|
-
return {
|
|
191
|
-
error: `Unknown skill${missing.length === 1 ? "" : "s"}: ${missing
|
|
192
|
-
.map((name) => `"${name}"`)
|
|
193
|
-
.join(", ")}. Available skills: ${available}.`,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
const skillPaths = new Map(
|
|
197
|
-
requested.map((name) => [name, skillMap.get(name)?.filePath ?? name]),
|
|
198
|
-
);
|
|
199
|
-
const args = buildSkillArgs(requested, skillPaths);
|
|
200
|
-
resolvedSkillArgsCache.set(cacheKey, { skillPaths, ts: Date.now() });
|
|
201
|
-
return { args };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
70
|
export function getSubagentDepth(): number {
|
|
205
71
|
const d = Number(process.env.PI_SUBAGENT_DEPTH ?? "0");
|
|
206
72
|
if (!Number.isFinite(d) || d < 0) return 0;
|
|
@@ -210,52 +76,3 @@ export function getSubagentDepth(): number {
|
|
|
210
76
|
export function subagentDepthEnv(): Record<string, string> {
|
|
211
77
|
return { PI_SUBAGENT_DEPTH: String(getSubagentDepth() + 1) };
|
|
212
78
|
}
|
|
213
|
-
|
|
214
|
-
export function findLastAssistantTextMessage(messages: Message[]): number {
|
|
215
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
216
|
-
const msg = messages[i];
|
|
217
|
-
if (
|
|
218
|
-
msg?.role === "assistant" &&
|
|
219
|
-
Array.isArray(msg.content) &&
|
|
220
|
-
msg.content.some(
|
|
221
|
-
(c) =>
|
|
222
|
-
c.type === "text" &&
|
|
223
|
-
typeof c.text === "string" &&
|
|
224
|
-
c.text.trim().length > 0,
|
|
225
|
-
)
|
|
226
|
-
) {
|
|
227
|
-
return i;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return -1;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function extractFinalOutputFromMessages(messages: Message[]): string {
|
|
234
|
-
const lastAsstIdx = findLastAssistantTextMessage(messages);
|
|
235
|
-
if (lastAsstIdx < 0) return "";
|
|
236
|
-
const content = messages[lastAsstIdx]?.content;
|
|
237
|
-
if (!Array.isArray(content)) return "";
|
|
238
|
-
const lastText = content.findLast((p) => p.type === "text");
|
|
239
|
-
return lastText?.type === "text" ? (lastText.text ?? "") : "";
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export function detectMessageError(messages: Message[]): boolean {
|
|
243
|
-
const lastAssistantIdx = findLastAssistantTextMessage(messages);
|
|
244
|
-
const from = lastAssistantIdx >= 0 ? lastAssistantIdx + 1 : 0;
|
|
245
|
-
for (let i = messages.length - 1; i >= from; i--) {
|
|
246
|
-
const msg = messages[i];
|
|
247
|
-
if (msg?.role === "toolResult" && msg.isError) return true;
|
|
248
|
-
}
|
|
249
|
-
return false;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export function hasSubagentFailed(result: SingleResult): boolean {
|
|
253
|
-
if (result.outcome?.trim()) return false;
|
|
254
|
-
return (
|
|
255
|
-
result.exitCode !== 0 ||
|
|
256
|
-
result.stopReason === "error" ||
|
|
257
|
-
result.stopReason === "aborted" ||
|
|
258
|
-
Boolean(result.errorMessage?.trim()) ||
|
|
259
|
-
detectMessageError(result.messages ?? [])
|
|
260
|
-
);
|
|
261
|
-
}
|