@tintinweb/pi-subagents 0.4.0 → 0.4.1
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/CHANGELOG.md +10 -0
- package/dist/agent-manager.d.ts +70 -0
- package/dist/agent-manager.js +236 -0
- package/dist/agent-runner.d.ts +60 -0
- package/dist/agent-runner.js +265 -0
- package/dist/agent-types.d.ts +41 -0
- package/dist/agent-types.js +130 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/custom-agents.d.ts +14 -0
- package/dist/custom-agents.js +100 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +126 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1270 -0
- package/dist/model-resolver.d.ts +19 -0
- package/dist/model-resolver.js +62 -0
- package/dist/prompts.d.ts +14 -0
- package/dist/prompts.js +48 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +5 -0
- package/dist/ui/agent-widget.d.ts +101 -0
- package/dist/ui/agent-widget.js +333 -0
- package/dist/ui/conversation-viewer.d.ts +31 -0
- package/dist/ui/conversation-viewer.js +236 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +22 -0
- package/src/index.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.1] - 2026-03-11
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Graceful shutdown in headless mode** — the CLI now waits for all running and queued background agents to complete before exiting (`waitForAll` on `session_shutdown`). Previously, background agents could be silently killed mid-execution when the session ended. Only affects headless/non-interactive mode; interactive sessions already kept the process alive.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `hasRunning()` / `waitForAll()` methods on `AgentManager`.
|
|
15
|
+
- **Cross-package manager access** — agent manager exposed via `Symbol.for("pi-subagents:manager")` on `globalThis` for other extensions to check status or await completion.
|
|
16
|
+
|
|
8
17
|
## [0.4.0] - 2026-03-11
|
|
9
18
|
|
|
10
19
|
### Added
|
|
@@ -188,6 +197,7 @@ Initial release.
|
|
|
188
197
|
- **Thinking level** — per-agent extended thinking control
|
|
189
198
|
- **`/agent` and `/agents` commands**
|
|
190
199
|
|
|
200
|
+
[0.4.1]: https://github.com/tintinweb/pi-subagents/compare/v0.4.0...v0.4.1
|
|
191
201
|
[0.4.0]: https://github.com/tintinweb/pi-subagents/compare/v0.3.1...v0.4.0
|
|
192
202
|
[0.3.1]: https://github.com/tintinweb/pi-subagents/compare/v0.3.0...v0.3.1
|
|
193
203
|
[0.3.0]: https://github.com/tintinweb/pi-subagents/compare/v0.2.7...v0.3.0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-manager.ts — Tracks agents, background execution, resume support.
|
|
3
|
+
*
|
|
4
|
+
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
|
+
* Excess agents are queued and auto-started as running agents complete.
|
|
6
|
+
* Foreground agents bypass the queue (they block the parent anyway).
|
|
7
|
+
*/
|
|
8
|
+
import type { ExtensionContext, ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
10
|
+
import type { AgentSession } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import { type ToolActivity } from "./agent-runner.js";
|
|
12
|
+
import type { SubagentType, AgentRecord, ThinkingLevel } from "./types.js";
|
|
13
|
+
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
14
|
+
interface SpawnOptions {
|
|
15
|
+
description: string;
|
|
16
|
+
model?: Model<any>;
|
|
17
|
+
maxTurns?: number;
|
|
18
|
+
isolated?: boolean;
|
|
19
|
+
inheritContext?: boolean;
|
|
20
|
+
thinkingLevel?: ThinkingLevel;
|
|
21
|
+
isBackground?: boolean;
|
|
22
|
+
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
23
|
+
onToolActivity?: (activity: ToolActivity) => void;
|
|
24
|
+
/** Called on streaming text deltas from the assistant response. */
|
|
25
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
26
|
+
/** Called when the agent session is created (for accessing session stats). */
|
|
27
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare class AgentManager {
|
|
30
|
+
private agents;
|
|
31
|
+
private cleanupInterval;
|
|
32
|
+
private onComplete?;
|
|
33
|
+
private maxConcurrent;
|
|
34
|
+
/** Queue of background agents waiting to start. */
|
|
35
|
+
private queue;
|
|
36
|
+
/** Number of currently running background agents. */
|
|
37
|
+
private runningBackground;
|
|
38
|
+
constructor(onComplete?: OnAgentComplete, maxConcurrent?: number);
|
|
39
|
+
/** Update the max concurrent background agents limit. */
|
|
40
|
+
setMaxConcurrent(n: number): void;
|
|
41
|
+
getMaxConcurrent(): number;
|
|
42
|
+
/**
|
|
43
|
+
* Spawn an agent and return its ID immediately (for background use).
|
|
44
|
+
* If the concurrency limit is reached, the agent is queued.
|
|
45
|
+
*/
|
|
46
|
+
spawn(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions): string;
|
|
47
|
+
/** Actually start an agent (called immediately or from queue drain). */
|
|
48
|
+
private startAgent;
|
|
49
|
+
/** Start queued agents up to the concurrency limit. */
|
|
50
|
+
private drainQueue;
|
|
51
|
+
/**
|
|
52
|
+
* Spawn an agent and wait for completion (foreground use).
|
|
53
|
+
* Foreground agents bypass the concurrency queue.
|
|
54
|
+
*/
|
|
55
|
+
spawnAndWait(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit<SpawnOptions, "isBackground">): Promise<AgentRecord>;
|
|
56
|
+
/**
|
|
57
|
+
* Resume an existing agent session with a new prompt.
|
|
58
|
+
*/
|
|
59
|
+
resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
|
|
60
|
+
getRecord(id: string): AgentRecord | undefined;
|
|
61
|
+
listAgents(): AgentRecord[];
|
|
62
|
+
abort(id: string): boolean;
|
|
63
|
+
private cleanup;
|
|
64
|
+
/** Whether any agents are still running or queued. */
|
|
65
|
+
hasRunning(): boolean;
|
|
66
|
+
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
67
|
+
waitForAll(): Promise<void>;
|
|
68
|
+
dispose(): void;
|
|
69
|
+
}
|
|
70
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-manager.ts — Tracks agents, background execution, resume support.
|
|
3
|
+
*
|
|
4
|
+
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
|
+
* Excess agents are queued and auto-started as running agents complete.
|
|
6
|
+
* Foreground agents bypass the queue (they block the parent anyway).
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { runAgent, resumeAgent } from "./agent-runner.js";
|
|
10
|
+
/** Default max concurrent background agents. */
|
|
11
|
+
const DEFAULT_MAX_CONCURRENT = 4;
|
|
12
|
+
export class AgentManager {
|
|
13
|
+
agents = new Map();
|
|
14
|
+
cleanupInterval;
|
|
15
|
+
onComplete;
|
|
16
|
+
maxConcurrent;
|
|
17
|
+
/** Queue of background agents waiting to start. */
|
|
18
|
+
queue = [];
|
|
19
|
+
/** Number of currently running background agents. */
|
|
20
|
+
runningBackground = 0;
|
|
21
|
+
constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT) {
|
|
22
|
+
this.onComplete = onComplete;
|
|
23
|
+
this.maxConcurrent = maxConcurrent;
|
|
24
|
+
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
25
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
26
|
+
}
|
|
27
|
+
/** Update the max concurrent background agents limit. */
|
|
28
|
+
setMaxConcurrent(n) {
|
|
29
|
+
this.maxConcurrent = Math.max(1, n);
|
|
30
|
+
// Start queued agents if the new limit allows
|
|
31
|
+
this.drainQueue();
|
|
32
|
+
}
|
|
33
|
+
getMaxConcurrent() {
|
|
34
|
+
return this.maxConcurrent;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Spawn an agent and return its ID immediately (for background use).
|
|
38
|
+
* If the concurrency limit is reached, the agent is queued.
|
|
39
|
+
*/
|
|
40
|
+
spawn(pi, ctx, type, prompt, options) {
|
|
41
|
+
const id = randomUUID().slice(0, 17);
|
|
42
|
+
const abortController = new AbortController();
|
|
43
|
+
const record = {
|
|
44
|
+
id,
|
|
45
|
+
type,
|
|
46
|
+
description: options.description,
|
|
47
|
+
status: options.isBackground ? "queued" : "running",
|
|
48
|
+
toolUses: 0,
|
|
49
|
+
startedAt: Date.now(),
|
|
50
|
+
abortController,
|
|
51
|
+
};
|
|
52
|
+
this.agents.set(id, record);
|
|
53
|
+
const args = { pi, ctx, type, prompt, options };
|
|
54
|
+
if (options.isBackground && this.runningBackground >= this.maxConcurrent) {
|
|
55
|
+
// Queue it — will be started when a running agent completes
|
|
56
|
+
this.queue.push({ id, args });
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
this.startAgent(id, record, args);
|
|
60
|
+
return id;
|
|
61
|
+
}
|
|
62
|
+
/** Actually start an agent (called immediately or from queue drain). */
|
|
63
|
+
startAgent(id, record, { pi, ctx, type, prompt, options }) {
|
|
64
|
+
record.status = "running";
|
|
65
|
+
record.startedAt = Date.now();
|
|
66
|
+
if (options.isBackground)
|
|
67
|
+
this.runningBackground++;
|
|
68
|
+
const promise = runAgent(ctx, type, prompt, {
|
|
69
|
+
pi,
|
|
70
|
+
model: options.model,
|
|
71
|
+
maxTurns: options.maxTurns,
|
|
72
|
+
isolated: options.isolated,
|
|
73
|
+
inheritContext: options.inheritContext,
|
|
74
|
+
thinkingLevel: options.thinkingLevel,
|
|
75
|
+
signal: record.abortController.signal,
|
|
76
|
+
onToolActivity: (activity) => {
|
|
77
|
+
if (activity.type === "end")
|
|
78
|
+
record.toolUses++;
|
|
79
|
+
options.onToolActivity?.(activity);
|
|
80
|
+
},
|
|
81
|
+
onTextDelta: options.onTextDelta,
|
|
82
|
+
onSessionCreated: (session) => {
|
|
83
|
+
record.session = session;
|
|
84
|
+
options.onSessionCreated?.(session);
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
.then(({ responseText, session, aborted, steered }) => {
|
|
88
|
+
// Don't overwrite status if externally stopped via abort()
|
|
89
|
+
if (record.status !== "stopped") {
|
|
90
|
+
record.status = aborted ? "aborted" : steered ? "steered" : "completed";
|
|
91
|
+
}
|
|
92
|
+
record.result = responseText;
|
|
93
|
+
record.session = session;
|
|
94
|
+
record.completedAt ??= Date.now();
|
|
95
|
+
if (options.isBackground) {
|
|
96
|
+
this.runningBackground--;
|
|
97
|
+
this.onComplete?.(record);
|
|
98
|
+
this.drainQueue();
|
|
99
|
+
}
|
|
100
|
+
return responseText;
|
|
101
|
+
})
|
|
102
|
+
.catch((err) => {
|
|
103
|
+
// Don't overwrite status if externally stopped via abort()
|
|
104
|
+
if (record.status !== "stopped") {
|
|
105
|
+
record.status = "error";
|
|
106
|
+
}
|
|
107
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
108
|
+
record.completedAt ??= Date.now();
|
|
109
|
+
if (options.isBackground) {
|
|
110
|
+
this.runningBackground--;
|
|
111
|
+
this.onComplete?.(record);
|
|
112
|
+
this.drainQueue();
|
|
113
|
+
}
|
|
114
|
+
return "";
|
|
115
|
+
});
|
|
116
|
+
record.promise = promise;
|
|
117
|
+
}
|
|
118
|
+
/** Start queued agents up to the concurrency limit. */
|
|
119
|
+
drainQueue() {
|
|
120
|
+
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
|
|
121
|
+
const next = this.queue.shift();
|
|
122
|
+
const record = this.agents.get(next.id);
|
|
123
|
+
if (!record || record.status !== "queued")
|
|
124
|
+
continue;
|
|
125
|
+
this.startAgent(next.id, record, next.args);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Spawn an agent and wait for completion (foreground use).
|
|
130
|
+
* Foreground agents bypass the concurrency queue.
|
|
131
|
+
*/
|
|
132
|
+
async spawnAndWait(pi, ctx, type, prompt, options) {
|
|
133
|
+
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
|
|
134
|
+
const record = this.agents.get(id);
|
|
135
|
+
await record.promise;
|
|
136
|
+
return record;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resume an existing agent session with a new prompt.
|
|
140
|
+
*/
|
|
141
|
+
async resume(id, prompt, signal) {
|
|
142
|
+
const record = this.agents.get(id);
|
|
143
|
+
if (!record?.session)
|
|
144
|
+
return undefined;
|
|
145
|
+
record.status = "running";
|
|
146
|
+
record.startedAt = Date.now();
|
|
147
|
+
record.completedAt = undefined;
|
|
148
|
+
record.result = undefined;
|
|
149
|
+
record.error = undefined;
|
|
150
|
+
try {
|
|
151
|
+
const responseText = await resumeAgent(record.session, prompt, {
|
|
152
|
+
onToolActivity: (activity) => {
|
|
153
|
+
if (activity.type === "end")
|
|
154
|
+
record.toolUses++;
|
|
155
|
+
},
|
|
156
|
+
signal,
|
|
157
|
+
});
|
|
158
|
+
record.status = "completed";
|
|
159
|
+
record.result = responseText;
|
|
160
|
+
record.completedAt = Date.now();
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
record.status = "error";
|
|
164
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
165
|
+
record.completedAt = Date.now();
|
|
166
|
+
}
|
|
167
|
+
return record;
|
|
168
|
+
}
|
|
169
|
+
getRecord(id) {
|
|
170
|
+
return this.agents.get(id);
|
|
171
|
+
}
|
|
172
|
+
listAgents() {
|
|
173
|
+
return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
174
|
+
}
|
|
175
|
+
abort(id) {
|
|
176
|
+
const record = this.agents.get(id);
|
|
177
|
+
if (!record)
|
|
178
|
+
return false;
|
|
179
|
+
// Remove from queue if queued
|
|
180
|
+
if (record.status === "queued") {
|
|
181
|
+
this.queue = this.queue.filter(q => q.id !== id);
|
|
182
|
+
record.status = "stopped";
|
|
183
|
+
record.completedAt = Date.now();
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (record.status !== "running")
|
|
187
|
+
return false;
|
|
188
|
+
record.abortController?.abort();
|
|
189
|
+
record.status = "stopped";
|
|
190
|
+
record.completedAt = Date.now();
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
cleanup() {
|
|
194
|
+
const cutoff = Date.now() - 10 * 60_000;
|
|
195
|
+
for (const [id, record] of this.agents) {
|
|
196
|
+
if (record.status === "running" || record.status === "queued")
|
|
197
|
+
continue;
|
|
198
|
+
if ((record.completedAt ?? 0) >= cutoff)
|
|
199
|
+
continue;
|
|
200
|
+
// Dispose and clear session so memory can be reclaimed
|
|
201
|
+
if (record.session) {
|
|
202
|
+
record.session.dispose();
|
|
203
|
+
record.session = undefined;
|
|
204
|
+
}
|
|
205
|
+
this.agents.delete(id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Whether any agents are still running or queued. */
|
|
209
|
+
hasRunning() {
|
|
210
|
+
return [...this.agents.values()].some(r => r.status === "running" || r.status === "queued");
|
|
211
|
+
}
|
|
212
|
+
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
213
|
+
async waitForAll() {
|
|
214
|
+
// Loop because drainQueue respects the concurrency limit — as running
|
|
215
|
+
// agents finish they start queued ones, which need awaiting too.
|
|
216
|
+
while (true) {
|
|
217
|
+
this.drainQueue();
|
|
218
|
+
const pending = [...this.agents.values()]
|
|
219
|
+
.filter(r => r.status === "running" || r.status === "queued")
|
|
220
|
+
.map(r => r.promise)
|
|
221
|
+
.filter(Boolean);
|
|
222
|
+
if (pending.length === 0)
|
|
223
|
+
break;
|
|
224
|
+
await Promise.allSettled(pending);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
dispose() {
|
|
228
|
+
clearInterval(this.cleanupInterval);
|
|
229
|
+
// Clear queue
|
|
230
|
+
this.queue = [];
|
|
231
|
+
for (const record of this.agents.values()) {
|
|
232
|
+
record.session?.dispose();
|
|
233
|
+
}
|
|
234
|
+
this.agents.clear();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
|
|
3
|
+
*/
|
|
4
|
+
import { type AgentSession, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
7
|
+
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
8
|
+
/** Get the default max turns value. */
|
|
9
|
+
export declare function getDefaultMaxTurns(): number;
|
|
10
|
+
/** Set the default max turns value (minimum 1). */
|
|
11
|
+
export declare function setDefaultMaxTurns(n: number): void;
|
|
12
|
+
/** Get the grace turns value. */
|
|
13
|
+
export declare function getGraceTurns(): number;
|
|
14
|
+
/** Set the grace turns value (minimum 1). */
|
|
15
|
+
export declare function setGraceTurns(n: number): void;
|
|
16
|
+
/** Info about a tool event in the subagent. */
|
|
17
|
+
export interface ToolActivity {
|
|
18
|
+
type: "start" | "end";
|
|
19
|
+
toolName: string;
|
|
20
|
+
}
|
|
21
|
+
export interface RunOptions {
|
|
22
|
+
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
|
|
23
|
+
pi: ExtensionAPI;
|
|
24
|
+
model?: Model<any>;
|
|
25
|
+
maxTurns?: number;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
isolated?: boolean;
|
|
28
|
+
inheritContext?: boolean;
|
|
29
|
+
thinkingLevel?: ThinkingLevel;
|
|
30
|
+
/** Called on tool start/end with activity info. */
|
|
31
|
+
onToolActivity?: (activity: ToolActivity) => void;
|
|
32
|
+
/** Called on streaming text deltas from the assistant response. */
|
|
33
|
+
onTextDelta?: (delta: string, fullText: string) => void;
|
|
34
|
+
onSessionCreated?: (session: AgentSession) => void;
|
|
35
|
+
}
|
|
36
|
+
export interface RunResult {
|
|
37
|
+
responseText: string;
|
|
38
|
+
session: AgentSession;
|
|
39
|
+
/** True if the agent was hard-aborted (max_turns + grace exceeded). */
|
|
40
|
+
aborted: boolean;
|
|
41
|
+
/** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
|
|
42
|
+
steered: boolean;
|
|
43
|
+
}
|
|
44
|
+
export declare function runAgent(ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
|
|
45
|
+
/**
|
|
46
|
+
* Send a new prompt to an existing session (resume).
|
|
47
|
+
*/
|
|
48
|
+
export declare function resumeAgent(session: AgentSession, prompt: string, options?: {
|
|
49
|
+
onToolActivity?: (activity: ToolActivity) => void;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}): Promise<string>;
|
|
52
|
+
/**
|
|
53
|
+
* Send a steering message to a running subagent.
|
|
54
|
+
* The message will interrupt the agent after its current tool execution.
|
|
55
|
+
*/
|
|
56
|
+
export declare function steerAgent(session: AgentSession, message: string): Promise<void>;
|
|
57
|
+
/**
|
|
58
|
+
* Get the subagent's conversation messages as formatted text.
|
|
59
|
+
*/
|
|
60
|
+
export declare function getAgentConversation(session: AgentSession): string;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
|
|
3
|
+
*/
|
|
4
|
+
import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { getToolsForType, getConfig, getAgentConfig } from "./agent-types.js";
|
|
6
|
+
import { buildAgentPrompt } from "./prompts.js";
|
|
7
|
+
import { buildParentContext, extractText } from "./context.js";
|
|
8
|
+
import { detectEnv } from "./env.js";
|
|
9
|
+
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
10
|
+
const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
|
|
11
|
+
/** Default max turns to prevent subagents from looping indefinitely. */
|
|
12
|
+
let defaultMaxTurns = 50;
|
|
13
|
+
/** Get the default max turns value. */
|
|
14
|
+
export function getDefaultMaxTurns() { return defaultMaxTurns; }
|
|
15
|
+
/** Set the default max turns value (minimum 1). */
|
|
16
|
+
export function setDefaultMaxTurns(n) { defaultMaxTurns = Math.max(1, n); }
|
|
17
|
+
/** Additional turns allowed after the soft limit steer message. */
|
|
18
|
+
let graceTurns = 5;
|
|
19
|
+
/** Get the grace turns value. */
|
|
20
|
+
export function getGraceTurns() { return graceTurns; }
|
|
21
|
+
/** Set the grace turns value (minimum 1). */
|
|
22
|
+
export function setGraceTurns(n) { graceTurns = Math.max(1, n); }
|
|
23
|
+
/**
|
|
24
|
+
* Try to find the right model for an agent type.
|
|
25
|
+
* Priority: explicit option > config.model > parent model.
|
|
26
|
+
*/
|
|
27
|
+
function resolveDefaultModel(parentModel, registry, configModel) {
|
|
28
|
+
if (configModel) {
|
|
29
|
+
const slashIdx = configModel.indexOf("/");
|
|
30
|
+
if (slashIdx !== -1) {
|
|
31
|
+
const provider = configModel.slice(0, slashIdx);
|
|
32
|
+
const modelId = configModel.slice(slashIdx + 1);
|
|
33
|
+
// Build a set of available model keys for fast lookup
|
|
34
|
+
const available = registry.getAvailable?.();
|
|
35
|
+
const availableKeys = available
|
|
36
|
+
? new Set(available.map((m) => `${m.provider}/${m.id}`))
|
|
37
|
+
: undefined;
|
|
38
|
+
const isAvailable = (p, id) => !availableKeys || availableKeys.has(`${p}/${id}`);
|
|
39
|
+
const found = registry.find(provider, modelId);
|
|
40
|
+
if (found && isAvailable(provider, modelId))
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return parentModel;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to a session and collect the last assistant message text.
|
|
48
|
+
* Returns an object with a `getText()` getter and an `unsubscribe` function.
|
|
49
|
+
*/
|
|
50
|
+
function collectResponseText(session) {
|
|
51
|
+
let text = "";
|
|
52
|
+
const unsubscribe = session.subscribe((event) => {
|
|
53
|
+
if (event.type === "message_start") {
|
|
54
|
+
text = "";
|
|
55
|
+
}
|
|
56
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
57
|
+
text += event.assistantMessageEvent.delta;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
return { getText: () => text, unsubscribe };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Wire an AbortSignal to abort a session.
|
|
64
|
+
* Returns a cleanup function to remove the listener.
|
|
65
|
+
*/
|
|
66
|
+
function forwardAbortSignal(session, signal) {
|
|
67
|
+
if (!signal)
|
|
68
|
+
return () => { };
|
|
69
|
+
const onAbort = () => session.abort();
|
|
70
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
71
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
72
|
+
}
|
|
73
|
+
export async function runAgent(ctx, type, prompt, options) {
|
|
74
|
+
const config = getConfig(type);
|
|
75
|
+
const agentConfig = getAgentConfig(type);
|
|
76
|
+
const env = await detectEnv(options.pi, ctx.cwd);
|
|
77
|
+
// Get parent system prompt for append-mode agents
|
|
78
|
+
const parentSystemPrompt = ctx.getSystemPrompt();
|
|
79
|
+
// Build system prompt from agent config
|
|
80
|
+
let systemPrompt;
|
|
81
|
+
if (agentConfig) {
|
|
82
|
+
systemPrompt = buildAgentPrompt(agentConfig, ctx.cwd, env, parentSystemPrompt);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Unknown type fallback: general-purpose (defensive — unreachable in practice
|
|
86
|
+
// since index.ts resolves unknown types to "general-purpose" before calling runAgent)
|
|
87
|
+
systemPrompt = buildAgentPrompt({
|
|
88
|
+
name: type,
|
|
89
|
+
description: "General-purpose agent",
|
|
90
|
+
systemPrompt: "",
|
|
91
|
+
promptMode: "append",
|
|
92
|
+
extensions: true,
|
|
93
|
+
skills: true,
|
|
94
|
+
inheritContext: false,
|
|
95
|
+
runInBackground: false,
|
|
96
|
+
isolated: false,
|
|
97
|
+
}, ctx.cwd, env, parentSystemPrompt);
|
|
98
|
+
}
|
|
99
|
+
const tools = getToolsForType(type, ctx.cwd);
|
|
100
|
+
// Resolve extensions/skills: isolated overrides to false
|
|
101
|
+
const extensions = options.isolated ? false : config.extensions;
|
|
102
|
+
const skills = options.isolated ? false : config.skills;
|
|
103
|
+
// Load extensions/skills: true or string[] → load; false → don't
|
|
104
|
+
const loader = new DefaultResourceLoader({
|
|
105
|
+
cwd: ctx.cwd,
|
|
106
|
+
noExtensions: extensions === false,
|
|
107
|
+
noSkills: skills === false,
|
|
108
|
+
noPromptTemplates: true,
|
|
109
|
+
noThemes: true,
|
|
110
|
+
systemPromptOverride: () => systemPrompt,
|
|
111
|
+
});
|
|
112
|
+
await loader.reload();
|
|
113
|
+
// Resolve model: explicit option > config.model > parent model
|
|
114
|
+
const model = options.model ?? resolveDefaultModel(ctx.model, ctx.modelRegistry, agentConfig?.model);
|
|
115
|
+
// Resolve thinking level: explicit option > agent config > undefined (inherit)
|
|
116
|
+
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
|
|
117
|
+
const sessionOpts = {
|
|
118
|
+
cwd: ctx.cwd,
|
|
119
|
+
sessionManager: SessionManager.inMemory(ctx.cwd),
|
|
120
|
+
settingsManager: SettingsManager.create(),
|
|
121
|
+
modelRegistry: ctx.modelRegistry,
|
|
122
|
+
model,
|
|
123
|
+
tools,
|
|
124
|
+
resourceLoader: loader,
|
|
125
|
+
};
|
|
126
|
+
if (thinkingLevel) {
|
|
127
|
+
sessionOpts.thinkingLevel = thinkingLevel;
|
|
128
|
+
}
|
|
129
|
+
// createAgentSession's type signature may not include thinkingLevel yet
|
|
130
|
+
const { session } = await createAgentSession(sessionOpts);
|
|
131
|
+
// Filter active tools: remove our own tools to prevent nesting,
|
|
132
|
+
// and apply extension allowlist if specified
|
|
133
|
+
if (extensions !== false) {
|
|
134
|
+
const builtinToolNames = new Set(tools.map(t => t.name));
|
|
135
|
+
const activeTools = session.getActiveToolNames().filter((t) => {
|
|
136
|
+
if (EXCLUDED_TOOL_NAMES.includes(t))
|
|
137
|
+
return false;
|
|
138
|
+
if (builtinToolNames.has(t))
|
|
139
|
+
return true;
|
|
140
|
+
if (Array.isArray(extensions)) {
|
|
141
|
+
return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
session.setActiveToolsByName(activeTools);
|
|
146
|
+
}
|
|
147
|
+
options.onSessionCreated?.(session);
|
|
148
|
+
// Track turns for graceful max_turns enforcement
|
|
149
|
+
let turnCount = 0;
|
|
150
|
+
const maxTurns = options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns;
|
|
151
|
+
let softLimitReached = false;
|
|
152
|
+
let aborted = false;
|
|
153
|
+
let currentMessageText = "";
|
|
154
|
+
const unsubTurns = session.subscribe((event) => {
|
|
155
|
+
if (event.type === "turn_end") {
|
|
156
|
+
turnCount++;
|
|
157
|
+
if (!softLimitReached && turnCount >= maxTurns) {
|
|
158
|
+
softLimitReached = true;
|
|
159
|
+
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
|
|
160
|
+
}
|
|
161
|
+
else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
162
|
+
aborted = true;
|
|
163
|
+
session.abort();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (event.type === "message_start") {
|
|
167
|
+
currentMessageText = "";
|
|
168
|
+
}
|
|
169
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
170
|
+
currentMessageText += event.assistantMessageEvent.delta;
|
|
171
|
+
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
|
|
172
|
+
}
|
|
173
|
+
if (event.type === "tool_execution_start") {
|
|
174
|
+
options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
175
|
+
}
|
|
176
|
+
if (event.type === "tool_execution_end") {
|
|
177
|
+
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const collector = collectResponseText(session);
|
|
181
|
+
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
182
|
+
// Build the effective prompt: optionally prepend parent context
|
|
183
|
+
let effectivePrompt = prompt;
|
|
184
|
+
if (options.inheritContext) {
|
|
185
|
+
const parentContext = buildParentContext(ctx);
|
|
186
|
+
if (parentContext) {
|
|
187
|
+
effectivePrompt = parentContext + prompt;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await session.prompt(effectivePrompt);
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
unsubTurns();
|
|
195
|
+
collector.unsubscribe();
|
|
196
|
+
cleanupAbort();
|
|
197
|
+
}
|
|
198
|
+
return { responseText: collector.getText(), session, aborted, steered: softLimitReached };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Send a new prompt to an existing session (resume).
|
|
202
|
+
*/
|
|
203
|
+
export async function resumeAgent(session, prompt, options = {}) {
|
|
204
|
+
const collector = collectResponseText(session);
|
|
205
|
+
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
206
|
+
const unsubToolUse = options.onToolActivity
|
|
207
|
+
? session.subscribe((event) => {
|
|
208
|
+
if (event.type === "tool_execution_start")
|
|
209
|
+
options.onToolActivity({ type: "start", toolName: event.toolName });
|
|
210
|
+
if (event.type === "tool_execution_end")
|
|
211
|
+
options.onToolActivity({ type: "end", toolName: event.toolName });
|
|
212
|
+
})
|
|
213
|
+
: () => { };
|
|
214
|
+
try {
|
|
215
|
+
await session.prompt(prompt);
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
collector.unsubscribe();
|
|
219
|
+
unsubToolUse();
|
|
220
|
+
cleanupAbort();
|
|
221
|
+
}
|
|
222
|
+
return collector.getText();
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Send a steering message to a running subagent.
|
|
226
|
+
* The message will interrupt the agent after its current tool execution.
|
|
227
|
+
*/
|
|
228
|
+
export async function steerAgent(session, message) {
|
|
229
|
+
await session.steer(message);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Get the subagent's conversation messages as formatted text.
|
|
233
|
+
*/
|
|
234
|
+
export function getAgentConversation(session) {
|
|
235
|
+
const parts = [];
|
|
236
|
+
for (const msg of session.messages) {
|
|
237
|
+
if (msg.role === "user") {
|
|
238
|
+
const text = typeof msg.content === "string"
|
|
239
|
+
? msg.content
|
|
240
|
+
: extractText(msg.content);
|
|
241
|
+
if (text.trim())
|
|
242
|
+
parts.push(`[User]: ${text.trim()}`);
|
|
243
|
+
}
|
|
244
|
+
else if (msg.role === "assistant") {
|
|
245
|
+
const textParts = [];
|
|
246
|
+
const toolCalls = [];
|
|
247
|
+
for (const c of msg.content) {
|
|
248
|
+
if (c.type === "text" && c.text)
|
|
249
|
+
textParts.push(c.text);
|
|
250
|
+
else if (c.type === "toolCall")
|
|
251
|
+
toolCalls.push(` Tool: ${c.toolName ?? "unknown"}`);
|
|
252
|
+
}
|
|
253
|
+
if (textParts.length > 0)
|
|
254
|
+
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
|
255
|
+
if (toolCalls.length > 0)
|
|
256
|
+
parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
|
|
257
|
+
}
|
|
258
|
+
else if (msg.role === "toolResult") {
|
|
259
|
+
const text = extractText(msg.content);
|
|
260
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
|
|
261
|
+
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return parts.join("\n\n");
|
|
265
|
+
}
|