@yandy0725/pi-subagents 0.1.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 +155 -0
- package/README.zh.md +155 -0
- package/index.ts +1 -0
- package/package.json +49 -0
- package/src/config/agent-types.ts +127 -0
- package/src/config/custom-agents.ts +109 -0
- package/src/config/default-agents.ts +117 -0
- package/src/config/invocation-config.ts +30 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +49 -0
- package/src/handlers/lifecycle.ts +63 -0
- package/src/handlers/tool-start.ts +32 -0
- package/src/index.ts +186 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +88 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +240 -0
- package/src/lifecycle/parent-snapshot.ts +45 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/subagent-manager.ts +353 -0
- package/src/lifecycle/subagent-session.ts +232 -0
- package/src/lifecycle/subagent-state.ts +216 -0
- package/src/lifecycle/subagent.ts +498 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +65 -0
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/lifecycle/workspace.ts +47 -0
- package/src/observation/composite-subagent-observer.ts +49 -0
- package/src/observation/notification-state.ts +27 -0
- package/src/observation/notification.ts +186 -0
- package/src/observation/record-observer.ts +75 -0
- package/src/observation/renderer.ts +63 -0
- package/src/observation/subagent-events-observer.ts +94 -0
- package/src/runtime.ts +77 -0
- package/src/service/service-adapter.ts +131 -0
- package/src/service/service.ts +123 -0
- package/src/session/content-items.ts +51 -0
- package/src/session/context.ts +78 -0
- package/src/session/conversation.ts +44 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +121 -0
- package/src/session/prompts.ts +83 -0
- package/src/session/session-config.ts +172 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +227 -0
- package/src/tools/agent-tool.ts +220 -0
- package/src/tools/background-spawner.ts +66 -0
- package/src/tools/foreground-runner.ts +114 -0
- package/src/tools/get-result-tool.ts +120 -0
- package/src/tools/helpers.ts +105 -0
- package/src/tools/result-renderer.ts +109 -0
- package/src/tools/spawn-config.ts +150 -0
- package/src/tools/steer-tool.ts +90 -0
- package/src/types.ts +115 -0
- package/src/ui/agent-widget.ts +311 -0
- package/src/ui/display.ts +174 -0
- package/src/ui/session-navigation.ts +147 -0
- package/src/ui/session-navigator.ts +406 -0
- package/src/ui/subagents-settings.ts +77 -0
- package/src/ui/widget-renderer.ts +296 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-state.ts — SubagentState value object: lifecycle status, metrics, and live activity.
|
|
3
|
+
*
|
|
4
|
+
* Owns the passive, readable state of a subagent — status, result, error,
|
|
5
|
+
* timestamps, stats (toolUses, lifetimeUsage, compactionCount), and live-activity
|
|
6
|
+
* fields (turnCount, activeTools, responseText) — together with the transition
|
|
7
|
+
* methods (markRunning, markCompleted, …), accumulation methods
|
|
8
|
+
* (incrementToolUses, addUsage, incrementCompactions), and live-activity
|
|
9
|
+
* transition methods (incrementTurnCount, addActiveTool, removeActiveTool,
|
|
10
|
+
* resetResponseText, appendResponseText) that mutate them.
|
|
11
|
+
*
|
|
12
|
+
* State is encapsulated behind getters; external code reads through them but
|
|
13
|
+
* mutates only via the transition/accumulation methods. The value object owns
|
|
14
|
+
* all of its own mutations — no field is written from outside.
|
|
15
|
+
*
|
|
16
|
+
* Subagent holds one of these privately and delegates its getters and mutation
|
|
17
|
+
* methods to it. Extracting it lets the lifecycle state machine and the
|
|
18
|
+
* session-event observer be unit-tested without constructing an executor.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { LifetimeUsage } from "../lifecycle/usage";
|
|
22
|
+
import { addUsage } from "../lifecycle/usage";
|
|
23
|
+
|
|
24
|
+
export type SubagentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
25
|
+
|
|
26
|
+
export interface SubagentStateInit {
|
|
27
|
+
status?: SubagentStatus;
|
|
28
|
+
result?: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
startedAt?: number;
|
|
31
|
+
completedAt?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class SubagentState {
|
|
35
|
+
// Transition state — encapsulated behind getters, mutated only via transition methods
|
|
36
|
+
private _status: SubagentStatus;
|
|
37
|
+
get status(): SubagentStatus {
|
|
38
|
+
return this._status;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private _result?: string;
|
|
42
|
+
get result(): string | undefined {
|
|
43
|
+
return this._result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private _error?: string;
|
|
47
|
+
get error(): string | undefined {
|
|
48
|
+
return this._error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private _startedAt: number;
|
|
52
|
+
get startedAt(): number {
|
|
53
|
+
return this._startedAt;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private _completedAt?: number;
|
|
57
|
+
get completedAt(): number | undefined {
|
|
58
|
+
return this._completedAt;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Stats — accumulated via mutation methods, readable via getters
|
|
62
|
+
private _toolUses = 0;
|
|
63
|
+
get toolUses(): number {
|
|
64
|
+
return this._toolUses;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private _lifetimeUsage: LifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
|
|
68
|
+
get lifetimeUsage(): Readonly<LifetimeUsage> {
|
|
69
|
+
return this._lifetimeUsage;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private _compactionCount = 0;
|
|
73
|
+
get compactionCount(): number {
|
|
74
|
+
return this._compactionCount;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Live activity — accumulated via transition methods, readable via getters
|
|
78
|
+
private _turnCount = 1;
|
|
79
|
+
get turnCount(): number {
|
|
80
|
+
return this._turnCount;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private _activeTools = new Map<string, string>();
|
|
84
|
+
get activeTools(): ReadonlyMap<string, string> {
|
|
85
|
+
return this._activeTools;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private _toolKeySeq = 0;
|
|
89
|
+
|
|
90
|
+
private _responseText = "";
|
|
91
|
+
get responseText(): string {
|
|
92
|
+
return this._responseText;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
constructor(init: SubagentStateInit = {}) {
|
|
96
|
+
this._status = init.status ?? "queued";
|
|
97
|
+
this._result = init.result;
|
|
98
|
+
this._error = init.error;
|
|
99
|
+
this._startedAt = init.startedAt ?? Date.now();
|
|
100
|
+
this._completedAt = init.completedAt;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Increment tool use count. Called by record-observer on tool_execution_end. */
|
|
104
|
+
incrementToolUses(): void {
|
|
105
|
+
this._toolUses++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */
|
|
109
|
+
addUsage(delta: { input: number; output: number; cacheWrite: number }): void {
|
|
110
|
+
addUsage(this._lifetimeUsage, delta);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Increment compaction count. Called by record-observer on compaction_end. */
|
|
114
|
+
incrementCompactions(): void {
|
|
115
|
+
this._compactionCount++;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Record a turn boundary. Called by record-observer on turn_end. */
|
|
119
|
+
incrementTurnCount(): void {
|
|
120
|
+
this._turnCount++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Record a tool starting. Called by record-observer on tool_execution_start. */
|
|
124
|
+
addActiveTool(toolName: string): void {
|
|
125
|
+
this._activeTools.set(toolName + "_" + ++this._toolKeySeq, toolName);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Remove one active tool by name (first match). Called by record-observer on tool_execution_end. */
|
|
129
|
+
removeActiveTool(toolName: string): void {
|
|
130
|
+
for (const [key, name] of this._activeTools) {
|
|
131
|
+
if (name === toolName) {
|
|
132
|
+
this._activeTools.delete(key);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Reset the current response text. Called by record-observer on message_start. */
|
|
139
|
+
resetResponseText(): void {
|
|
140
|
+
this._responseText = "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Append a text delta to the current response text. Called by record-observer on message_update. */
|
|
144
|
+
appendResponseText(delta: string): void {
|
|
145
|
+
this._responseText += delta;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Transition to running state. Sets status and startedAt. */
|
|
149
|
+
markRunning(startedAt: number): void {
|
|
150
|
+
this._status = "running";
|
|
151
|
+
this._startedAt = startedAt;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Transition to completed state.
|
|
156
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
157
|
+
*/
|
|
158
|
+
markCompleted(result: string, completedAt?: number): void {
|
|
159
|
+
this._result = result;
|
|
160
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
161
|
+
if (this._status !== "stopped") {
|
|
162
|
+
this._status = "completed";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Transition to aborted state.
|
|
168
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
169
|
+
*/
|
|
170
|
+
markAborted(result: string, completedAt?: number): void {
|
|
171
|
+
this._result = result;
|
|
172
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
173
|
+
if (this._status !== "stopped") {
|
|
174
|
+
this._status = "aborted";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Transition to steered state.
|
|
180
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
181
|
+
*/
|
|
182
|
+
markSteered(result: string, completedAt?: number): void {
|
|
183
|
+
this._result = result;
|
|
184
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
185
|
+
if (this._status !== "stopped") {
|
|
186
|
+
this._status = "steered";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Transition to error state.
|
|
192
|
+
* Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
|
|
193
|
+
*/
|
|
194
|
+
markError(error: unknown, completedAt?: number): void {
|
|
195
|
+
this._error = error instanceof Error ? error.message : String(error);
|
|
196
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
197
|
+
if (this._status !== "stopped") {
|
|
198
|
+
this._status = "error";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Transition to stopped state. Always valid — no guard. */
|
|
203
|
+
markStopped(completedAt?: number): void {
|
|
204
|
+
this._status = "stopped";
|
|
205
|
+
this._completedAt = completedAt ?? Date.now();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
|
|
209
|
+
resetForResume(startedAt: number): void {
|
|
210
|
+
this._status = "running";
|
|
211
|
+
this._startedAt = startedAt;
|
|
212
|
+
this._completedAt = undefined;
|
|
213
|
+
this._result = undefined;
|
|
214
|
+
this._error = undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent.ts — Subagent class: identity, lifecycle status, and per-subagent behavior.
|
|
3
|
+
*
|
|
4
|
+
* Status/stats are delegated to the SubagentState value object; listener
|
|
5
|
+
* lifecycle to RunListeners; workspace prepare/dispose to WorkspaceBracket.
|
|
6
|
+
* Behavior (abort, steer buffering) lives here rather than on SubagentManager.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
+
import type { AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { debugLog } from "../debug";
|
|
12
|
+
import type { CreateSubagentSessionParams } from "../lifecycle/create-subagent-session";
|
|
13
|
+
import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
|
|
14
|
+
import { RunListeners } from "../lifecycle/run-listeners";
|
|
15
|
+
import type { SubagentSession, TurnLoopResult } from "../lifecycle/subagent-session";
|
|
16
|
+
import { SubagentState, type SubagentStatus } from "../lifecycle/subagent-state";
|
|
17
|
+
import type { LifetimeUsage } from "../lifecycle/usage";
|
|
18
|
+
import type { WorkspaceProvider } from "../lifecycle/workspace";
|
|
19
|
+
import { WorkspaceBracket } from "../lifecycle/workspace-bracket";
|
|
20
|
+
import { NotificationState } from "../observation/notification-state";
|
|
21
|
+
import { subscribeSubagentObserver } from "../observation/record-observer";
|
|
22
|
+
import type { RunConfig } from "../runtime";
|
|
23
|
+
import type {
|
|
24
|
+
AgentInvocation,
|
|
25
|
+
CompactionInfo,
|
|
26
|
+
ParentSessionInfo,
|
|
27
|
+
SessionMessage,
|
|
28
|
+
SubagentType,
|
|
29
|
+
ThinkingLevel,
|
|
30
|
+
} from "../types";
|
|
31
|
+
|
|
32
|
+
/** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
|
|
33
|
+
export interface SubagentLifecycleObserver {
|
|
34
|
+
/** Fires when the subagent transitions to running (inside run(), after markRunning). */
|
|
35
|
+
onStarted?(agent: Subagent): void;
|
|
36
|
+
/** Fires once the session is created — the subagent's subagentSession is now available. */
|
|
37
|
+
onSessionCreated?(agent: Subagent): void;
|
|
38
|
+
/** Fires once when the run completes or fails (for concurrency drain). */
|
|
39
|
+
onRunFinished?(agent: Subagent): void;
|
|
40
|
+
/** Fires on compaction events during the run. */
|
|
41
|
+
onCompacted?(agent: Subagent, info: CompactionInfo): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type { SubagentStatus } from "../lifecycle/subagent-state";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The execution machinery a Subagent needs to run. A single mandatory
|
|
48
|
+
* collaborator: production (SubagentManager.spawn) always supplies it, so run()
|
|
49
|
+
* needs no "not configured" guards. The genuinely-optional behavior knobs stay
|
|
50
|
+
* optional; the four inputs run() cannot proceed without are required.
|
|
51
|
+
*/
|
|
52
|
+
export interface SubagentExecution {
|
|
53
|
+
/** Assembly factory that produces a born-complete SubagentSession. */
|
|
54
|
+
createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
55
|
+
/** Immutable spawn-time parent snapshot handed to the session factory. */
|
|
56
|
+
snapshot: ParentSnapshot;
|
|
57
|
+
/** Initial prompt for the turn loop. */
|
|
58
|
+
prompt: string;
|
|
59
|
+
/** Parent working directory handed to a workspace provider's prepare(). */
|
|
60
|
+
baseCwd: string;
|
|
61
|
+
observer?: SubagentLifecycleObserver;
|
|
62
|
+
getRunConfig?: () => RunConfig;
|
|
63
|
+
/** Resolves the registered workspace provider (if any) at run-start. */
|
|
64
|
+
getWorkspaceProvider?: () => WorkspaceProvider | undefined;
|
|
65
|
+
model?: Model<any>;
|
|
66
|
+
maxTurns?: number;
|
|
67
|
+
thinkingLevel?: ThinkingLevel;
|
|
68
|
+
parentSession?: ParentSessionInfo;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SubagentInit {
|
|
73
|
+
// Identity
|
|
74
|
+
id: string;
|
|
75
|
+
type: SubagentType;
|
|
76
|
+
description: string;
|
|
77
|
+
invocation?: AgentInvocation;
|
|
78
|
+
|
|
79
|
+
/** Execution machinery — always supplied; construct-complete, no test fallbacks. */
|
|
80
|
+
execution: SubagentExecution;
|
|
81
|
+
|
|
82
|
+
/** Lifecycle status and metrics. Defaults to a fresh queued state. */
|
|
83
|
+
state?: SubagentState;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class Subagent {
|
|
87
|
+
// Identity — set once at construction
|
|
88
|
+
readonly id: string;
|
|
89
|
+
readonly type: SubagentType;
|
|
90
|
+
readonly description: string;
|
|
91
|
+
readonly invocation?: AgentInvocation;
|
|
92
|
+
|
|
93
|
+
// Lifecycle status and metrics — owned by a private value object; getters and
|
|
94
|
+
// mutation methods below delegate to it one line.
|
|
95
|
+
private readonly state: SubagentState;
|
|
96
|
+
get status(): SubagentStatus {
|
|
97
|
+
return this.state.status;
|
|
98
|
+
}
|
|
99
|
+
get result(): string | undefined {
|
|
100
|
+
return this.state.result;
|
|
101
|
+
}
|
|
102
|
+
get error(): string | undefined {
|
|
103
|
+
return this.state.error;
|
|
104
|
+
}
|
|
105
|
+
get startedAt(): number {
|
|
106
|
+
return this.state.startedAt;
|
|
107
|
+
}
|
|
108
|
+
get completedAt(): number | undefined {
|
|
109
|
+
return this.state.completedAt;
|
|
110
|
+
}
|
|
111
|
+
get toolUses(): number {
|
|
112
|
+
return this.state.toolUses;
|
|
113
|
+
}
|
|
114
|
+
get lifetimeUsage(): Readonly<LifetimeUsage> {
|
|
115
|
+
return this.state.lifetimeUsage;
|
|
116
|
+
}
|
|
117
|
+
get compactionCount(): number {
|
|
118
|
+
return this.state.compactionCount;
|
|
119
|
+
}
|
|
120
|
+
get turnCount(): number {
|
|
121
|
+
return this.state.turnCount;
|
|
122
|
+
}
|
|
123
|
+
get activeTools(): ReadonlyMap<string, string> {
|
|
124
|
+
return this.state.activeTools;
|
|
125
|
+
}
|
|
126
|
+
get responseText(): string {
|
|
127
|
+
return this.state.responseText;
|
|
128
|
+
}
|
|
129
|
+
get maxTurns(): number | undefined {
|
|
130
|
+
return this.execution.maxTurns;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
readonly abortController: AbortController;
|
|
134
|
+
private _promise?: Promise<void>;
|
|
135
|
+
get promise(): Promise<void> | undefined {
|
|
136
|
+
return this._promise;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private readonly execution: SubagentExecution;
|
|
140
|
+
private readonly listeners = new RunListeners();
|
|
141
|
+
private readonly workspaceBracket: WorkspaceBracket;
|
|
142
|
+
|
|
143
|
+
subagentSession?: SubagentSession;
|
|
144
|
+
private _notification?: NotificationState;
|
|
145
|
+
get notification(): NotificationState | undefined {
|
|
146
|
+
return this._notification;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Steer buffer — messages queued before the session is ready
|
|
150
|
+
private _pendingSteers: string[] = [];
|
|
151
|
+
/** Number of steer messages waiting to be delivered. */
|
|
152
|
+
get pendingSteerCount(): number {
|
|
153
|
+
return this._pendingSteers.length;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Path to the agent's session JSONL file, or undefined if not yet available. */
|
|
157
|
+
get outputFile(): string | undefined {
|
|
158
|
+
return this.subagentSession?.outputFile;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Returns true when a SubagentSession is available (session is ready). */
|
|
162
|
+
isSessionReady(): boolean {
|
|
163
|
+
return this.subagentSession != null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Deliver or buffer a steer message.
|
|
168
|
+
* Returns true when delivered immediately; false when buffered for later delivery.
|
|
169
|
+
*/
|
|
170
|
+
async steer(message: string): Promise<boolean> {
|
|
171
|
+
if (!this.subagentSession) {
|
|
172
|
+
this.queueSteer(message);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
await this.subagentSession.steer(message);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Return the session conversation as formatted text, or undefined if no session. */
|
|
180
|
+
getConversation(): string | undefined {
|
|
181
|
+
return this.subagentSession?.getConversation();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Return the session context window utilization (0-100), or null if unavailable. */
|
|
185
|
+
getContextPercent(): number | null {
|
|
186
|
+
return this.subagentSession?.getContextPercent() ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Subscribe to session events for live updates (e.g., conversation viewer).
|
|
191
|
+
* Returns an unsubscribe function, or undefined if no session is available.
|
|
192
|
+
*/
|
|
193
|
+
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined {
|
|
194
|
+
return this.subagentSession?.subscribe(fn);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The session's message history, or an empty array if no session. */
|
|
198
|
+
get messages(): readonly unknown[] {
|
|
199
|
+
return this.subagentSession?.messages ?? [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
|
|
203
|
+
get agentMessages(): readonly SessionMessage[] {
|
|
204
|
+
return this.subagentSession?.agentMessages ?? [];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Resolve a registered tool definition by name, or undefined if no session. */
|
|
208
|
+
getToolDefinition(name: string): ToolDefinition | undefined {
|
|
209
|
+
return this.subagentSession?.getToolDefinition(name);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
constructor(init: SubagentInit) {
|
|
213
|
+
// Identity
|
|
214
|
+
this.id = init.id;
|
|
215
|
+
this.type = init.type;
|
|
216
|
+
this.description = init.description;
|
|
217
|
+
this.invocation = init.invocation;
|
|
218
|
+
|
|
219
|
+
// Lifecycle status and metrics — fresh queued state unless one is supplied
|
|
220
|
+
this.state = init.state ?? new SubagentState();
|
|
221
|
+
|
|
222
|
+
// Abort controller — always created, never injected
|
|
223
|
+
this.abortController = new AbortController();
|
|
224
|
+
|
|
225
|
+
// Execution machinery — a single mandatory collaborator
|
|
226
|
+
this.execution = init.execution;
|
|
227
|
+
|
|
228
|
+
// Per-run lifecycle collaborators
|
|
229
|
+
this.workspaceBracket = new WorkspaceBracket(this.execution.getWorkspaceProvider ?? (() => undefined));
|
|
230
|
+
|
|
231
|
+
// Notification state — created from parentSession.toolCallId if present
|
|
232
|
+
const toolCallId = init.execution.parentSession?.toolCallId;
|
|
233
|
+
if (toolCallId) {
|
|
234
|
+
this._notification = new NotificationState(toolCallId);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Execute the full agent lifecycle: workspace preparation, session creation
|
|
240
|
+
* via the factory, observer wiring, the turn loop, workspace disposal, and
|
|
241
|
+
* status transitions.
|
|
242
|
+
*
|
|
243
|
+
* Execution is supplied at construction (mandatory), so run() needs no
|
|
244
|
+
* "not configured" guards. The returned promise always resolves (errors are
|
|
245
|
+
* captured internally).
|
|
246
|
+
*/
|
|
247
|
+
async run(): Promise<void> {
|
|
248
|
+
this.markRunning(Date.now());
|
|
249
|
+
this.execution.observer?.onStarted?.(this);
|
|
250
|
+
this.listeners.wireSignal(this.execution.signal, () => this.abort());
|
|
251
|
+
|
|
252
|
+
// Guard the await so the no-provider path stays synchronous, preserving
|
|
253
|
+
// the original run() timing: the factory is called in the same turn as
|
|
254
|
+
// spawn() when no workspace provider is registered.
|
|
255
|
+
let cwd: string | undefined;
|
|
256
|
+
if (this.workspaceBracket.hasProvider()) {
|
|
257
|
+
try {
|
|
258
|
+
cwd = await this.workspaceBracket.prepare({
|
|
259
|
+
agentId: this.id,
|
|
260
|
+
agentType: this.type,
|
|
261
|
+
baseCwd: this.execution.baseCwd,
|
|
262
|
+
invocation: this.invocation,
|
|
263
|
+
});
|
|
264
|
+
} catch (err) {
|
|
265
|
+
this.markError(err);
|
|
266
|
+
this.listeners.release();
|
|
267
|
+
this.execution.observer?.onRunFinished?.(this);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
this.subagentSession = await this.execution.createSubagentSession({
|
|
274
|
+
snapshot: this.execution.snapshot,
|
|
275
|
+
type: this.type,
|
|
276
|
+
cwd,
|
|
277
|
+
parentSession: this.execution.parentSession,
|
|
278
|
+
model: this.execution.model,
|
|
279
|
+
thinkingLevel: this.execution.thinkingLevel,
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
// The factory disposed its own session on a post-creation failure.
|
|
283
|
+
this.failRun(err);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.flushPendingSteers();
|
|
288
|
+
this.listeners.attachObserver(
|
|
289
|
+
subscribeSubagentObserver(this.subagentSession, this.state, {
|
|
290
|
+
onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
this.execution.observer?.onSessionCreated?.(this);
|
|
294
|
+
|
|
295
|
+
const runConfig = this.execution.getRunConfig?.();
|
|
296
|
+
try {
|
|
297
|
+
const result = await this.subagentSession.runTurnLoop(this.execution.prompt, {
|
|
298
|
+
maxTurns: this.execution.maxTurns,
|
|
299
|
+
defaultMaxTurns: runConfig?.defaultMaxTurns,
|
|
300
|
+
graceTurns: runConfig?.graceTurns,
|
|
301
|
+
signal: this.abortController.signal,
|
|
302
|
+
});
|
|
303
|
+
this.completeRun(result);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
this.failRun(err);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Start execution immediately (foreground / bypassQueue paths).
|
|
311
|
+
* Stores the run promise so it is awaitable via the `promise` getter.
|
|
312
|
+
*/
|
|
313
|
+
start(): void {
|
|
314
|
+
this._promise = this.guardedRun();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Schedule execution through an external concurrency scheduler (the limiter).
|
|
319
|
+
* Captures the scheduler's promise eagerly, so a still-queued agent is
|
|
320
|
+
* awaitable via the `promise` getter from spawn — not only once its slot opens.
|
|
321
|
+
* The guard in guardedRun() makes an abort-while-queued run a no-op when the
|
|
322
|
+
* slot finally frees.
|
|
323
|
+
*/
|
|
324
|
+
scheduleVia(schedule: (thunk: () => Promise<void>) => Promise<void>): void {
|
|
325
|
+
this._promise = schedule(() => this.guardedRun());
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Run unless the agent left the active set before its slot opened
|
|
330
|
+
* (e.g. abort-while-queued): a non-queued, non-running status resolves
|
|
331
|
+
* immediately without running.
|
|
332
|
+
*/
|
|
333
|
+
private guardedRun(): Promise<void> {
|
|
334
|
+
if (this.status !== "queued" && this.status !== "running") return Promise.resolve();
|
|
335
|
+
return this.run();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Resume an existing session with a new prompt, managing the observer
|
|
340
|
+
* subscription lifecycle internally (same wiring as run()).
|
|
341
|
+
*
|
|
342
|
+
* Requires an existing SubagentSession (set when the original run created it).
|
|
343
|
+
* The returned promise always resolves (errors are captured internally).
|
|
344
|
+
* The parent signal flows straight through to resumeTurnLoop — resume does not
|
|
345
|
+
* route through this.abortController.
|
|
346
|
+
*/
|
|
347
|
+
async resume(prompt: string, signal?: AbortSignal): Promise<void> {
|
|
348
|
+
const subagentSession = this.subagentSession;
|
|
349
|
+
if (!subagentSession) {
|
|
350
|
+
throw new Error("Subagent not configured for resume — missing session");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.resetForResume(Date.now());
|
|
354
|
+
this.listeners.attachObserver(
|
|
355
|
+
subscribeSubagentObserver(subagentSession, this.state, {
|
|
356
|
+
onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const responseText = await subagentSession.resumeTurnLoop(prompt, signal);
|
|
362
|
+
this.markCompleted(responseText);
|
|
363
|
+
} catch (err) {
|
|
364
|
+
this.markError(err);
|
|
365
|
+
} finally {
|
|
366
|
+
this.listeners.release();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Increment tool use count. Called by record-observer on tool_execution_end. */
|
|
371
|
+
incrementToolUses(): void {
|
|
372
|
+
this.state.incrementToolUses();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */
|
|
376
|
+
addUsage(delta: { input: number; output: number; cacheWrite: number }): void {
|
|
377
|
+
this.state.addUsage(delta);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Increment compaction count. Called by record-observer on compaction_end. */
|
|
381
|
+
incrementCompactions(): void {
|
|
382
|
+
this.state.incrementCompactions();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Transition to running state. Sets status and startedAt. */
|
|
386
|
+
markRunning(startedAt: number): void {
|
|
387
|
+
this.state.markRunning(startedAt);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Transition to completed state.
|
|
392
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
393
|
+
*/
|
|
394
|
+
markCompleted(result: string, completedAt?: number): void {
|
|
395
|
+
this.state.markCompleted(result, completedAt);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Transition to aborted state.
|
|
400
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
401
|
+
*/
|
|
402
|
+
markAborted(result: string, completedAt?: number): void {
|
|
403
|
+
this.state.markAborted(result, completedAt);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Transition to steered state.
|
|
408
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
409
|
+
*/
|
|
410
|
+
markSteered(result: string, completedAt?: number): void {
|
|
411
|
+
this.state.markSteered(result, completedAt);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Transition to error state.
|
|
416
|
+
* Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
|
|
417
|
+
*/
|
|
418
|
+
markError(error: unknown, completedAt?: number): void {
|
|
419
|
+
this.state.markError(error, completedAt);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Transition to stopped state. Always valid — no guard. */
|
|
423
|
+
markStopped(completedAt?: number): void {
|
|
424
|
+
this.state.markStopped(completedAt);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Abort a running agent: fire AbortController and transition to stopped.
|
|
429
|
+
* Returns false if the agent is not running.
|
|
430
|
+
* A still-queued agent is stopped by SubagentManager; its scheduled thunk
|
|
431
|
+
* then no-ops on the queued-status guard.
|
|
432
|
+
*/
|
|
433
|
+
abort(): boolean {
|
|
434
|
+
if (this.status !== "running") return false;
|
|
435
|
+
this.abortController.abort();
|
|
436
|
+
this.markStopped();
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Buffer a steer message for delivery once the session is ready.
|
|
442
|
+
* Called internally from steer() before the session is ready.
|
|
443
|
+
*/
|
|
444
|
+
private queueSteer(message: string): void {
|
|
445
|
+
this._pendingSteers.push(message);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Flush all buffered steer messages to the session and clear the buffer.
|
|
450
|
+
* Called once the session is available (inside run()).
|
|
451
|
+
*/
|
|
452
|
+
private flushPendingSteers(): void {
|
|
453
|
+
for (const msg of this._pendingSteers) {
|
|
454
|
+
this.subagentSession?.steer(msg).catch(() => {});
|
|
455
|
+
}
|
|
456
|
+
this._pendingSteers = [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
|
|
460
|
+
resetForResume(startedAt: number): void {
|
|
461
|
+
this.state.resetForResume(startedAt);
|
|
462
|
+
this.listeners.release();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */
|
|
466
|
+
completeRun(result: TurnLoopResult): void {
|
|
467
|
+
this.listeners.release();
|
|
468
|
+
|
|
469
|
+
const finalStatus: SubagentStatus = result.aborted ? "aborted" : result.steered ? "steered" : "completed";
|
|
470
|
+
const finalResult =
|
|
471
|
+
result.responseText + this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
|
|
472
|
+
|
|
473
|
+
if (result.aborted) this.markAborted(finalResult);
|
|
474
|
+
else if (result.steered) this.markSteered(finalResult);
|
|
475
|
+
else this.markCompleted(finalResult);
|
|
476
|
+
|
|
477
|
+
this.execution.observer?.onRunFinished?.(this);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Dispose the wrapped session, firing the `disposed` lifecycle event. */
|
|
481
|
+
disposeSession(): void {
|
|
482
|
+
this.subagentSession?.dispose();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
|
|
486
|
+
failRun(err: unknown): void {
|
|
487
|
+
this.markError(err);
|
|
488
|
+
this.listeners.release();
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
this.workspaceBracket.dispose({ status: "error", description: this.description });
|
|
492
|
+
} catch (cleanupErr) {
|
|
493
|
+
debugLog("workspace dispose on agent error", cleanupErr);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
this.execution.observer?.onRunFinished?.(this);
|
|
497
|
+
}
|
|
498
|
+
}
|