@mystilleef/pi-subagent 0.10.2 → 0.11.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 +2 -0
- package/package.json +8 -8
- package/src/agent/agents.ts +33 -8
- package/src/child/complete-extension.ts +36 -0
- package/src/child/complete-outcome.ts +35 -0
- package/src/child/model-resolution.ts +81 -0
- package/src/child/process-utils.ts +103 -0
- package/src/child/process.ts +124 -451
- package/src/child/prompt-contract.ts +14 -6
- package/src/child/prompt-setup.ts +36 -0
- package/src/child/result-builder.ts +142 -0
- package/src/child/sampling-extension.ts +49 -0
- package/src/child/streaming-progress.ts +138 -0
- package/src/orchestration/subagent-orchestrator.ts +20 -16
- package/src/output/normalize.ts +1 -1
- package/src/output/summary.ts +22 -6
- package/src/output/ui.ts +19 -21
- package/src/progress/progress-state.ts +10 -12
- package/src/progress/result-details.ts +54 -45
- package/src/shared/sampling.ts +56 -0
- package/src/shared/types.ts +1 -0
- package/src/shared/utils.ts +10 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mystilleef/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.
|
|
69
|
-
"@earendil-works/pi-ai": "^0.
|
|
70
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
71
|
-
"@earendil-works/pi-tui": "^0.
|
|
67
|
+
"@biomejs/biome": "^2.5.1",
|
|
68
|
+
"@earendil-works/pi-agent-core": "^0.80.2",
|
|
69
|
+
"@earendil-works/pi-ai": "^0.80.2",
|
|
70
|
+
"@earendil-works/pi-coding-agent": "^0.80.2",
|
|
71
|
+
"@earendil-works/pi-tui": "^0.80.2",
|
|
72
72
|
"@types/bun": "^1.3.14",
|
|
73
|
-
"@types/node": "^
|
|
74
|
-
"typebox": "^1.
|
|
73
|
+
"@types/node": "^26.0.1",
|
|
74
|
+
"typebox": "^1.3.0",
|
|
75
75
|
"typescript": "^6.0.3"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/src/agent/agents.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Dirent } from "node:fs";
|
|
|
2
2
|
import * as fsPromises from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isValidSamplingValue } from "../shared/sampling.js";
|
|
5
6
|
|
|
6
7
|
export type AgentSource = "user" | "project";
|
|
7
8
|
export type AgentScope = AgentSource | "both";
|
|
@@ -25,6 +26,8 @@ export interface AgentConfig {
|
|
|
25
26
|
thinking?: ThinkingLevel | undefined;
|
|
26
27
|
model?: string | undefined;
|
|
27
28
|
provider?: string | undefined;
|
|
29
|
+
temperature?: number | undefined;
|
|
30
|
+
topP?: number | undefined;
|
|
28
31
|
systemPrompt: string;
|
|
29
32
|
source: AgentSource;
|
|
30
33
|
filePath: string;
|
|
@@ -82,6 +85,23 @@ function parseOptionalString(raw: unknown): string | undefined {
|
|
|
82
85
|
return normalized.length > 0 ? normalized : undefined;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
function isNonStringOptional(raw: unknown): boolean {
|
|
89
|
+
return raw != null && typeof raw !== "string";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseSamplingValue(
|
|
93
|
+
raw: unknown,
|
|
94
|
+
field: string,
|
|
95
|
+
agentName: string,
|
|
96
|
+
): number | undefined {
|
|
97
|
+
if (raw === undefined) return undefined;
|
|
98
|
+
if (isValidSamplingValue(raw)) return raw;
|
|
99
|
+
console.warn(
|
|
100
|
+
`Warning: Agent '${agentName}' has invalid '${field}' value: ${raw}`,
|
|
101
|
+
);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
85
105
|
function parseAgentConfig(
|
|
86
106
|
content: string,
|
|
87
107
|
source: AgentSource,
|
|
@@ -109,21 +129,24 @@ function parseAgentConfig(
|
|
|
109
129
|
thinking: rawThinking,
|
|
110
130
|
model: rawModel,
|
|
111
131
|
provider: rawProvider,
|
|
132
|
+
temperature: rawTemperature,
|
|
133
|
+
top_p: rawTopP,
|
|
112
134
|
} = frontmatter;
|
|
113
135
|
if (typeof name !== "string" || typeof description !== "string") return null;
|
|
114
|
-
if (rawTools
|
|
115
|
-
if (rawSkills
|
|
116
|
-
if (rawThinking
|
|
117
|
-
if (rawModel
|
|
118
|
-
if (rawProvider
|
|
136
|
+
if (isNonStringOptional(rawTools)) return null;
|
|
137
|
+
if (isNonStringOptional(rawSkills)) return null;
|
|
138
|
+
if (isNonStringOptional(rawThinking)) return null;
|
|
139
|
+
if (isNonStringOptional(rawModel)) return null;
|
|
140
|
+
if (isNonStringOptional(rawProvider)) return null;
|
|
119
141
|
const tools = parseCommaList(rawTools);
|
|
120
|
-
const skills =
|
|
121
|
-
? (parseCommaList(rawSkills) ?? [])
|
|
122
|
-
: undefined;
|
|
142
|
+
const skills =
|
|
143
|
+
rawSkills !== undefined ? (parseCommaList(rawSkills) ?? []) : undefined;
|
|
123
144
|
const thinking = parseThinkingLevel(rawThinking);
|
|
124
145
|
const model = parseOptionalString(rawModel);
|
|
125
146
|
const provider = parseOptionalString(rawProvider);
|
|
126
147
|
if (provider !== undefined && model === undefined) return null;
|
|
148
|
+
const temperature = parseSamplingValue(rawTemperature, "temperature", name);
|
|
149
|
+
const topP = parseSamplingValue(rawTopP, "top_p", name);
|
|
127
150
|
return {
|
|
128
151
|
name,
|
|
129
152
|
description,
|
|
@@ -135,6 +158,8 @@ function parseAgentConfig(
|
|
|
135
158
|
systemPrompt: body,
|
|
136
159
|
source,
|
|
137
160
|
filePath,
|
|
161
|
+
...(temperature !== undefined && { temperature }),
|
|
162
|
+
...(topP !== undefined && { topP }),
|
|
138
163
|
};
|
|
139
164
|
}
|
|
140
165
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
export const completeParams = Type.Object({
|
|
5
|
+
outcome: Type.String({
|
|
6
|
+
description:
|
|
7
|
+
"A short, single-sentence summary of the task outcome. Keep it concise, brief, and under 100 characters.",
|
|
8
|
+
minLength: 1,
|
|
9
|
+
pattern: "^[\\s\\S]*\\S[\\s\\S]*$",
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const completeTool = defineTool({
|
|
14
|
+
name: "complete",
|
|
15
|
+
label: "Complete",
|
|
16
|
+
description:
|
|
17
|
+
"Complete the task and report the structured outcome. Call this as your final action.",
|
|
18
|
+
promptSnippet: "Complete the task and report the structured outcome.",
|
|
19
|
+
promptGuidelines: [
|
|
20
|
+
"Call complete as your final action to report the outcome after completing the task.",
|
|
21
|
+
],
|
|
22
|
+
parameters: completeParams,
|
|
23
|
+
async execute(_toolCallId, params) {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: params.outcome }],
|
|
26
|
+
details: {
|
|
27
|
+
outcome: params.outcome,
|
|
28
|
+
},
|
|
29
|
+
terminate: true,
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default function (pi: ExtensionAPI) {
|
|
35
|
+
pi.registerTool(completeTool);
|
|
36
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
import { completeParams } from "./complete-extension.js";
|
|
4
|
+
|
|
5
|
+
export function getOutcomeString(source: unknown): string | undefined {
|
|
6
|
+
if (Value.Check(completeParams, source)) {
|
|
7
|
+
return source.outcome.trim();
|
|
8
|
+
}
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extracts the latest valid outcome from complete tool call arguments in messages.
|
|
14
|
+
*
|
|
15
|
+
* Reads from call arguments (message_end) rather than tool results (tool_result_end)
|
|
16
|
+
* because tool_result_end is unreliable when terminate: true causes pi to exit before
|
|
17
|
+
* delivering it. Call arguments are always delivered via message_end.
|
|
18
|
+
*/
|
|
19
|
+
export function getLatestOutcomeFromMessages(
|
|
20
|
+
messages: Message[] | undefined,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
if (!messages?.length) return undefined;
|
|
23
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
24
|
+
const msg = messages[i];
|
|
25
|
+
if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
26
|
+
for (let j = msg.content.length - 1; j >= 0; j--) {
|
|
27
|
+
const part = msg.content[j];
|
|
28
|
+
if (part?.type !== "toolCall") continue;
|
|
29
|
+
if (part.name !== "complete" || !part.id) continue;
|
|
30
|
+
const outcome = getOutcomeString(part.arguments);
|
|
31
|
+
if (outcome) return outcome;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model and thinking level resolution for subagent child processes.
|
|
3
|
+
* Handles provider/model selection and thinking level clamping.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
clampThinkingLevel,
|
|
8
|
+
getSupportedThinkingLevels,
|
|
9
|
+
type ModelThinkingLevel,
|
|
10
|
+
} from "@earendil-works/pi-ai";
|
|
11
|
+
import { getModel } from "@earendil-works/pi-ai/compat";
|
|
12
|
+
import type { ThinkingLevel } from "../agent/agents.js";
|
|
13
|
+
|
|
14
|
+
export type ChildModelSettings = {
|
|
15
|
+
provider?: string | undefined;
|
|
16
|
+
id?: string | undefined;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolves the effective thinking level for a model, clamping to supported levels.
|
|
21
|
+
* Returns a warning message if the requested level differs from the effective level.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveThinkingLevel(
|
|
24
|
+
requested: ThinkingLevel,
|
|
25
|
+
provider: string,
|
|
26
|
+
modelId: string,
|
|
27
|
+
): { level: ThinkingLevel; warning?: string } {
|
|
28
|
+
const model = getModel(provider as never, modelId as never);
|
|
29
|
+
if (!model) return { level: requested };
|
|
30
|
+
const mkWarning = (effective: ThinkingLevel) =>
|
|
31
|
+
`Thinking level "${requested}" not supported by model "${provider}/${modelId}"; using "${effective}" instead`;
|
|
32
|
+
if (model.reasoning === false) {
|
|
33
|
+
return { level: "off", warning: mkWarning("off") };
|
|
34
|
+
}
|
|
35
|
+
if (!model.thinkingLevelMap) return { level: requested };
|
|
36
|
+
const supported = getSupportedThinkingLevels(model);
|
|
37
|
+
if (supported.length === 0) return { level: requested };
|
|
38
|
+
const clamped = clampThinkingLevel(
|
|
39
|
+
model,
|
|
40
|
+
requested as ModelThinkingLevel,
|
|
41
|
+
) as ThinkingLevel;
|
|
42
|
+
if (clamped === requested) return { level: requested };
|
|
43
|
+
return { level: clamped, warning: mkWarning(clamped) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolves effective child model settings by merging agent config with parent settings.
|
|
48
|
+
* Agent-specific settings take precedence over parent settings.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveEffectiveChildModelSettings(
|
|
51
|
+
agent: { provider?: string | undefined; model?: string | undefined },
|
|
52
|
+
parentModel: ChildModelSettings | undefined,
|
|
53
|
+
): ChildModelSettings {
|
|
54
|
+
return {
|
|
55
|
+
provider: agent.provider ?? parentModel?.provider,
|
|
56
|
+
id:
|
|
57
|
+
agent.model ??
|
|
58
|
+
(agent.provider === undefined ? parentModel?.id : undefined),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Builds a display string for the model and thinking level.
|
|
64
|
+
* Returns undefined if no parts are available.
|
|
65
|
+
*/
|
|
66
|
+
export function buildModelDisplay(
|
|
67
|
+
effectiveModel: ChildModelSettings,
|
|
68
|
+
thinking: ThinkingLevel,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
if (effectiveModel.provider) {
|
|
72
|
+
parts.push(effectiveModel.provider);
|
|
73
|
+
}
|
|
74
|
+
if (effectiveModel.id) {
|
|
75
|
+
parts.push(effectiveModel.id);
|
|
76
|
+
}
|
|
77
|
+
if (thinking) {
|
|
78
|
+
parts.push(thinking);
|
|
79
|
+
}
|
|
80
|
+
return parts.length > 0 ? parts.join(" ・ ") : undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for child process management.
|
|
3
|
+
* Handles byte-limited string appending, UTF-8 truncation, and context window resolution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
10
|
+
import { getModel } from "@earendil-works/pi-ai/compat";
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = path.dirname(__filename);
|
|
14
|
+
|
|
15
|
+
function resolveExtensionPath(baseName: string, dir?: string): string {
|
|
16
|
+
const resolvedDir = dir ?? __dirname;
|
|
17
|
+
const ext = __filename.endsWith(".ts") ? ".ts" : ".js";
|
|
18
|
+
const primary = path.join(resolvedDir, `${baseName}${ext}`);
|
|
19
|
+
if (fs.existsSync(primary)) return primary;
|
|
20
|
+
const altExt = ext === ".ts" ? ".js" : ".ts";
|
|
21
|
+
const fallback = path.join(resolvedDir, `${baseName}${altExt}`);
|
|
22
|
+
if (fs.existsSync(fallback)) return fallback;
|
|
23
|
+
throw new Error(`${baseName} not found at ${primary} or ${fallback}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolves the absolute path to the complete-extension file.
|
|
28
|
+
* Prefers the extension matching the current runtime, then falls back to the
|
|
29
|
+
* alternate extension. Throws if neither exists.
|
|
30
|
+
* Pass `dir` in tests to use a controlled directory.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveCompleteExtensionPath(dir?: string): string {
|
|
33
|
+
return resolveExtensionPath("complete-extension", dir);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolves the absolute path to the sampling-extension file.
|
|
38
|
+
* Prefers the extension matching the current runtime, then falls back to the
|
|
39
|
+
* alternate extension. Throws if neither exists.
|
|
40
|
+
* Pass `dir` in tests to use a controlled directory.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveSamplingExtensionPath(dir?: string): string {
|
|
43
|
+
return resolveExtensionPath("sampling-extension", dir);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Appends data to a string while enforcing a maximum byte limit.
|
|
48
|
+
* Handles both string and Buffer inputs, truncating at valid UTF-8 boundaries.
|
|
49
|
+
*/
|
|
50
|
+
export function appendWithByteLimit(
|
|
51
|
+
current: string,
|
|
52
|
+
data: string | Buffer,
|
|
53
|
+
max: number,
|
|
54
|
+
): string {
|
|
55
|
+
const currentBytes = Buffer.from(current, "utf-8");
|
|
56
|
+
if (currentBytes.length >= max) return current;
|
|
57
|
+
const incomingBytes = Buffer.isBuffer(data)
|
|
58
|
+
? data
|
|
59
|
+
: Buffer.from(data, "utf-8");
|
|
60
|
+
const combined = Buffer.concat([currentBytes, incomingBytes]);
|
|
61
|
+
if (combined.length <= max) return combined.toString("utf-8");
|
|
62
|
+
return truncateValidUtf8(combined, max);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Truncates a buffer to the specified byte limit while preserving valid UTF-8 sequences.
|
|
67
|
+
* Returns an empty string if no valid truncation point is found.
|
|
68
|
+
*/
|
|
69
|
+
export function truncateValidUtf8(buffer: Buffer, max: number): string {
|
|
70
|
+
let end = Math.min(max, buffer.length);
|
|
71
|
+
while (end > 0) {
|
|
72
|
+
const candidate = buffer.subarray(0, end).toString("utf-8");
|
|
73
|
+
if (!candidate.endsWith("�")) return candidate;
|
|
74
|
+
end -= 1;
|
|
75
|
+
}
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolves the context window size in tokens for a given message.
|
|
81
|
+
* Returns undefined if the message doesn't have valid provider/model info
|
|
82
|
+
* or if the model lookup fails.
|
|
83
|
+
*
|
|
84
|
+
* Rationale: Subagent usage reporting needs context window awareness to provide
|
|
85
|
+
* meaningful "context full" indicators to the parent.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveContextWindowTokens(msg: Message): number | undefined {
|
|
88
|
+
const m = msg as unknown as Record<string, unknown>;
|
|
89
|
+
if (typeof m["provider"] !== "string" || typeof m["model"] !== "string")
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
const contextWindow = getModel(
|
|
93
|
+
m["provider"] as never,
|
|
94
|
+
m["model"] as never,
|
|
95
|
+
)?.contextWindow;
|
|
96
|
+
return Number.isFinite(contextWindow) && contextWindow > 0
|
|
97
|
+
? contextWindow
|
|
98
|
+
: undefined;
|
|
99
|
+
} catch {
|
|
100
|
+
/* model lookup failures return undefined to skip context window tracking */
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|