Skip to content

Commit a9bc10b

Browse files
committed
feat: add specialized-subagents extension with scout
- scout subagent for web research (Exa search, URL fetch, GitHub) - shared executor with streaming, tool tracking, cost aggregation - renderCall/renderResult for proper TUI display - skill for creating new subagents
1 parent 6f51a2f commit a9bc10b

30 files changed

Lines changed: 3060 additions & 0 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Custom extensions for [Pi](https://github.com/mariozechner/pi-coding-agent), a c
1515
| [pi-ui](extensions/pi-ui/README.md) | Custom header and footer |
1616
| [preset](extensions/preset/README.md) | Named presets for model and subagent gateway |
1717
| [processes](extensions/processes/README.md) | Background process management |
18+
| [specialized-subagents](extensions/specialized-subagents/README.md) | Framework for spawning specialized subagents |
1819

1920
## Development
2021

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Specialized Subagents Extension
2+
3+
Framework for spawning specialized subagents with custom tools, consistent UI rendering, and logging.
4+
5+
## Features
6+
7+
- **Custom tools per subagent**: Each subagent has its own tool set
8+
- **Streaming UI**: Tool call progress, spinner animation, markdown rendering
9+
- **Cost tracking**: LLM tokens and external API costs (e.g., Exa)
10+
- **Logging**: Session-like logging in `~/.pi/agent/subagents/`
11+
12+
## Available Subagents
13+
14+
### Scout
15+
16+
Web research assistant. Fetches URLs, searches the web, and accesses GitHub content.
17+
18+
Tools: `fetch_url`, `search`, `github`
19+
20+
## SubagentConfig Options
21+
22+
| Option | Type | Default | Description |
23+
|--------|------|---------|-------------|
24+
| `name` | `string` | required | Subagent name (for logging) |
25+
| `model` | `Model` | required | Model instance from registry |
26+
| `systemPrompt` | `string` | required | System prompt |
27+
| `tools` | `AgentTool[]` | `[]` | Built-in tools |
28+
| `customTools` | `ToolDefinition[]` | `[]` | Custom tool definitions |
29+
| `skills` | `Skill[]` | `[]` | Skills to load |
30+
| `thinkingLevel` | `ThinkingLevel` | `"low"` | Thinking level |
31+
| `logging.enabled` | `boolean` | `false` | Enable logging |
32+
| `logging.debug` | `boolean` | `false` | Include raw events |
33+
34+
## SubagentResult
35+
36+
| Field | Type | Description |
37+
|-------|------|-------------|
38+
| `content` | `string` | Final response text |
39+
| `aborted` | `boolean` | Whether aborted |
40+
| `toolCalls` | `SubagentToolCall[]` | Tool execution history |
41+
| `error` | `string?` | Error message if failed |
42+
| `runId` | `string` | Unique run identifier |
43+
| `logFiles` | `{stream, debug}?` | Log file paths |
44+
| `usage` | `SubagentUsage` | Token and cost info |
45+
46+
## SubagentUsage
47+
48+
| Field | Type | Description |
49+
|-------|------|-------------|
50+
| `inputTokens` | `number?` | Input tokens from API |
51+
| `outputTokens` | `number?` | Output tokens from API |
52+
| `cacheReadTokens` | `number?` | Cache read tokens |
53+
| `cacheWriteTokens` | `number?` | Cache write tokens |
54+
| `estimatedTokens` | `number` | Fallback estimate |
55+
| `llmCost` | `number?` | LLM cost in USD |
56+
| `toolCost` | `number?` | External API cost in USD |
57+
| `totalCost` | `number?` | Total cost (llm + tool) |
58+
59+
## Creating New Subagents
60+
61+
See the `create-specialized-subagent` skill and `subagents/scout/` for reference.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2+
import { createScoutTool, SCOUT_GUIDANCE } from "./subagents/scout";
3+
4+
/**
5+
* Specialized Subagents Extension
6+
*
7+
* Provides specialized subagents with custom tools:
8+
* - scout: Web research and URL fetching (Exa + GitHub APIs)
9+
*/
10+
11+
// Collect all subagent guidances
12+
const SUBAGENT_GUIDANCES = [SCOUT_GUIDANCE];
13+
14+
export default function (pi: ExtensionAPI) {
15+
// Register tools
16+
pi.registerTool(createScoutTool());
17+
18+
// Inject subagent guidance into system prompt
19+
pi.on("before_agent_start", async (event) => {
20+
const guidance = SUBAGENT_GUIDANCES.join("\n");
21+
return {
22+
systemPrompt: `${event.systemPrompt}\n${guidance}`,
23+
};
24+
});
25+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Lightweight Exa API client.
3+
*/
4+
5+
const EXA_BASE_URL = "https://api.exa.ai";
6+
7+
/** Exa search result */
8+
export interface ExaSearchResult {
9+
title: string;
10+
url: string;
11+
publishedDate?: string;
12+
author?: string;
13+
text?: string;
14+
summary?: string;
15+
highlights?: string[];
16+
}
17+
18+
/** Exa search response */
19+
export interface ExaSearchResponse {
20+
requestId: string;
21+
results: ExaSearchResult[];
22+
costDollars?: { total: number };
23+
}
24+
25+
/** Exa contents result */
26+
export interface ExaContentsResult {
27+
title: string;
28+
url: string;
29+
text?: string;
30+
summary?: string;
31+
publishedDate?: string;
32+
author?: string;
33+
}
34+
35+
/**
36+
* Exa URL status (from contents endpoint).
37+
*
38+
* Error tags:
39+
* - CRAWL_NOT_FOUND (404): Content not found at URL
40+
* - CRAWL_TIMEOUT (408): Request timed out
41+
* - CRAWL_LIVECRAWL_TIMEOUT (408): Live crawl timed out
42+
* - SOURCE_NOT_AVAILABLE (403): Access forbidden or behind paywall
43+
* - CRAWL_UNKNOWN_ERROR (500+): Other crawling errors
44+
*/
45+
export interface ExaUrlStatus {
46+
id: string;
47+
status: "success" | "error";
48+
error?: {
49+
httpStatusCode?: number;
50+
tag?:
51+
| "CRAWL_NOT_FOUND"
52+
| "CRAWL_TIMEOUT"
53+
| "CRAWL_LIVECRAWL_TIMEOUT"
54+
| "SOURCE_NOT_AVAILABLE"
55+
| "CRAWL_UNKNOWN_ERROR";
56+
};
57+
}
58+
59+
/** Exa contents response */
60+
export interface ExaContentsResponse {
61+
requestId: string;
62+
results: ExaContentsResult[];
63+
statuses?: ExaUrlStatus[];
64+
costDollars?: { total: number };
65+
}
66+
67+
/** Exa search options */
68+
export interface ExaSearchOptions {
69+
query: string;
70+
numResults?: number;
71+
includeText?: boolean;
72+
includeSummary?: boolean;
73+
maxTextCharacters?: number;
74+
}
75+
76+
/** Exa contents options */
77+
export interface ExaContentsOptions {
78+
urls: string[];
79+
includeText?: boolean;
80+
livecrawl?: "never" | "fallback" | "preferred" | "always";
81+
}
82+
83+
export class ExaClient {
84+
private apiKey: string;
85+
86+
constructor() {
87+
const apiKey = process.env.EXA_API_KEY;
88+
if (!apiKey) {
89+
throw new Error("EXA_API_KEY environment variable is not set");
90+
}
91+
this.apiKey = apiKey;
92+
}
93+
94+
private async request<T>(
95+
endpoint: string,
96+
body: Record<string, unknown>,
97+
signal?: AbortSignal,
98+
): Promise<T> {
99+
const response = await fetch(`${EXA_BASE_URL}${endpoint}`, {
100+
method: "POST",
101+
headers: {
102+
"Content-Type": "application/json",
103+
"x-api-key": this.apiKey,
104+
},
105+
body: JSON.stringify(body),
106+
signal,
107+
});
108+
109+
if (!response.ok) {
110+
const errorText = await response.text();
111+
throw new Error(`Exa API error (${response.status}): ${errorText}`);
112+
}
113+
114+
return response.json() as Promise<T>;
115+
}
116+
117+
/** Search the web */
118+
async search(
119+
options: ExaSearchOptions,
120+
signal?: AbortSignal,
121+
): Promise<ExaSearchResponse> {
122+
const body: Record<string, unknown> = {
123+
query: options.query,
124+
numResults: options.numResults ?? 10,
125+
};
126+
127+
// Add contents options if text or summary requested
128+
if (options.includeText || options.includeSummary) {
129+
const contents: Record<string, unknown> = {};
130+
if (options.includeText) {
131+
contents.text = options.maxTextCharacters
132+
? { maxCharacters: options.maxTextCharacters }
133+
: true;
134+
}
135+
if (options.includeSummary) {
136+
contents.summary = {};
137+
}
138+
body.contents = contents;
139+
}
140+
141+
return this.request<ExaSearchResponse>("/search", body, signal);
142+
}
143+
144+
/** Get contents of URLs */
145+
async contents(
146+
options: ExaContentsOptions,
147+
signal?: AbortSignal,
148+
): Promise<ExaContentsResponse> {
149+
const body: Record<string, unknown> = {
150+
urls: options.urls,
151+
text: options.includeText ?? true,
152+
livecrawl: options.livecrawl ?? "fallback",
153+
};
154+
155+
return this.request<ExaContentsResponse>("/contents", body, signal);
156+
}
157+
}
158+
159+
/** Create an Exa client (throws if EXA_API_KEY not set) */
160+
export function createExaClient(): ExaClient {
161+
return new ExaClient();
162+
}

0 commit comments

Comments
 (0)