@oai404iao/pi-subagent 0.3.0 → 0.4.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 +294 -89
- package/agents/worker.md +1 -1
- package/config.example.json +3 -4
- package/config.schema.json +26 -20
- package/index.ts +2 -0
- package/package.json +13 -12
- package/src/agent-state.ts +125 -0
- package/src/agent-sync.ts +171 -88
- package/src/agents.ts +3 -22
- package/src/catalog.ts +47 -0
- package/src/completion-mailbox.ts +656 -0
- package/src/config.ts +43 -35
- package/src/coordinator.ts +2172 -326
- package/src/descriptor.ts +96 -33
- package/src/index.ts +176 -58
- package/src/mailbox.ts +451 -0
- package/src/providers.ts +221 -28
- package/src/render.ts +23 -16
- package/src/scheduler.ts +173 -0
- package/src/schemas.ts +76 -38
- package/src/task-path.ts +146 -0
- package/src/types.ts +53 -15
package/src/providers.ts
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { rm } from "node:fs/promises";
|
|
2
2
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ContextInheritance,
|
|
6
|
+
SubagentMode,
|
|
7
|
+
SubagentProviderName,
|
|
8
|
+
} from "./types.ts";
|
|
9
|
+
|
|
10
|
+
const INHERITED_COMPACTION_CUSTOM_TYPE =
|
|
11
|
+
"pi-subagent/inherited-compaction-summary";
|
|
12
|
+
const INHERITED_BRANCH_CUSTOM_TYPE =
|
|
13
|
+
"pi-subagent/inherited-branch-summary";
|
|
14
|
+
const COMPACTION_SUMMARY_PREFIX =
|
|
15
|
+
"The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
|
|
16
|
+
const COMPACTION_SUMMARY_SUFFIX = "\n</summary>";
|
|
17
|
+
const BRANCH_SUMMARY_PREFIX =
|
|
18
|
+
"The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
|
|
19
|
+
const BRANCH_SUMMARY_SUFFIX = "</summary>";
|
|
5
20
|
|
|
6
21
|
export interface SessionView {
|
|
7
22
|
getBranch(): SessionEntry[];
|
|
@@ -10,6 +25,7 @@ export interface SessionView {
|
|
|
10
25
|
getSessionFile(): string | undefined;
|
|
11
26
|
getSessionId(): string;
|
|
12
27
|
getEntries(): SessionEntry[];
|
|
28
|
+
buildContextEntries(): SessionEntry[];
|
|
13
29
|
appendCustomEntry(customType: string, data?: unknown): string;
|
|
14
30
|
}
|
|
15
31
|
|
|
@@ -27,7 +43,11 @@ export interface ChildProvider {
|
|
|
27
43
|
name: SubagentProviderName;
|
|
28
44
|
inheritsParentContext: boolean;
|
|
29
45
|
supportsContinuable: boolean;
|
|
30
|
-
prepare(
|
|
46
|
+
prepare(
|
|
47
|
+
parent: ProviderParent,
|
|
48
|
+
mode: SubagentMode,
|
|
49
|
+
context?: ContextInheritance,
|
|
50
|
+
): Promise<PreparedChildSession>;
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
async function removeOwnedSession(path: string | undefined): Promise<void> {
|
|
@@ -68,33 +88,195 @@ export function completedTurnBoundaryId(entries: readonly SessionEntry[]): strin
|
|
|
68
88
|
return undefined;
|
|
69
89
|
}
|
|
70
90
|
|
|
71
|
-
|
|
91
|
+
interface CompletedTurnSpan {
|
|
92
|
+
start: number;
|
|
93
|
+
end: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isSummaryEntry(entry: SessionEntry): boolean {
|
|
97
|
+
return (
|
|
98
|
+
entry.type === "compaction"
|
|
99
|
+
|| entry.type === "branch_summary"
|
|
100
|
+
|| (
|
|
101
|
+
entry.type === "message"
|
|
102
|
+
&& (
|
|
103
|
+
entry.message.role === "compactionSummary"
|
|
104
|
+
|| entry.message.role === "branchSummary"
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function startsModelTurn(entry: SessionEntry): boolean {
|
|
111
|
+
if (entry.type === "custom_message") return true;
|
|
112
|
+
if (entry.type !== "message") return false;
|
|
113
|
+
switch (entry.message.role) {
|
|
114
|
+
case "user":
|
|
115
|
+
case "custom":
|
|
116
|
+
return true;
|
|
117
|
+
case "bashExecution":
|
|
118
|
+
return !entry.message.excludeFromContext;
|
|
119
|
+
default:
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function completedTurnSpans(
|
|
125
|
+
entries: readonly SessionEntry[],
|
|
126
|
+
): {
|
|
127
|
+
turns: CompletedTurnSpan[];
|
|
128
|
+
latestSummary?: number;
|
|
129
|
+
} {
|
|
130
|
+
const turns: CompletedTurnSpan[] = [];
|
|
131
|
+
let latestSummary: number | undefined;
|
|
132
|
+
let currentStart: number | undefined;
|
|
133
|
+
let currentEnd: number | undefined;
|
|
134
|
+
const finishCurrent = () => {
|
|
135
|
+
if (currentStart !== undefined && currentEnd !== undefined) {
|
|
136
|
+
turns.push({ start: currentStart, end: currentEnd });
|
|
137
|
+
}
|
|
138
|
+
currentStart = undefined;
|
|
139
|
+
currentEnd = undefined;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
for (let index = 0; index < entries.length; index++) {
|
|
143
|
+
const entry = entries[index]!;
|
|
144
|
+
if (isSummaryEntry(entry)) {
|
|
145
|
+
finishCurrent();
|
|
146
|
+
latestSummary = index;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (startsModelTurn(entry)) {
|
|
150
|
+
finishCurrent();
|
|
151
|
+
currentStart = index;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (entry.type !== "message") continue;
|
|
155
|
+
if (
|
|
156
|
+
currentStart === undefined
|
|
157
|
+
&& (
|
|
158
|
+
entry.message.role === "assistant"
|
|
159
|
+
|| entry.message.role === "toolResult"
|
|
160
|
+
)
|
|
161
|
+
) {
|
|
162
|
+
currentStart = latestSummary ?? 0;
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
entry.message.role === "assistant"
|
|
166
|
+
&& entry.message.stopReason !== "toolUse"
|
|
167
|
+
) {
|
|
168
|
+
currentEnd = index;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
finishCurrent();
|
|
172
|
+
return {
|
|
173
|
+
turns,
|
|
174
|
+
...(latestSummary !== undefined ? { latestSummary } : {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function completedContextEntries(
|
|
179
|
+
entries: readonly SessionEntry[],
|
|
180
|
+
context: Exclude<ContextInheritance, { mode: "fresh" }>,
|
|
181
|
+
): SessionEntry[] {
|
|
182
|
+
const { turns, latestSummary } = completedTurnSpans(entries);
|
|
183
|
+
const end = Math.max(
|
|
184
|
+
turns.at(-1)?.end ?? -1,
|
|
185
|
+
latestSummary ?? -1,
|
|
186
|
+
);
|
|
187
|
+
if (end < 0) return [];
|
|
188
|
+
let start = 0;
|
|
189
|
+
if (context.mode === "last_n_completed") {
|
|
190
|
+
if (turns.length >= context.completedTurns) {
|
|
191
|
+
start = turns[turns.length - context.completedTurns]!.start;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return entries
|
|
195
|
+
.slice(start, end + 1)
|
|
196
|
+
.map((entry) => structuredClone(entry));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function appendInheritedEntry(
|
|
200
|
+
session: SessionManager,
|
|
201
|
+
entry: SessionEntry,
|
|
202
|
+
): void {
|
|
203
|
+
if (entry.type === "message") {
|
|
204
|
+
if (entry.message.role === "compactionSummary") {
|
|
205
|
+
session.appendCustomMessageEntry(
|
|
206
|
+
INHERITED_COMPACTION_CUSTOM_TYPE,
|
|
207
|
+
`${COMPACTION_SUMMARY_PREFIX}${entry.message.summary}${COMPACTION_SUMMARY_SUFFIX}`,
|
|
208
|
+
false,
|
|
209
|
+
{ tokensBefore: entry.message.tokensBefore },
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (entry.message.role === "branchSummary") {
|
|
214
|
+
session.appendCustomMessageEntry(
|
|
215
|
+
INHERITED_BRANCH_CUSTOM_TYPE,
|
|
216
|
+
`${BRANCH_SUMMARY_PREFIX}${entry.message.summary}${BRANCH_SUMMARY_SUFFIX}`,
|
|
217
|
+
false,
|
|
218
|
+
{ fromId: entry.message.fromId },
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
session.appendMessage(
|
|
223
|
+
structuredClone(
|
|
224
|
+
entry.message,
|
|
225
|
+
) as Parameters<SessionManager["appendMessage"]>[0],
|
|
226
|
+
);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (entry.type === "custom_message") {
|
|
230
|
+
session.appendCustomMessageEntry(
|
|
231
|
+
entry.customType,
|
|
232
|
+
structuredClone(entry.content),
|
|
233
|
+
entry.display,
|
|
234
|
+
structuredClone(entry.details),
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (entry.type === "compaction") {
|
|
239
|
+
session.appendCustomMessageEntry(
|
|
240
|
+
INHERITED_COMPACTION_CUSTOM_TYPE,
|
|
241
|
+
`${COMPACTION_SUMMARY_PREFIX}${entry.summary}${COMPACTION_SUMMARY_SUFFIX}`,
|
|
242
|
+
false,
|
|
243
|
+
{ tokensBefore: entry.tokensBefore },
|
|
244
|
+
);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (entry.type === "branch_summary") {
|
|
248
|
+
session.appendCustomMessageEntry(
|
|
249
|
+
INHERITED_BRANCH_CUSTOM_TYPE,
|
|
250
|
+
`${BRANCH_SUMMARY_PREFIX}${entry.summary}${BRANCH_SUMMARY_SUFFIX}`,
|
|
251
|
+
false,
|
|
252
|
+
{ fromId: entry.fromId },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function forkedSession(
|
|
258
|
+
parent: ProviderParent,
|
|
259
|
+
context: Exclude<ContextInheritance, { mode: "fresh" }>,
|
|
260
|
+
): PreparedChildSession {
|
|
72
261
|
const parentFile = parent.sessionManager.getSessionFile();
|
|
73
|
-
const
|
|
74
|
-
|
|
262
|
+
const inherited = completedContextEntries(
|
|
263
|
+
parent.sessionManager.buildContextEntries(),
|
|
264
|
+
context,
|
|
265
|
+
);
|
|
266
|
+
if (inherited.length === 0) return freshSession(parent);
|
|
75
267
|
if (!parentFile) {
|
|
76
268
|
throw new Error(
|
|
77
269
|
"fork provider cannot copy completed history from an ephemeral parent session; use spawn instead",
|
|
78
270
|
);
|
|
79
271
|
}
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
parent.sessionManager.getCwd(),
|
|
85
|
-
);
|
|
86
|
-
if (!clone.getEntry(boundaryId)) return freshSession(parent);
|
|
87
|
-
const childFile = clone.createBranchedSession(boundaryId);
|
|
88
|
-
if (!childFile) return freshSession(parent);
|
|
89
|
-
const sessionManager = SessionManager.open(
|
|
90
|
-
childFile,
|
|
91
|
-
parent.sessionManager.getSessionDir(),
|
|
92
|
-
parent.sessionManager.getCwd(),
|
|
93
|
-
);
|
|
272
|
+
const prepared = freshSession(parent);
|
|
273
|
+
for (const entry of inherited) {
|
|
274
|
+
appendInheritedEntry(prepared.sessionManager, entry);
|
|
275
|
+
}
|
|
94
276
|
return {
|
|
95
|
-
|
|
96
|
-
seedMessageCount:
|
|
97
|
-
|
|
277
|
+
...prepared,
|
|
278
|
+
seedMessageCount:
|
|
279
|
+
prepared.sessionManager.buildSessionContext().messages.length,
|
|
98
280
|
};
|
|
99
281
|
}
|
|
100
282
|
|
|
@@ -103,7 +285,14 @@ export class SpawnProvider implements ChildProvider {
|
|
|
103
285
|
readonly inheritsParentContext = false;
|
|
104
286
|
readonly supportsContinuable = true;
|
|
105
287
|
|
|
106
|
-
prepare(
|
|
288
|
+
prepare(
|
|
289
|
+
parent: ProviderParent,
|
|
290
|
+
_mode: SubagentMode,
|
|
291
|
+
context: ContextInheritance = { mode: "fresh" },
|
|
292
|
+
): Promise<PreparedChildSession> {
|
|
293
|
+
if (context.mode !== "fresh") {
|
|
294
|
+
throw new Error("spawn provider requires fresh context");
|
|
295
|
+
}
|
|
107
296
|
return Promise.resolve(freshSession(parent));
|
|
108
297
|
}
|
|
109
298
|
}
|
|
@@ -111,13 +300,17 @@ export class SpawnProvider implements ChildProvider {
|
|
|
111
300
|
export class ForkProvider implements ChildProvider {
|
|
112
301
|
readonly name = "fork";
|
|
113
302
|
readonly inheritsParentContext = true;
|
|
114
|
-
readonly supportsContinuable =
|
|
303
|
+
readonly supportsContinuable = true;
|
|
115
304
|
|
|
116
|
-
async prepare(
|
|
117
|
-
|
|
118
|
-
|
|
305
|
+
async prepare(
|
|
306
|
+
parent: ProviderParent,
|
|
307
|
+
_mode: SubagentMode,
|
|
308
|
+
context: ContextInheritance = { mode: "all_completed" },
|
|
309
|
+
): Promise<PreparedChildSession> {
|
|
310
|
+
if (context.mode === "fresh") {
|
|
311
|
+
throw new Error("fork provider requires inherited context");
|
|
119
312
|
}
|
|
120
|
-
return forkedSession(parent);
|
|
313
|
+
return forkedSession(parent, context);
|
|
121
314
|
}
|
|
122
315
|
}
|
|
123
316
|
|
package/src/render.ts
CHANGED
|
@@ -4,26 +4,28 @@ import type { DelegationDetails, ParentMessageDetails } from "./types.ts";
|
|
|
4
4
|
import { formatUsage } from "./result.ts";
|
|
5
5
|
|
|
6
6
|
export function renderDelegationCall(
|
|
7
|
-
args: {
|
|
7
|
+
args: {
|
|
8
|
+
agent?: string;
|
|
9
|
+
task_name?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
prompt?: string;
|
|
12
|
+
context?: { mode?: string };
|
|
13
|
+
},
|
|
8
14
|
theme: {
|
|
9
15
|
fg(color: any, text: string): string;
|
|
10
16
|
bold(text: string): string;
|
|
11
17
|
},
|
|
12
18
|
provider: "spawn" | "fork",
|
|
19
|
+
runtimeMode: "foreground" | "background" = "background",
|
|
13
20
|
): Text {
|
|
14
|
-
const mode
|
|
15
|
-
|
|
16
|
-
? "fork · foreground"
|
|
17
|
-
: args.run_in_background === false
|
|
18
|
-
? "spawn · foreground"
|
|
19
|
-
: args.run_in_background === true
|
|
20
|
-
? "spawn · background"
|
|
21
|
-
: "spawn · configured default";
|
|
21
|
+
const contextMode = provider === "fork" ? "all_completed" : (args.context?.mode ?? "fresh");
|
|
22
|
+
const mode = `${contextMode} · ${runtimeMode}`;
|
|
22
23
|
let text =
|
|
23
24
|
theme.fg("toolTitle", theme.bold(provider === "fork" ? "subagent_fork " : "subagent ")) +
|
|
24
25
|
theme.fg("accent", args.agent ?? "…") +
|
|
25
26
|
theme.fg("muted", ` [${mode}]`);
|
|
26
27
|
if (args.description) text += `\n ${theme.fg("dim", args.description)}`;
|
|
28
|
+
if (args.task_name) text += `\n ${theme.fg("dim", `path: ${args.task_name}`)}`;
|
|
27
29
|
return new Text(text, 0, 0);
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -37,6 +39,10 @@ export function renderDelegationResult(
|
|
|
37
39
|
},
|
|
38
40
|
): Text | Container {
|
|
39
41
|
if (!details) return new Text(content || "(no output)", 0, 0);
|
|
42
|
+
const taskPath = details.taskPath || details.agentId;
|
|
43
|
+
const contextMode =
|
|
44
|
+
details.context?.mode
|
|
45
|
+
?? (details.provider === "fork" ? "all_completed" : "fresh");
|
|
40
46
|
const running = options.isPartial || details.status === "starting" || details.status === "running";
|
|
41
47
|
const icon = running
|
|
42
48
|
? theme.fg("warning", "◌")
|
|
@@ -45,8 +51,8 @@ export function renderDelegationResult(
|
|
|
45
51
|
: theme.fg("success", "✓");
|
|
46
52
|
const header = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))} ${theme.fg(
|
|
47
53
|
"muted",
|
|
48
|
-
`[${details.provider}/${details.mode}]`,
|
|
49
|
-
)} ${theme.fg("dim", details.agentId)}`;
|
|
54
|
+
`[${details.provider}/${details.mode}/${contextMode}]`,
|
|
55
|
+
)} ${theme.fg("accent", taskPath)} ${theme.fg("dim", details.agentId)}`;
|
|
50
56
|
|
|
51
57
|
if (!options.expanded) {
|
|
52
58
|
const lines = [header, theme.fg("muted", details.label)];
|
|
@@ -95,14 +101,15 @@ export function renderParentMessage(
|
|
|
95
101
|
},
|
|
96
102
|
): Text | Markdown {
|
|
97
103
|
if (expanded) return new Markdown(content, outputPad, 0, getMarkdownTheme());
|
|
98
|
-
const kind = details?.kind === "report" ? "report" : "settled";
|
|
99
|
-
const icon = details?.stopReason && details.stopReason !== "completed" ? "◐" : "●";
|
|
100
104
|
const label = details?.label ? ` — ${details.label}` : "";
|
|
101
105
|
return new Text(
|
|
102
|
-
theme.fg("accent",
|
|
106
|
+
theme.fg("accent", "●") +
|
|
103
107
|
" " +
|
|
104
|
-
theme.fg("toolTitle", theme.bold(
|
|
105
|
-
theme.fg(
|
|
108
|
+
theme.fg("toolTitle", theme.bold("subagent report")) +
|
|
109
|
+
theme.fg(
|
|
110
|
+
"muted",
|
|
111
|
+
` ${details?.taskPath ?? details?.childAgentId ?? "unknown"}${label}`,
|
|
112
|
+
),
|
|
106
113
|
outputPad,
|
|
107
114
|
0,
|
|
108
115
|
);
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
export interface BackgroundRunPermit {
|
|
2
|
+
release(): void;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface BackgroundRunAcquireOptions {
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
waitForCapacity?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class BackgroundConcurrencyLimitError extends Error {
|
|
11
|
+
constructor(readonly limit: number) {
|
|
12
|
+
super(
|
|
13
|
+
`background subagent concurrency limit (${limit}) is already in use; wait for an active background run to finish`,
|
|
14
|
+
);
|
|
15
|
+
this.name = "BackgroundConcurrencyLimitError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface BackgroundRunWaiter {
|
|
20
|
+
resolve: (permit: BackgroundRunPermit) => void;
|
|
21
|
+
reject: (error: Error) => void;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
onAbort?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class BackgroundRunLimiter {
|
|
27
|
+
private active = 0;
|
|
28
|
+
private readonly waiters: BackgroundRunWaiter[] = [];
|
|
29
|
+
private closedError: Error | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(private limit = 4) {
|
|
32
|
+
validateLimit(limit);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get activeCount(): number {
|
|
36
|
+
return this.active;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get pendingCount(): number {
|
|
40
|
+
return this.waiters.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
configure(limit: number): void {
|
|
44
|
+
validateLimit(limit);
|
|
45
|
+
if (limit === this.limit) return;
|
|
46
|
+
this.limit = limit;
|
|
47
|
+
this.drain();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
acquire(
|
|
51
|
+
options: BackgroundRunAcquireOptions = {},
|
|
52
|
+
): Promise<BackgroundRunPermit> {
|
|
53
|
+
if (this.closedError) return Promise.reject(this.closedError);
|
|
54
|
+
if (options.signal?.aborted) {
|
|
55
|
+
return Promise.reject(abortReason(options.signal));
|
|
56
|
+
}
|
|
57
|
+
if (this.waiters.length === 0 && this.active < this.limit) {
|
|
58
|
+
this.active += 1;
|
|
59
|
+
return Promise.resolve(this.createPermit());
|
|
60
|
+
}
|
|
61
|
+
if (options.waitForCapacity === false) {
|
|
62
|
+
return Promise.reject(new BackgroundConcurrencyLimitError(this.limit));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Promise<BackgroundRunPermit>((resolve, reject) => {
|
|
66
|
+
const waiter: BackgroundRunWaiter = {
|
|
67
|
+
resolve,
|
|
68
|
+
reject,
|
|
69
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
70
|
+
};
|
|
71
|
+
if (options.signal) {
|
|
72
|
+
waiter.onAbort = () => {
|
|
73
|
+
const index = this.waiters.indexOf(waiter);
|
|
74
|
+
if (index >= 0) this.waiters.splice(index, 1);
|
|
75
|
+
reject(abortReason(options.signal!));
|
|
76
|
+
this.drain();
|
|
77
|
+
};
|
|
78
|
+
options.signal.addEventListener("abort", waiter.onAbort, { once: true });
|
|
79
|
+
}
|
|
80
|
+
this.waiters.push(waiter);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close(error = new Error("background subagent scheduler is shutting down")): void {
|
|
85
|
+
if (this.closedError) return;
|
|
86
|
+
this.closedError = error;
|
|
87
|
+
for (const waiter of this.waiters.splice(0)) {
|
|
88
|
+
this.removeAbortListener(waiter);
|
|
89
|
+
waiter.reject(error);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private createPermit(): BackgroundRunPermit {
|
|
94
|
+
let released = false;
|
|
95
|
+
return {
|
|
96
|
+
release: () => {
|
|
97
|
+
if (released) return;
|
|
98
|
+
released = true;
|
|
99
|
+
this.active = Math.max(0, this.active - 1);
|
|
100
|
+
this.drain();
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private drain(): void {
|
|
106
|
+
while (this.waiters.length > 0) {
|
|
107
|
+
const waiter = this.waiters[0]!;
|
|
108
|
+
if (waiter.signal?.aborted) {
|
|
109
|
+
this.waiters.shift();
|
|
110
|
+
this.removeAbortListener(waiter);
|
|
111
|
+
waiter.reject(abortReason(waiter.signal));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (this.closedError) {
|
|
115
|
+
this.waiters.shift();
|
|
116
|
+
this.removeAbortListener(waiter);
|
|
117
|
+
waiter.reject(this.closedError);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (this.active >= this.limit) return;
|
|
121
|
+
this.waiters.shift();
|
|
122
|
+
this.removeAbortListener(waiter);
|
|
123
|
+
this.active += 1;
|
|
124
|
+
waiter.resolve(this.createPermit());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private removeAbortListener(waiter: BackgroundRunWaiter): void {
|
|
129
|
+
if (waiter.signal && waiter.onAbort) {
|
|
130
|
+
waiter.signal.removeEventListener("abort", waiter.onAbort);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class AgentOperationQueue {
|
|
136
|
+
private readonly tails = new Map<string, Promise<void>>();
|
|
137
|
+
|
|
138
|
+
async run<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
|
139
|
+
const predecessor = this.tails.get(agentId) ?? Promise.resolve();
|
|
140
|
+
let release!: () => void;
|
|
141
|
+
const gate = new Promise<void>((resolve) => {
|
|
142
|
+
release = resolve;
|
|
143
|
+
});
|
|
144
|
+
const tail = predecessor.catch(() => {}).then(() => gate);
|
|
145
|
+
this.tails.set(agentId, tail);
|
|
146
|
+
|
|
147
|
+
await predecessor.catch(() => {});
|
|
148
|
+
try {
|
|
149
|
+
return await operation();
|
|
150
|
+
} finally {
|
|
151
|
+
release();
|
|
152
|
+
if (this.tails.get(agentId) === tail) this.tails.delete(agentId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async waitForIdle(): Promise<void> {
|
|
157
|
+
while (this.tails.size > 0) {
|
|
158
|
+
await Promise.all([...this.tails.values()].map((tail) => tail.catch(() => {})));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateLimit(limit: number): void {
|
|
164
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
165
|
+
throw new Error("background run limit must be a positive safe integer");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function abortReason(signal: AbortSignal): Error {
|
|
170
|
+
return signal.reason instanceof Error
|
|
171
|
+
? signal.reason
|
|
172
|
+
: new Error(signal.reason ? String(signal.reason) : "background run scheduling aborted");
|
|
173
|
+
}
|
package/src/schemas.ts
CHANGED
|
@@ -17,6 +17,15 @@ function agentNameParameter(agentNames?: readonly string[]) {
|
|
|
17
17
|
function delegationFields(agentNames?: readonly string[]) {
|
|
18
18
|
return {
|
|
19
19
|
agent: agentNameParameter(agentNames),
|
|
20
|
+
task_name: Type.Optional(
|
|
21
|
+
Type.String({
|
|
22
|
+
description:
|
|
23
|
+
"Stable readable child path segment; lowercase letters, digits, hyphens, and underscores",
|
|
24
|
+
pattern: "^[a-z0-9][a-z0-9_-]{0,63}$",
|
|
25
|
+
minLength: 1,
|
|
26
|
+
maxLength: 64,
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
20
29
|
description: Type.String({
|
|
21
30
|
description: "Short 3-5 word display label for the delegated task",
|
|
22
31
|
minLength: 1,
|
|
@@ -29,6 +38,27 @@ function delegationFields(agentNames?: readonly string[]) {
|
|
|
29
38
|
};
|
|
30
39
|
}
|
|
31
40
|
|
|
41
|
+
const ContextParameters = Type.Object(
|
|
42
|
+
{
|
|
43
|
+
mode: StringEnum(
|
|
44
|
+
["fresh", "all_completed", "last_n_completed"] as const,
|
|
45
|
+
{
|
|
46
|
+
description:
|
|
47
|
+
"Parent context inherited once when the child is created",
|
|
48
|
+
},
|
|
49
|
+
),
|
|
50
|
+
completed_turns: Type.Optional(
|
|
51
|
+
Type.Integer({
|
|
52
|
+
description:
|
|
53
|
+
"Number of completed parent turns for last_n_completed",
|
|
54
|
+
minimum: 1,
|
|
55
|
+
maximum: 100,
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
{ additionalProperties: false },
|
|
60
|
+
);
|
|
61
|
+
|
|
32
62
|
function forkDelegationFields(agentNames?: readonly string[]) {
|
|
33
63
|
return {
|
|
34
64
|
...delegationFields(agentNames),
|
|
@@ -40,56 +70,32 @@ function forkDelegationFields(agentNames?: readonly string[]) {
|
|
|
40
70
|
};
|
|
41
71
|
}
|
|
42
72
|
|
|
43
|
-
function
|
|
44
|
-
enableRunInBackground: boolean,
|
|
45
|
-
agentNames?: readonly string[],
|
|
46
|
-
) {
|
|
73
|
+
export function delegationParameters(agentNames?: readonly string[]) {
|
|
47
74
|
const fields = delegationFields(agentNames);
|
|
48
75
|
return Type.Object(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
Type.Boolean({
|
|
54
|
-
description:
|
|
55
|
-
"Run as a continuable background child. The spawn provider defaults this from configuration.",
|
|
56
|
-
}),
|
|
57
|
-
),
|
|
58
|
-
}
|
|
59
|
-
: fields,
|
|
76
|
+
{
|
|
77
|
+
...fields,
|
|
78
|
+
context: Type.Optional(ContextParameters),
|
|
79
|
+
},
|
|
60
80
|
{ additionalProperties: false },
|
|
61
81
|
);
|
|
62
82
|
}
|
|
63
83
|
|
|
64
|
-
export const ForegroundDelegationParameters = createDelegationParameters(false);
|
|
65
|
-
|
|
66
|
-
export const DelegationParameters = createDelegationParameters(true);
|
|
67
|
-
|
|
68
|
-
export function delegationParameters(
|
|
69
|
-
enableRunInBackground: boolean,
|
|
70
|
-
agentNames?: readonly string[],
|
|
71
|
-
) {
|
|
72
|
-
if (agentNames === undefined) {
|
|
73
|
-
return enableRunInBackground ? DelegationParameters : ForegroundDelegationParameters;
|
|
74
|
-
}
|
|
75
|
-
return createDelegationParameters(enableRunInBackground, agentNames);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const ForkDelegationParameters = Type.Object(
|
|
79
|
-
forkDelegationFields(),
|
|
80
|
-
{ additionalProperties: false },
|
|
81
|
-
);
|
|
82
|
-
|
|
83
84
|
export function forkDelegationParameters(agentNames?: readonly string[]) {
|
|
84
|
-
|
|
85
|
-
return Type.Object(
|
|
85
|
+
const fields = forkDelegationFields(agentNames);
|
|
86
|
+
return Type.Object(
|
|
87
|
+
fields,
|
|
88
|
+
{ additionalProperties: false },
|
|
89
|
+
);
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
export const SendMessageParameters = Type.Object(
|
|
89
93
|
{
|
|
90
94
|
subagent_id: Type.String({
|
|
91
|
-
description:
|
|
95
|
+
description:
|
|
96
|
+
"Readable absolute/relative task path or durable id of a direct continuable child",
|
|
92
97
|
minLength: 1,
|
|
98
|
+
maxLength: 4096,
|
|
93
99
|
}),
|
|
94
100
|
message: Type.String({
|
|
95
101
|
description: "Message to enqueue as the child's next FIFO turn",
|
|
@@ -99,11 +105,43 @@ export const SendMessageParameters = Type.Object(
|
|
|
99
105
|
{ additionalProperties: false },
|
|
100
106
|
);
|
|
101
107
|
|
|
108
|
+
export const FollowupTaskParameters = Type.Object(
|
|
109
|
+
{
|
|
110
|
+
subagent_id: Type.String({
|
|
111
|
+
description:
|
|
112
|
+
"Readable absolute/relative task path or durable id of a direct continuable child",
|
|
113
|
+
minLength: 1,
|
|
114
|
+
maxLength: 4096,
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
{ additionalProperties: false },
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
export const DEFAULT_WAIT_AGENT_TIMEOUT_MS = 30_000;
|
|
121
|
+
export const MAX_WAIT_AGENT_TIMEOUT_MS = 120_000;
|
|
122
|
+
|
|
123
|
+
export const WaitAgentParameters = Type.Object(
|
|
124
|
+
{
|
|
125
|
+
timeout_ms: Type.Optional(
|
|
126
|
+
Type.Integer({
|
|
127
|
+
description:
|
|
128
|
+
"Maximum event-driven wait in milliseconds before returning a timeout",
|
|
129
|
+
minimum: 0,
|
|
130
|
+
maximum: MAX_WAIT_AGENT_TIMEOUT_MS,
|
|
131
|
+
default: DEFAULT_WAIT_AGENT_TIMEOUT_MS,
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
{ additionalProperties: false },
|
|
136
|
+
);
|
|
137
|
+
|
|
102
138
|
export const InterruptParameters = Type.Object(
|
|
103
139
|
{
|
|
104
140
|
agent_id: Type.String({
|
|
105
|
-
description:
|
|
141
|
+
description:
|
|
142
|
+
"Readable absolute/relative task path or durable id of a live descendant whose current turn should stop",
|
|
106
143
|
minLength: 1,
|
|
144
|
+
maxLength: 4096,
|
|
107
145
|
}),
|
|
108
146
|
},
|
|
109
147
|
{ additionalProperties: false },
|