@chaotic1988/pi-subagent 0.2.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 +46 -0
- package/agent.ts +163 -0
- package/docs/user-guide.md +127 -0
- package/index.ts +587 -0
- package/package.json +30 -0
- package/runner.ts +295 -0
- package/skills/authoring-agent-specs/SKILL.md +147 -0
- package/tests/agent.test.ts +74 -0
- package/tests/runner.test.ts +111 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +17 -0
package/runner.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK-based runner for subagent execution.
|
|
3
|
+
*
|
|
4
|
+
* Creates in-process AgentSession instances instead of spawning child processes.
|
|
5
|
+
* Handles model resolution, resource loading, event subscription, and abort signals.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Message, Model, Api } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
10
|
+
import {
|
|
11
|
+
type AgentSessionEvent,
|
|
12
|
+
AuthStorage,
|
|
13
|
+
createAgentSession,
|
|
14
|
+
DefaultResourceLoader,
|
|
15
|
+
getAgentDir,
|
|
16
|
+
ModelRegistry,
|
|
17
|
+
SessionManager,
|
|
18
|
+
SettingsManager,
|
|
19
|
+
type Skill,
|
|
20
|
+
type ResourceDiagnostic,
|
|
21
|
+
} from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import type { AgentConfig } from "./agent.js";
|
|
23
|
+
|
|
24
|
+
export interface UsageStats {
|
|
25
|
+
input: number;
|
|
26
|
+
output: number;
|
|
27
|
+
cacheRead: number;
|
|
28
|
+
cacheWrite: number;
|
|
29
|
+
cost: number;
|
|
30
|
+
contextTokens: number;
|
|
31
|
+
turns: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SingleResult {
|
|
35
|
+
agent: string;
|
|
36
|
+
agentSource: "user" | "project" | "unknown";
|
|
37
|
+
task: string;
|
|
38
|
+
exitCode: number;
|
|
39
|
+
messages: Message[];
|
|
40
|
+
stderr: string;
|
|
41
|
+
usage: UsageStats;
|
|
42
|
+
model?: string;
|
|
43
|
+
stopReason?: string;
|
|
44
|
+
errorMessage?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type OnUpdateCallback = (result: SingleResult) => void;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Model resolution
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a "<provider>/<id>" model string through the registry.
|
|
55
|
+
* Returns the `Model<Api>` on success, or an error string on failure.
|
|
56
|
+
* No silent fallback — the caller must handle errors.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveModel(
|
|
59
|
+
registry: ModelRegistry,
|
|
60
|
+
name: string,
|
|
61
|
+
): Model<Api> | string {
|
|
62
|
+
const i = name.indexOf("/");
|
|
63
|
+
if (i < 0) {
|
|
64
|
+
return `malformed model "${name}", expected <provider>/<id>`;
|
|
65
|
+
}
|
|
66
|
+
const provider = name.slice(0, i);
|
|
67
|
+
const id = name.slice(i + 1);
|
|
68
|
+
const model = registry.find(provider, id);
|
|
69
|
+
if (!model) {
|
|
70
|
+
return `model "${name}" not found`;
|
|
71
|
+
}
|
|
72
|
+
if (!registry.hasConfiguredAuth(model)) {
|
|
73
|
+
return `no auth configured for provider "${provider}"`;
|
|
74
|
+
}
|
|
75
|
+
return model;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Resource loader
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a ResourceLoader for an agent session.
|
|
84
|
+
* - No extensions / no skills by default (explicit-only).
|
|
85
|
+
* - System prompt appended when non-empty.
|
|
86
|
+
*/
|
|
87
|
+
export function buildResourceLoader(agent: AgentConfig, cwd: string): DefaultResourceLoader {
|
|
88
|
+
const exts: string[] = agent.extensions ?? [];
|
|
89
|
+
const skills: string[] = agent.skills ?? [];
|
|
90
|
+
|
|
91
|
+
return new DefaultResourceLoader({
|
|
92
|
+
cwd,
|
|
93
|
+
agentDir: getAgentDir(),
|
|
94
|
+
appendSystemPrompt: agent.systemPrompt?.trim() ? [agent.systemPrompt] : undefined,
|
|
95
|
+
|
|
96
|
+
noExtensions: true,
|
|
97
|
+
additionalExtensionPaths: exts.length > 0 ? exts : undefined,
|
|
98
|
+
|
|
99
|
+
noSkills: skills.length === 0,
|
|
100
|
+
skillsOverride:
|
|
101
|
+
skills.length > 0
|
|
102
|
+
? (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => ({
|
|
103
|
+
skills: base.skills.filter((s) => skills.includes(s.name)),
|
|
104
|
+
diagnostics: base.diagnostics,
|
|
105
|
+
})
|
|
106
|
+
: undefined,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Single agent run
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run a single agent via an in-process SDK session.
|
|
116
|
+
*/
|
|
117
|
+
export async function runAgent(
|
|
118
|
+
agent: AgentConfig,
|
|
119
|
+
task: string,
|
|
120
|
+
cwd: string,
|
|
121
|
+
modelRegistry: ModelRegistry,
|
|
122
|
+
authStorage: AuthStorage,
|
|
123
|
+
signal?: AbortSignal,
|
|
124
|
+
onUpdate?: OnUpdateCallback,
|
|
125
|
+
): Promise<SingleResult> {
|
|
126
|
+
const result: SingleResult = {
|
|
127
|
+
agent: agent.name,
|
|
128
|
+
agentSource: agent.source,
|
|
129
|
+
task,
|
|
130
|
+
exitCode: 0,
|
|
131
|
+
messages: [],
|
|
132
|
+
stderr: "",
|
|
133
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
134
|
+
};
|
|
135
|
+
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
let model: Model<Api> | undefined;
|
|
139
|
+
let thinkingLevel: ThinkingLevel | undefined;
|
|
140
|
+
|
|
141
|
+
if (agent.model) {
|
|
142
|
+
const resolved = resolveModel(modelRegistry, agent.model);
|
|
143
|
+
if (typeof resolved === "string") {
|
|
144
|
+
result.exitCode = 1;
|
|
145
|
+
result.errorMessage = resolved;
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
model = resolved;
|
|
149
|
+
thinkingLevel = agent.thinkingLevel as ThinkingLevel | undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const loader = buildResourceLoader(agent, cwd);
|
|
153
|
+
await loader.reload();
|
|
154
|
+
|
|
155
|
+
const { session: createdSession } = await createAgentSession({
|
|
156
|
+
cwd,
|
|
157
|
+
model,
|
|
158
|
+
thinkingLevel,
|
|
159
|
+
tools: agent.tools,
|
|
160
|
+
resourceLoader: loader,
|
|
161
|
+
sessionManager: SessionManager.create(cwd),
|
|
162
|
+
authStorage,
|
|
163
|
+
modelRegistry,
|
|
164
|
+
settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
|
|
165
|
+
});
|
|
166
|
+
session = createdSession;
|
|
167
|
+
|
|
168
|
+
session.subscribe((event: AgentSessionEvent) => {
|
|
169
|
+
if (event.type === "turn_end") {
|
|
170
|
+
result.usage.turns++;
|
|
171
|
+
const msg = event.message as Message;
|
|
172
|
+
const u = (msg as any).usage;
|
|
173
|
+
if (u) {
|
|
174
|
+
result.usage.input += u.input || 0;
|
|
175
|
+
result.usage.output += u.output || 0;
|
|
176
|
+
result.usage.cacheRead += u.cacheRead || 0;
|
|
177
|
+
result.usage.cacheWrite += u.cacheWrite || 0;
|
|
178
|
+
result.usage.cost += u.cost?.total || 0;
|
|
179
|
+
result.usage.contextTokens = u.totalTokens || 0;
|
|
180
|
+
}
|
|
181
|
+
if (!result.model && (msg as any).model) result.model = (msg as any).model;
|
|
182
|
+
if ((msg as any).stopReason) result.stopReason = (msg as any).stopReason;
|
|
183
|
+
if ((msg as any).errorMessage) result.errorMessage = (msg as any).errorMessage;
|
|
184
|
+
result.messages.push(msg, ...event.toolResults);
|
|
185
|
+
onUpdate?.(result);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (signal) {
|
|
190
|
+
if (signal.aborted) {
|
|
191
|
+
session.abort();
|
|
192
|
+
} else {
|
|
193
|
+
signal.addEventListener("abort", () => session!.abort(), { once: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await session.prompt(task);
|
|
198
|
+
result.exitCode = 0;
|
|
199
|
+
return result;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
result.exitCode = 1;
|
|
202
|
+
result.errorMessage = err instanceof Error ? err.message : String(err);
|
|
203
|
+
return result;
|
|
204
|
+
} finally {
|
|
205
|
+
session?.dispose();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Parallel execution
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
214
|
+
items: TIn[],
|
|
215
|
+
concurrency: number,
|
|
216
|
+
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
217
|
+
): Promise<TOut[]> {
|
|
218
|
+
if (items.length === 0) return [];
|
|
219
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
220
|
+
const results: TOut[] = new Array(items.length);
|
|
221
|
+
let nextIndex = 0;
|
|
222
|
+
const workers = new Array(limit).fill(null).map(async () => {
|
|
223
|
+
while (true) {
|
|
224
|
+
const current = nextIndex++;
|
|
225
|
+
if (current >= items.length) return;
|
|
226
|
+
results[current] = await fn(items[current], current);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
await Promise.all(workers);
|
|
230
|
+
return results;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const MAX_CONCURRENCY = 4;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Run multiple agents in parallel with a concurrency cap.
|
|
237
|
+
*/
|
|
238
|
+
export async function runParallel(
|
|
239
|
+
cwd: string,
|
|
240
|
+
agents: AgentConfig[],
|
|
241
|
+
tasks: Array<{ agent: string; task: string; cwd?: string }>,
|
|
242
|
+
modelRegistry: ModelRegistry,
|
|
243
|
+
authStorage: AuthStorage,
|
|
244
|
+
signal?: AbortSignal,
|
|
245
|
+
onUpdate?: (results: SingleResult[]) => void,
|
|
246
|
+
): Promise<SingleResult[]> {
|
|
247
|
+
// Track all results; exitCode: -1 = still running
|
|
248
|
+
const allResults: SingleResult[] = tasks.map((t) => ({
|
|
249
|
+
agent: t.agent,
|
|
250
|
+
agentSource: "unknown" as const,
|
|
251
|
+
task: t.task,
|
|
252
|
+
exitCode: -1,
|
|
253
|
+
messages: [],
|
|
254
|
+
stderr: "",
|
|
255
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
256
|
+
}));
|
|
257
|
+
|
|
258
|
+
const emitUpdate = () => onUpdate?.([...allResults]);
|
|
259
|
+
|
|
260
|
+
const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
261
|
+
const agent = agents.find((a) => a.name === t.agent);
|
|
262
|
+
if (!agent) {
|
|
263
|
+
const result: SingleResult = {
|
|
264
|
+
agent: t.agent,
|
|
265
|
+
agentSource: "unknown",
|
|
266
|
+
task: t.task,
|
|
267
|
+
exitCode: 1,
|
|
268
|
+
messages: [],
|
|
269
|
+
stderr: `Unknown agent: "${t.agent}"`,
|
|
270
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
271
|
+
};
|
|
272
|
+
allResults[index] = result;
|
|
273
|
+
emitUpdate();
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const result = await runAgent(
|
|
278
|
+
agent,
|
|
279
|
+
t.task,
|
|
280
|
+
t.cwd ?? cwd,
|
|
281
|
+
modelRegistry,
|
|
282
|
+
authStorage,
|
|
283
|
+
signal,
|
|
284
|
+
(partial) => {
|
|
285
|
+
allResults[index] = partial;
|
|
286
|
+
emitUpdate();
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
allResults[index] = result;
|
|
290
|
+
emitUpdate();
|
|
291
|
+
return result;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return results;
|
|
295
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: authoring-agent-specs
|
|
3
|
+
description: Authoring agent spec files (frontmatter + system prompt) for pi-subagent. Use when defining new agents, updating agent behavior, or scaffolding specialist prompts.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Authoring Agent Specs
|
|
7
|
+
|
|
8
|
+
Create well-formed agent spec files that pi-subagent can discover and invoke.
|
|
9
|
+
|
|
10
|
+
## Agent Spec Format
|
|
11
|
+
|
|
12
|
+
An agent is defined in a `.md` file with YAML frontmatter followed by a system prompt body.
|
|
13
|
+
|
|
14
|
+
```markdown
|
|
15
|
+
---
|
|
16
|
+
name: my-agent
|
|
17
|
+
description: One-line summary shown in agent listings
|
|
18
|
+
model: anthropic/claude-sonnet-4.6
|
|
19
|
+
tools: read,bash,edit,write
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
You are a specialist in X. Keep responses concise.
|
|
23
|
+
Always confirm before making destructive changes.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Required Fields
|
|
27
|
+
|
|
28
|
+
| Field | Description |
|
|
29
|
+
|-------|-------------|
|
|
30
|
+
| `name` | Identifier used when invoking the agent. Lowercase, hyphens, numbers only. |
|
|
31
|
+
| `description` | One-line summary shown in agent listings and the subagent tool description. |
|
|
32
|
+
|
|
33
|
+
### Optional Fields
|
|
34
|
+
|
|
35
|
+
| Field | Description |
|
|
36
|
+
|-------|-------------|
|
|
37
|
+
| `model` | `<provider>/<id>` format (e.g. `anthropic/claude-sonnet-4.6`). Resolution failure (malformed, not found, no auth) halts the run with an error. Falls back to the SDK default if absent. |
|
|
38
|
+
| `thinkingLevel` | `off \| minimal \| low \| medium \| high \| xhigh`. Clamped to the model's supported levels by the SDK. Falls back to the SDK default if absent. |
|
|
39
|
+
| `tools` | Comma-separated allowed tools (e.g. `read,bash,edit`). All tools if absent. |
|
|
40
|
+
| `extensions` | Omit or empty → none; list of paths → exactly those extensions loaded. Supports `${ENV_VAR}` substitution. |
|
|
41
|
+
| `skills` | Omit or empty → none; list of names → exactly those skills loaded. |
|
|
42
|
+
|
|
43
|
+
### `extensions` and `skills`
|
|
44
|
+
|
|
45
|
+
Subagents start with **no extensions and no skills** by default. List them explicitly if the agent needs them.
|
|
46
|
+
|
|
47
|
+
| Frontmatter | Behaviour |
|
|
48
|
+
|-------------|-----------|
|
|
49
|
+
| Field absent or empty | No extensions/skills loaded. |
|
|
50
|
+
| `extensions` with paths (`extensions: /path/a, /path/b`) | Exactly those extensions loaded. |
|
|
51
|
+
| `skills` with names (`skills: my-skill, other-skill`) | Exactly those skills loaded. |
|
|
52
|
+
|
|
53
|
+
## Where to Place Agents
|
|
54
|
+
|
|
55
|
+
### User-level (available in all projects)
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
~/.pi/agent/agents/<name>.md
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Project-level (available in one project)
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
<project-root>/.pi/agents/<name>.md
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Bundled with an extension
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
<extension-dir>/agents/<name>.md
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Choose based on scope:
|
|
74
|
+
- **User-level** for personal agents you reuse across projects.
|
|
75
|
+
- **Project-level** for agents specific to a codebase (e.g. a framework-specific reviewer).
|
|
76
|
+
- **Extension-bundled** when distributing agents as part of a package.
|
|
77
|
+
|
|
78
|
+
## Naming Rules
|
|
79
|
+
|
|
80
|
+
- Lowercase letters, numbers, and hyphens only.
|
|
81
|
+
- No leading, trailing, or consecutive hyphens.
|
|
82
|
+
- The file name (minus `.md`) should match the `name` field for clarity, though pi-subagent reads the frontmatter `name`, not the filename.
|
|
83
|
+
|
|
84
|
+
**Good:** `security-reviewer`, `docs-writer`, `perf-analyzer`
|
|
85
|
+
**Bad:** `SecurityReviewer`, `-reviewer`, `code--reviewer`
|
|
86
|
+
|
|
87
|
+
## System Prompt Guidelines
|
|
88
|
+
|
|
89
|
+
The body after the frontmatter becomes the agent's system prompt. Effective prompts are:
|
|
90
|
+
|
|
91
|
+
- **Role-specific.** Define what the agent is expert at.
|
|
92
|
+
- **Constrained.** Specify output format (e.g. "Respond with a bullet list").
|
|
93
|
+
- **Scoped.** State what the agent should NOT do.
|
|
94
|
+
- **Concise.** Subagents have isolated context — tight prompts produce better results.
|
|
95
|
+
|
|
96
|
+
## Examples
|
|
97
|
+
|
|
98
|
+
### Code Reviewer
|
|
99
|
+
|
|
100
|
+
```markdown
|
|
101
|
+
---
|
|
102
|
+
name: code-reviewer
|
|
103
|
+
description: Reviews code for correctness, clarity, and security issues
|
|
104
|
+
tools: read,find,grep,ls
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
You are a thorough code reviewer. For each file or diff you review:
|
|
108
|
+
|
|
109
|
+
1. Identify correctness bugs and logic errors.
|
|
110
|
+
2. Flag security vulnerabilities.
|
|
111
|
+
3. Assess code clarity and naming.
|
|
112
|
+
4. Suggest specific improvements.
|
|
113
|
+
|
|
114
|
+
Respond with a brief summary followed by a bullet list of issues, grouped by severity (critical, warning, minor).
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Documentation Writer
|
|
118
|
+
|
|
119
|
+
```markdown
|
|
120
|
+
---
|
|
121
|
+
name: docs-writer
|
|
122
|
+
description: Writes clear, concise documentation and JSDoc comments
|
|
123
|
+
tools: read,bash,edit,write
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
You write documentation. Follow these rules:
|
|
127
|
+
|
|
128
|
+
- Use active voice and present tense.
|
|
129
|
+
- Keep paragraphs under 4 sentences.
|
|
130
|
+
- Include code examples for non-trivial APIs.
|
|
131
|
+
- For JSDoc: include @param, @returns, and @throws when applicable.
|
|
132
|
+
- Match the existing documentation style in the codebase.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Agent with Explicit Extensions and Skills
|
|
136
|
+
|
|
137
|
+
```markdown
|
|
138
|
+
---
|
|
139
|
+
name: code-reviewer-with-skill
|
|
140
|
+
description: Reviews code using a custom review skill
|
|
141
|
+
tools: read,bash,edit
|
|
142
|
+
extensions: ./my-extension
|
|
143
|
+
skills: code-review
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
You review code using the review skill for structured feedback.
|
|
147
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseCommaList, formatAgentList } from "../agent.js";
|
|
3
|
+
import type { AgentConfig } from "../agent.js";
|
|
4
|
+
|
|
5
|
+
describe("parseCommaList", () => {
|
|
6
|
+
it("returns undefined for undefined input", () => {
|
|
7
|
+
expect(parseCommaList(undefined)).toBe(undefined);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns undefined for null input", () => {
|
|
11
|
+
expect(parseCommaList(null)).toBe(undefined);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns undefined for empty string", () => {
|
|
15
|
+
expect(parseCommaList("")).toBe(undefined);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns a single-element array for a single path", () => {
|
|
19
|
+
expect(parseCommaList("path/a")).toEqual(["path/a"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("splits a comma-separated string into an array", () => {
|
|
23
|
+
expect(parseCommaList("path/a, path/b")).toEqual(["path/a", "path/b"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("trims whitespace from each segment", () => {
|
|
27
|
+
expect(parseCommaList(" path/a , path/b ")).toEqual(["path/a", "path/b"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns undefined when all segments are empty after filtering", () => {
|
|
31
|
+
expect(parseCommaList(",")).toBe(undefined);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns undefined for non-string non-null input", () => {
|
|
35
|
+
expect(parseCommaList(42)).toBe(undefined);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("formatAgentList", () => {
|
|
40
|
+
const makeAgent = (name: string): AgentConfig => ({
|
|
41
|
+
name,
|
|
42
|
+
description: `${name} description`,
|
|
43
|
+
systemPrompt: "",
|
|
44
|
+
source: "user",
|
|
45
|
+
filePath: `/agents/${name}.md`,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns 'none' for an empty array", () => {
|
|
49
|
+
expect(formatAgentList([], 10)).toEqual({ text: "none", remaining: 0 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("lists all agents when count is below maxItems", () => {
|
|
53
|
+
const agents = [makeAgent("alpha"), makeAgent("beta")];
|
|
54
|
+
const result = formatAgentList(agents, 10);
|
|
55
|
+
expect(result.remaining).toBe(0);
|
|
56
|
+
expect(result.text).toContain("alpha (user): alpha description");
|
|
57
|
+
expect(result.text).toContain("beta (user): beta description");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("limits output to maxItems and reports remaining count", () => {
|
|
61
|
+
const agents = [makeAgent("a"), makeAgent("b"), makeAgent("c")];
|
|
62
|
+
const result = formatAgentList(agents, 2);
|
|
63
|
+
expect(result.remaining).toBe(1);
|
|
64
|
+
expect(result.text).toContain("a (user)");
|
|
65
|
+
expect(result.text).toContain("b (user)");
|
|
66
|
+
expect(result.text).not.toContain("c (user)");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("formats each agent as 'name (source): description'", () => {
|
|
70
|
+
const agents = [makeAgent("my-agent")];
|
|
71
|
+
const result = formatAgentList(agents, 10);
|
|
72
|
+
expect(result.text).toBe("my-agent (user): my-agent description");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { resolveModel, buildResourceLoader } from "../runner.js";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// resolveModel
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
describe("resolveModel", () => {
|
|
9
|
+
const mockModel = { id: "claude-sonnet-4-5", provider: "anthropic" };
|
|
10
|
+
|
|
11
|
+
const makeRegistry = (found: any = undefined, hasAuth = true) => ({
|
|
12
|
+
find: vi.fn().mockReturnValue(found),
|
|
13
|
+
hasConfiguredAuth: vi.fn().mockReturnValue(hasAuth),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns error string for malformed name (no slash)", () => {
|
|
17
|
+
const registry = makeRegistry();
|
|
18
|
+
const result = resolveModel(registry as any, "claude-sonnet-4-5");
|
|
19
|
+
expect(result).toBe('malformed model "claude-sonnet-4-5", expected <provider>/<id>');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("resolves well-formed provider/id via registry", () => {
|
|
23
|
+
const registry = makeRegistry(mockModel);
|
|
24
|
+
const result = resolveModel(registry as any, "anthropic/claude-sonnet-4-5");
|
|
25
|
+
expect(registry.find).toHaveBeenCalledWith("anthropic", "claude-sonnet-4-5");
|
|
26
|
+
expect(result).toBe(mockModel);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns error string for unknown model", () => {
|
|
30
|
+
const registry = makeRegistry(undefined);
|
|
31
|
+
const result = resolveModel(registry as any, "unknown/model-x");
|
|
32
|
+
expect(result).toBe('model "unknown/model-x" not found');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns error string when no auth is configured", () => {
|
|
36
|
+
const registry = makeRegistry(mockModel, false);
|
|
37
|
+
const result = resolveModel(registry as any, "anthropic/claude-sonnet-4-5");
|
|
38
|
+
expect(result).toBe('no auth configured for provider "anthropic"');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns model when auth is configured", () => {
|
|
42
|
+
const registry = makeRegistry(mockModel, true);
|
|
43
|
+
const result = resolveModel(registry as any, "anthropic/claude-sonnet-4-5");
|
|
44
|
+
expect(result).toBe(mockModel);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// buildResourceLoader
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe("buildResourceLoader", () => {
|
|
53
|
+
const baseAgent = {
|
|
54
|
+
name: "test-agent",
|
|
55
|
+
description: "test",
|
|
56
|
+
systemPrompt: "You are a helper.",
|
|
57
|
+
source: "user" as const,
|
|
58
|
+
filePath: "/agents/test.md",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
it("always sets noExtensions=true even when extensions are listed", () => {
|
|
62
|
+
// Without extensions
|
|
63
|
+
const loader1 = buildResourceLoader(baseAgent, "/tmp");
|
|
64
|
+
expect(loader1).toBeDefined();
|
|
65
|
+
|
|
66
|
+
// With extensions — noExtensions should still be true
|
|
67
|
+
const loader2 = buildResourceLoader(
|
|
68
|
+
{ ...baseAgent, extensions: ["./ext-a"] },
|
|
69
|
+
"/tmp",
|
|
70
|
+
);
|
|
71
|
+
expect(loader2).toBeDefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("sets noSkills=true when no skills", () => {
|
|
75
|
+
const loader = buildResourceLoader(baseAgent, "/tmp");
|
|
76
|
+
expect(loader).toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("sets appendSystemPrompt when systemPrompt is non-empty", () => {
|
|
80
|
+
const loader = buildResourceLoader(baseAgent, "/tmp");
|
|
81
|
+
expect(loader).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not set appendSystemPrompt when systemPrompt is empty", () => {
|
|
85
|
+
const agent = { ...baseAgent, systemPrompt: "" };
|
|
86
|
+
const loader = buildResourceLoader(agent, "/tmp");
|
|
87
|
+
expect(loader).toBeDefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("passes extensions as additionalExtensionPaths", () => {
|
|
91
|
+
const agent = { ...baseAgent, extensions: ["./ext-a", "./ext-b"] };
|
|
92
|
+
const loader = buildResourceLoader(agent, "/tmp");
|
|
93
|
+
expect(loader).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("filters skills by name", () => {
|
|
97
|
+
const agent = { ...baseAgent, skills: ["grep", "find"] };
|
|
98
|
+
const loader = buildResourceLoader(agent, "/tmp");
|
|
99
|
+
expect(loader).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("handles both extensions and skills", () => {
|
|
103
|
+
const agent = {
|
|
104
|
+
...baseAgent,
|
|
105
|
+
extensions: ["./ext-a"],
|
|
106
|
+
skills: ["grep"],
|
|
107
|
+
};
|
|
108
|
+
const loader = buildResourceLoader(agent, "/tmp");
|
|
109
|
+
expect(loader).toBeDefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const globalRoot = execSync("pnpm root -g").toString().trim();
|
|
6
|
+
const piCodingAgentEntry = path.join(
|
|
7
|
+
globalRoot,
|
|
8
|
+
"@earendil-works/pi-coding-agent/dist/index.js"
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export default defineConfig({
|
|
12
|
+
resolve: {
|
|
13
|
+
alias: {
|
|
14
|
+
"@earendil-works/pi-coding-agent": piCodingAgentEntry,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|