@mystilleef/pi-subagent 0.10.2 → 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 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,19 +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
176
+ temperature: 0.7
177
+ top_p: 0.9
178
+ replace_prompt: true
129
179
  ```
130
180
 
131
- ### Accepted thinking values
132
-
133
- - `off`
134
- - `minimal`
135
- - `low`
136
- - `medium`
137
- - `high`
138
- - `xhigh`
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.
139
213
 
140
214
  ---
141
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mystilleef/pi-subagent",
3
- "version": "0.10.2",
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.0",
68
- "@earendil-works/pi-agent-core": "^0.79.4",
69
- "@earendil-works/pi-ai": "^0.79.4",
70
- "@earendil-works/pi-coding-agent": "^0.79.4",
71
- "@earendil-works/pi-tui": "^0.79.4",
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": "^25.9.3",
74
- "typebox": "^1.2.11",
73
+ "@types/node": "^26.1.0",
74
+ "typebox": "^1.3.4",
75
75
  "typescript": "^6.0.3"
76
76
  }
77
77
  }
@@ -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";
@@ -20,11 +21,16 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
20
21
  export interface AgentConfig {
21
22
  name: string;
22
23
  description: string;
24
+ context?: false | undefined;
23
25
  tools?: string[] | undefined;
24
- skills?: string[] | undefined;
26
+ skills?: string[] | false | undefined;
27
+ extensions?: string[] | undefined;
25
28
  thinking?: ThinkingLevel | undefined;
26
29
  model?: string | undefined;
27
30
  provider?: string | undefined;
31
+ temperature?: number | undefined;
32
+ topP?: number | undefined;
33
+ replacePrompt?: true | undefined;
28
34
  systemPrompt: string;
29
35
  source: AgentSource;
30
36
  filePath: string;
@@ -68,6 +74,21 @@ function parseCommaList(raw: unknown): string[] | undefined {
68
74
  return items.length > 0 ? items : undefined;
69
75
  }
70
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
+
71
92
  function parseThinkingLevel(raw: unknown): ThinkingLevel | undefined {
72
93
  if (typeof raw !== "string") return undefined;
73
94
  const normalized = raw.trim().toLowerCase();
@@ -82,6 +103,23 @@ function parseOptionalString(raw: unknown): string | undefined {
82
103
  return normalized.length > 0 ? normalized : undefined;
83
104
  }
84
105
 
106
+ function isNonStringOptional(raw: unknown): boolean {
107
+ return raw != null && typeof raw !== "string";
108
+ }
109
+
110
+ function parseSamplingValue(
111
+ raw: unknown,
112
+ field: string,
113
+ agentName: string,
114
+ ): number | undefined {
115
+ if (raw === undefined) return undefined;
116
+ if (isValidSamplingValue(raw)) return raw;
117
+ console.warn(
118
+ `Warning: Agent '${agentName}' has invalid '${field}' value: ${raw}`,
119
+ );
120
+ return undefined;
121
+ }
122
+
85
123
  function parseAgentConfig(
86
124
  content: string,
87
125
  source: AgentSource,
@@ -104,26 +142,49 @@ function parseAgentConfig(
104
142
  const {
105
143
  name,
106
144
  description,
145
+ context: rawContext,
107
146
  tools: rawTools,
108
147
  skills: rawSkills,
148
+ extensions: rawExtensions,
109
149
  thinking: rawThinking,
110
150
  model: rawModel,
111
151
  provider: rawProvider,
152
+ temperature: rawTemperature,
153
+ top_p: rawTopP,
154
+ replace_prompt: rawReplacePrompt,
112
155
  } = frontmatter;
113
156
  if (typeof name !== "string" || typeof description !== "string") return null;
114
- if (rawTools != null && typeof rawTools !== "string") return null;
115
- if (rawSkills != null && typeof rawSkills !== "string") return null;
116
- if (rawThinking != null && typeof rawThinking !== "string") return null;
117
- if (rawModel != null && typeof rawModel !== "string") return null;
118
- if (rawProvider != null && typeof rawProvider !== "string") return null;
157
+ if (rawContext !== undefined && typeof rawContext !== "boolean") return null;
158
+ if (rawReplacePrompt !== undefined && typeof rawReplacePrompt !== "boolean")
159
+ return null;
160
+ if (isNonStringOptional(rawTools)) return null;
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;
170
+ if (isNonStringOptional(rawThinking)) return null;
171
+ if (isNonStringOptional(rawModel)) return null;
172
+ if (isNonStringOptional(rawProvider)) return null;
119
173
  const tools = parseCommaList(rawTools);
120
- const skills = Object.hasOwn(frontmatter, "skills")
121
- ? (parseCommaList(rawSkills) ?? [])
122
- : undefined;
174
+ const skills =
175
+ rawSkills === false
176
+ ? false
177
+ : rawSkills !== undefined
178
+ ? (parseCommaList(rawSkills) ?? [])
179
+ : undefined;
123
180
  const thinking = parseThinkingLevel(rawThinking);
124
181
  const model = parseOptionalString(rawModel);
125
182
  const provider = parseOptionalString(rawProvider);
126
183
  if (provider !== undefined && model === undefined) return null;
184
+ const temperature = parseSamplingValue(rawTemperature, "temperature", name);
185
+ const topP = parseSamplingValue(rawTopP, "top_p", name);
186
+ const extensions = parseExtensions(rawExtensions);
187
+ if (rawReplacePrompt === true && body.trim().length === 0) return null;
127
188
  return {
128
189
  name,
129
190
  description,
@@ -135,6 +196,11 @@ function parseAgentConfig(
135
196
  systemPrompt: body,
136
197
  source,
137
198
  filePath,
199
+ ...(rawContext === false && { context: false }),
200
+ ...(temperature !== undefined && { temperature }),
201
+ ...(topP !== undefined && { topP }),
202
+ ...(extensions !== undefined && { extensions }),
203
+ ...(rawReplacePrompt === true && { replacePrompt: true }),
138
204
  };
139
205
  }
140
206
 
@@ -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,114 @@
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
+ * 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
+
57
+ /**
58
+ * Appends data to a string while enforcing a maximum byte limit.
59
+ * Handles both string and Buffer inputs, truncating at valid UTF-8 boundaries.
60
+ */
61
+ export function appendWithByteLimit(
62
+ current: string,
63
+ data: string | Buffer,
64
+ max: number,
65
+ ): string {
66
+ const currentBytes = Buffer.from(current, "utf-8");
67
+ if (currentBytes.length >= max) return current;
68
+ const incomingBytes = Buffer.isBuffer(data)
69
+ ? data
70
+ : Buffer.from(data, "utf-8");
71
+ const combined = Buffer.concat([currentBytes, incomingBytes]);
72
+ if (combined.length <= max) return combined.toString("utf-8");
73
+ return truncateValidUtf8(combined, max);
74
+ }
75
+
76
+ /**
77
+ * Truncates a buffer to the specified byte limit while preserving valid UTF-8 sequences.
78
+ * Returns an empty string if no valid truncation point is found.
79
+ */
80
+ export function truncateValidUtf8(buffer: Buffer, max: number): string {
81
+ let end = Math.min(max, buffer.length);
82
+ while (end > 0) {
83
+ const candidate = buffer.subarray(0, end).toString("utf-8");
84
+ if (!candidate.endsWith("�")) return candidate;
85
+ end -= 1;
86
+ }
87
+ return "";
88
+ }
89
+
90
+ /**
91
+ * Resolves the context window size in tokens for a given message.
92
+ * Returns undefined if the message doesn't have valid provider/model info
93
+ * or if the model lookup fails.
94
+ *
95
+ * Rationale: Subagent usage reporting needs context window awareness to provide
96
+ * meaningful "context full" indicators to the parent.
97
+ */
98
+ export function resolveContextWindowTokens(msg: Message): number | undefined {
99
+ const m = msg as unknown as Record<string, unknown>;
100
+ if (typeof m["provider"] !== "string" || typeof m["model"] !== "string")
101
+ return;
102
+ try {
103
+ const contextWindow = getModel(
104
+ m["provider"] as never,
105
+ m["model"] as never,
106
+ )?.contextWindow;
107
+ return Number.isFinite(contextWindow) && contextWindow > 0
108
+ ? contextWindow
109
+ : undefined;
110
+ } catch {
111
+ /* model lookup failures return undefined to skip context window tracking */
112
+ return;
113
+ }
114
+ }