@oh-my-pi/pi-coding-agent 16.2.9 → 16.2.12
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 +60 -0
- package/dist/cli.js +3464 -3306
- package/dist/types/cli/bench-cli.d.ts +0 -5
- package/dist/types/cli/dry-balance-cli.d.ts +0 -5
- package/dist/types/cli/models-cli.d.ts +1 -1
- package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
- package/dist/types/config/model-registry.d.ts +1 -23
- package/dist/types/config/model-resolver.d.ts +10 -15
- package/dist/types/config/models-config-schema.d.ts +0 -6
- package/dist/types/config/models-config.d.ts +0 -6
- package/dist/types/config/settings-schema.d.ts +31 -1
- package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/runner.d.ts +15 -0
- package/dist/types/extensibility/skills.d.ts +29 -0
- package/dist/types/lsp/client.d.ts +9 -3
- package/dist/types/modes/components/todo-reminder.d.ts +3 -1
- package/dist/types/modes/interactive-mode.d.ts +0 -1
- package/dist/types/modes/skill-command.d.ts +1 -1
- package/dist/types/modes/types.d.ts +0 -1
- package/dist/types/stt/asr-client.d.ts +7 -3
- package/dist/types/task/spawn-policy.d.ts +17 -0
- package/dist/types/task/spawn-policy.test.d.ts +1 -0
- package/dist/types/task/types.d.ts +8 -2
- package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
- package/dist/types/tools/browser/registry.d.ts +2 -0
- package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
- package/dist/types/tools/eval.d.ts +3 -6
- package/dist/types/tools/write.d.ts +1 -1
- package/dist/types/utils/image-vision-fallback.d.ts +1 -1
- package/package.json +14 -13
- package/scripts/bundle-dist.ts +23 -4
- package/scripts/generate-docs-index.ts +116 -24
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- package/src/async/job-manager.ts +27 -3
- package/src/auto-thinking/classifier.ts +1 -1
- package/src/cli/bench-cli.ts +3 -12
- package/src/cli/dry-balance-cli.ts +1 -6
- package/src/cli/grep-cli.ts +1 -1
- package/src/cli/models-cli.ts +3 -81
- package/src/commands/models.ts +1 -2
- package/src/commit/model-selection.ts +4 -4
- package/src/config/inline-tool-descriptors-mode.ts +1 -2
- package/src/config/model-discovery.ts +150 -82
- package/src/config/model-registry.ts +11 -225
- package/src/config/model-resolver.ts +23 -135
- package/src/config/models-config-schema.ts +0 -22
- package/src/config/prompt-templates.ts +20 -0
- package/src/config/settings-schema.ts +35 -1
- package/src/config/settings.ts +60 -24
- package/src/eval/__tests__/agent-bridge.test.ts +17 -3
- package/src/eval/agent-bridge.ts +7 -9
- package/src/eval/completion-bridge.ts +1 -1
- package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
- package/src/eval/py/runner.py +169 -18
- package/src/extensibility/extensions/model-api.ts +1 -3
- package/src/extensibility/extensions/runner.ts +62 -5
- package/src/extensibility/skills.ts +77 -0
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/lsp/client.ts +162 -45
- package/src/lsp/config.ts +17 -3
- package/src/lsp/index.ts +31 -21
- package/src/main.ts +0 -1
- package/src/mcp/transports/http.ts +19 -17
- package/src/mcp/transports/stdio.test.ts +51 -1
- package/src/mcp/transports/stdio.ts +19 -9
- package/src/memories/index.ts +0 -1
- package/src/mnemopi/backend.ts +1 -1
- package/src/modes/acp/acp-agent.ts +6 -9
- package/src/modes/components/model-selector.ts +32 -186
- package/src/modes/components/todo-reminder.ts +5 -1
- package/src/modes/controllers/event-controller.ts +1 -16
- package/src/modes/controllers/selector-controller.ts +57 -36
- package/src/modes/interactive-mode.ts +0 -6
- package/src/modes/rpc/rpc-mode.ts +5 -8
- package/src/modes/skill-command.ts +8 -20
- package/src/modes/types.ts +0 -1
- package/src/prompts/system/subagent-system-prompt.md +2 -2
- package/src/prompts/system/tool-call-loop-redirect.md +8 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/task.md +1 -1
- package/src/sdk.ts +2 -9
- package/src/session/agent-session.ts +89 -8
- package/src/session/session-context.ts +2 -1
- package/src/session/session-manager.ts +2 -1
- package/src/session/unexpected-stop-classifier.ts +1 -1
- package/src/stt/asr-client.ts +87 -27
- package/src/stt/downloader.ts +8 -2
- package/src/task/executor.ts +1 -3
- package/src/task/index.ts +37 -32
- package/src/task/spawn-policy.test.ts +63 -0
- package/src/task/spawn-policy.ts +58 -0
- package/src/task/types.ts +77 -6
- package/src/tools/__tests__/eval-description.test.ts +19 -0
- package/src/tools/ast-grep.ts +34 -12
- package/src/tools/browser/cmux/cmux-tab.ts +66 -24
- package/src/tools/browser/registry.ts +25 -0
- package/src/tools/browser/run-cancellation.ts +47 -0
- package/src/tools/browser/tab-supervisor.ts +120 -7
- package/src/tools/browser/tab-worker.ts +22 -10
- package/src/tools/browser.ts +1 -0
- package/src/tools/eval.ts +15 -10
- package/src/tools/grep.ts +11 -8
- package/src/tools/inspect-image.ts +1 -1
- package/src/tools/write.ts +49 -9
- package/src/utils/commit-message-generator.ts +0 -1
- package/src/utils/git.ts +22 -1
- package/src/utils/image-vision-fallback.ts +2 -3
- package/src/utils/title-generator.ts +1 -1
package/src/stt/asr-client.ts
CHANGED
|
@@ -4,12 +4,12 @@ import {
|
|
|
4
4
|
createWorkerHandle,
|
|
5
5
|
createWorkerSubprocess,
|
|
6
6
|
logWorkerMessage,
|
|
7
|
+
type RefCountedWorkerHandle,
|
|
7
8
|
resolveWorkerSpawnCmd,
|
|
8
9
|
SMOKE_TEST_TIMEOUT_MS,
|
|
9
10
|
type SpawnedSubprocess,
|
|
10
11
|
smokeTestWorker,
|
|
11
12
|
spawnWorkerOrUnavailable,
|
|
12
|
-
type WorkerHandle,
|
|
13
13
|
} from "../subprocess/worker-client";
|
|
14
14
|
import { tinyWorkerEnv } from "../tiny/title-client";
|
|
15
15
|
import { safeSend } from "../utils/ipc";
|
|
@@ -18,7 +18,7 @@ import type { SttModelKey } from "./models";
|
|
|
18
18
|
|
|
19
19
|
type PendingRequest =
|
|
20
20
|
| { kind: "transcribe"; modelKey: SttModelKey; resolve: (text: string) => void; reject: (error: Error) => void }
|
|
21
|
-
| { kind: "download"; modelKey: SttModelKey; resolve: (
|
|
21
|
+
| { kind: "download"; modelKey: SttModelKey; resolve: (result: SttDownloadResult) => void };
|
|
22
22
|
|
|
23
23
|
export interface SttTranscribeOptions {
|
|
24
24
|
language?: string;
|
|
@@ -30,6 +30,11 @@ export interface SttDownloadOptions {
|
|
|
30
30
|
onProgress?: (event: SttProgressEvent) => void;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface SttDownloadResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/** Live streaming session handle returned by {@link SttClient.startStream}. */
|
|
34
39
|
export interface SttStreamHandle {
|
|
35
40
|
/** Feed 16 kHz mono float samples as the recorder produces them. */
|
|
@@ -79,30 +84,55 @@ export function createSttSubprocess(): SpawnedSubprocess<SttWorkerOutbound> {
|
|
|
79
84
|
|
|
80
85
|
function wrapSubprocess(
|
|
81
86
|
spawned: SpawnedSubprocess<SttWorkerOutbound>,
|
|
82
|
-
):
|
|
87
|
+
): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
|
|
83
88
|
const { proc } = spawned;
|
|
84
|
-
return
|
|
89
|
+
return {
|
|
90
|
+
...createWorkerHandle<SttWorkerInbound, SttWorkerOutbound>(spawned, message => safeSend(proc, message, "stt")),
|
|
91
|
+
ref() {
|
|
92
|
+
try {
|
|
93
|
+
proc.ref();
|
|
94
|
+
} catch {
|
|
95
|
+
// Already gone.
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
unref() {
|
|
99
|
+
try {
|
|
100
|
+
proc.unref();
|
|
101
|
+
} catch {
|
|
102
|
+
// Already gone.
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function spawnInlineUnavailableWorker(error: unknown): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
|
|
109
|
+
return {
|
|
110
|
+
...createUnavailableWorker<SttWorkerInbound, SttWorkerOutbound>(error),
|
|
111
|
+
ref() {},
|
|
112
|
+
unref() {},
|
|
113
|
+
};
|
|
85
114
|
}
|
|
86
115
|
|
|
87
|
-
function spawnSttWorker():
|
|
116
|
+
function spawnSttWorker(): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
|
|
88
117
|
return spawnWorkerOrUnavailable(
|
|
89
118
|
() => wrapSubprocess(createSttSubprocess()),
|
|
90
|
-
|
|
119
|
+
spawnInlineUnavailableWorker,
|
|
91
120
|
"stt worker spawn failed; speech-to-text disabled",
|
|
92
121
|
);
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
export class SttClient {
|
|
96
|
-
#worker:
|
|
125
|
+
#worker: RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> | null = null;
|
|
97
126
|
#unsubscribeMessage: (() => void) | null = null;
|
|
98
127
|
#unsubscribeError: (() => void) | null = null;
|
|
99
128
|
#pending = new Map<string, PendingRequest>();
|
|
100
129
|
#streams = new Map<string, StreamState>();
|
|
101
130
|
#progressListeners = new Set<(event: SttProgressEvent) => void>();
|
|
102
131
|
#nextRequestId = 0;
|
|
103
|
-
#
|
|
132
|
+
#refed = false;
|
|
133
|
+
#spawnWorker: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound>;
|
|
104
134
|
|
|
105
|
-
constructor(spawnWorker: () =>
|
|
135
|
+
constructor(spawnWorker: () => RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> = spawnSttWorker) {
|
|
106
136
|
this.#spawnWorker = spawnWorker;
|
|
107
137
|
}
|
|
108
138
|
|
|
@@ -121,11 +151,11 @@ export class SttClient {
|
|
|
121
151
|
const worker = this.#ensureWorker();
|
|
122
152
|
const id = String(++this.#nextRequestId);
|
|
123
153
|
const { promise, resolve, reject } = Promise.withResolvers<string>();
|
|
124
|
-
this.#
|
|
154
|
+
this.#addPending(id, { kind: "transcribe", modelKey, resolve, reject });
|
|
125
155
|
const abort = (): void => {
|
|
126
156
|
const pending = this.#pending.get(id);
|
|
127
157
|
if (pending?.kind !== "transcribe") return;
|
|
128
|
-
this.#
|
|
158
|
+
this.#deletePending(id);
|
|
129
159
|
pending.reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
130
160
|
};
|
|
131
161
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
@@ -134,7 +164,7 @@ export class SttClient {
|
|
|
134
164
|
return await promise;
|
|
135
165
|
} finally {
|
|
136
166
|
options.signal?.removeEventListener("abort", abort);
|
|
137
|
-
this.#
|
|
167
|
+
this.#deletePending(id);
|
|
138
168
|
}
|
|
139
169
|
}
|
|
140
170
|
|
|
@@ -163,6 +193,7 @@ export class SttClient {
|
|
|
163
193
|
settled = true;
|
|
164
194
|
this.#streams.delete(id);
|
|
165
195
|
signal?.removeEventListener("abort", onAbort);
|
|
196
|
+
this.#syncWorkerRef();
|
|
166
197
|
apply();
|
|
167
198
|
};
|
|
168
199
|
this.#streams.set(id, {
|
|
@@ -173,6 +204,7 @@ export class SttClient {
|
|
|
173
204
|
reject,
|
|
174
205
|
finish,
|
|
175
206
|
});
|
|
207
|
+
this.#syncWorkerRef();
|
|
176
208
|
worker.send({ type: "stream_start", id, modelKey, language: options.language });
|
|
177
209
|
const handle: SttStreamHandle = {
|
|
178
210
|
pushAudio: audio => {
|
|
@@ -193,19 +225,19 @@ export class SttClient {
|
|
|
193
225
|
return handle;
|
|
194
226
|
}
|
|
195
227
|
|
|
196
|
-
async downloadModel(modelKey: SttModelKey, options: SttDownloadOptions = {}): Promise<
|
|
197
|
-
if (options.signal?.aborted) return false;
|
|
228
|
+
async downloadModel(modelKey: SttModelKey, options: SttDownloadOptions = {}): Promise<SttDownloadResult> {
|
|
229
|
+
if (options.signal?.aborted) return { ok: false };
|
|
198
230
|
const unsubscribe = options.onProgress ? this.onProgress(options.onProgress) : undefined;
|
|
199
231
|
try {
|
|
200
232
|
const worker = this.#ensureWorker();
|
|
201
233
|
const id = String(++this.#nextRequestId);
|
|
202
|
-
const { promise, resolve } = Promise.withResolvers<
|
|
203
|
-
this.#
|
|
234
|
+
const { promise, resolve } = Promise.withResolvers<SttDownloadResult>();
|
|
235
|
+
this.#addPending(id, { kind: "download", modelKey, resolve });
|
|
204
236
|
const abort = (): void => {
|
|
205
237
|
const pending = this.#pending.get(id);
|
|
206
238
|
if (pending?.kind !== "download") return;
|
|
207
|
-
this.#
|
|
208
|
-
pending.resolve(false);
|
|
239
|
+
this.#deletePending(id);
|
|
240
|
+
pending.resolve({ ok: false });
|
|
209
241
|
};
|
|
210
242
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
211
243
|
try {
|
|
@@ -213,14 +245,15 @@ export class SttClient {
|
|
|
213
245
|
return await promise;
|
|
214
246
|
} finally {
|
|
215
247
|
options.signal?.removeEventListener("abort", abort);
|
|
216
|
-
this.#
|
|
248
|
+
this.#deletePending(id);
|
|
217
249
|
}
|
|
218
250
|
} catch (error) {
|
|
251
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
219
252
|
logger.debug("stt: local model download failed", {
|
|
220
253
|
modelKey,
|
|
221
|
-
error:
|
|
254
|
+
error: message,
|
|
222
255
|
});
|
|
223
|
-
return false;
|
|
256
|
+
return { ok: false, error: message };
|
|
224
257
|
} finally {
|
|
225
258
|
unsubscribe?.();
|
|
226
259
|
}
|
|
@@ -236,9 +269,10 @@ export class SttClient {
|
|
|
236
269
|
for (const pending of this.#pending.values()) {
|
|
237
270
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
238
271
|
if (pending.kind === "transcribe") pending.reject(new Error("stt worker terminated"));
|
|
239
|
-
else pending.resolve(false);
|
|
272
|
+
else pending.resolve({ ok: false });
|
|
240
273
|
}
|
|
241
274
|
this.#pending.clear();
|
|
275
|
+
this.#refed = false;
|
|
242
276
|
this.#failStreams(new Error("stt worker terminated"));
|
|
243
277
|
try {
|
|
244
278
|
await worker?.terminate();
|
|
@@ -247,7 +281,7 @@ export class SttClient {
|
|
|
247
281
|
}
|
|
248
282
|
}
|
|
249
283
|
|
|
250
|
-
#ensureWorker():
|
|
284
|
+
#ensureWorker(): RefCountedWorkerHandle<SttWorkerInbound, SttWorkerOutbound> {
|
|
251
285
|
if (this.#worker) return this.#worker;
|
|
252
286
|
const worker = this.#spawnWorker();
|
|
253
287
|
this.#worker = worker;
|
|
@@ -256,6 +290,32 @@ export class SttClient {
|
|
|
256
290
|
return worker;
|
|
257
291
|
}
|
|
258
292
|
|
|
293
|
+
/** Register a pending request and keep the worker referenced while work is in flight. */
|
|
294
|
+
#addPending(id: string, request: PendingRequest): void {
|
|
295
|
+
this.#pending.set(id, request);
|
|
296
|
+
this.#syncWorkerRef();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Drop a pending request and unref the worker once no request or stream is active. */
|
|
300
|
+
#deletePending(id: string): void {
|
|
301
|
+
if (this.#pending.delete(id)) this.#syncWorkerRef();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* STT workers start unreferenced so an idle warm model never blocks exit.
|
|
306
|
+
* Setup/download commands must keep the worker alive while awaiting IPC, or
|
|
307
|
+
* Bun can drain the event loop immediately after `Preparing Speech-to-Text`.
|
|
308
|
+
*/
|
|
309
|
+
#syncWorkerRef(): void {
|
|
310
|
+
const worker = this.#worker;
|
|
311
|
+
if (!worker) return;
|
|
312
|
+
const shouldRef = this.#pending.size > 0 || this.#streams.size > 0;
|
|
313
|
+
if (shouldRef === this.#refed) return;
|
|
314
|
+
this.#refed = shouldRef;
|
|
315
|
+
if (shouldRef) worker.ref();
|
|
316
|
+
else worker.unref();
|
|
317
|
+
}
|
|
318
|
+
|
|
259
319
|
#handleMessage(message: SttWorkerOutbound): void {
|
|
260
320
|
if (message.type === "log") {
|
|
261
321
|
logWorkerMessage(message);
|
|
@@ -287,19 +347,19 @@ export class SttClient {
|
|
|
287
347
|
}
|
|
288
348
|
return;
|
|
289
349
|
}
|
|
290
|
-
this.#
|
|
350
|
+
this.#deletePending(message.id);
|
|
291
351
|
if (message.type === "transcription") {
|
|
292
352
|
if (pending.kind === "transcribe") pending.resolve(message.text);
|
|
293
353
|
return;
|
|
294
354
|
}
|
|
295
355
|
if (message.type === "downloaded") {
|
|
296
|
-
if (pending.kind === "download") pending.resolve(true);
|
|
356
|
+
if (pending.kind === "download") pending.resolve({ ok: true });
|
|
297
357
|
return;
|
|
298
358
|
}
|
|
299
359
|
// message.type === "error"
|
|
300
360
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
301
361
|
if (pending.kind === "transcribe") pending.reject(new Error(message.error));
|
|
302
|
-
else pending.resolve(false);
|
|
362
|
+
else pending.resolve({ ok: false, error: message.error });
|
|
303
363
|
}
|
|
304
364
|
|
|
305
365
|
#emitProgress(event: SttProgressEvent): void {
|
|
@@ -318,7 +378,7 @@ export class SttClient {
|
|
|
318
378
|
for (const pending of this.#pending.values()) {
|
|
319
379
|
this.#emitProgress({ modelKey: pending.modelKey, status: "error" });
|
|
320
380
|
if (pending.kind === "transcribe") pending.reject(error);
|
|
321
|
-
else pending.resolve(false);
|
|
381
|
+
else pending.resolve({ ok: false, error: error.message });
|
|
322
382
|
}
|
|
323
383
|
this.#pending.clear();
|
|
324
384
|
this.#failStreams(error);
|
package/src/stt/downloader.ts
CHANGED
|
@@ -90,7 +90,7 @@ export async function downloadSttModel(
|
|
|
90
90
|
): Promise<void> {
|
|
91
91
|
const spec = resolveSttModelSpec(key);
|
|
92
92
|
const files = new Map<string, { loaded: number; total: number }>();
|
|
93
|
-
const
|
|
93
|
+
const result = await sttClient.downloadModel(spec.key, {
|
|
94
94
|
signal: options?.signal,
|
|
95
95
|
onProgress: event => {
|
|
96
96
|
if ((event.status === "progress" || event.status === "progress_total") && event.file) {
|
|
@@ -117,7 +117,13 @@ export async function downloadSttModel(
|
|
|
117
117
|
});
|
|
118
118
|
},
|
|
119
119
|
});
|
|
120
|
-
if (!ok)
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
const detail = result.error ? `: ${result.error}` : ". Check your network connection.";
|
|
122
|
+
throw new Error(`Failed to download speech model (${spec.repo})${detail}`);
|
|
123
|
+
}
|
|
124
|
+
if (!(await isSttModelCached(spec.key))) {
|
|
125
|
+
throw new Error(`Speech model download finished without required files (${spec.repo}).`);
|
|
126
|
+
}
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
// ── Public API ─────────────────────────────────────────────────────
|
package/src/task/executor.ts
CHANGED
|
@@ -2053,9 +2053,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2053
2053
|
? formatModelSelectorValue(formatModelStringWithRouting(model), resolvedThinkingLevel)
|
|
2054
2054
|
: formatModelStringWithRouting(model);
|
|
2055
2055
|
}
|
|
2056
|
-
const effectiveThinkingLevel =
|
|
2057
|
-
? resolvedThinkingLevel
|
|
2058
|
-
: (thinkingLevel ?? resolvedThinkingLevel);
|
|
2056
|
+
const effectiveThinkingLevel = thinkingLevel ?? resolvedThinkingLevel;
|
|
2059
2057
|
resolvedAt = performance.now();
|
|
2060
2058
|
|
|
2061
2059
|
const effectiveCwd = worktree ?? cwd;
|
package/src/task/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type:
|
|
|
30
30
|
import { truncateForPrompt } from "../tools/approval";
|
|
31
31
|
import { isIrcEnabled } from "../tools/irc";
|
|
32
32
|
import { formatBytes, formatDuration } from "../tools/render-utils";
|
|
33
|
+
import { DEFAULT_SPAWN_AGENT, resolveSpawnPolicy } from "./spawn-policy";
|
|
33
34
|
import {
|
|
34
35
|
type AgentDefinition,
|
|
35
36
|
type AgentProgress,
|
|
@@ -187,17 +188,13 @@ function renderDescription(
|
|
|
187
188
|
ircEnabled: boolean,
|
|
188
189
|
parentSpawns: string,
|
|
189
190
|
): string {
|
|
190
|
-
const
|
|
191
|
+
const spawnPolicy = resolveSpawnPolicy(parentSpawns);
|
|
192
|
+
const spawningDisabled = !spawnPolicy.enabled;
|
|
191
193
|
let filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
|
|
192
194
|
if (spawningDisabled) {
|
|
193
195
|
filteredAgents = [];
|
|
194
|
-
} else if (
|
|
195
|
-
const allowed = new Set(
|
|
196
|
-
parentSpawns
|
|
197
|
-
.split(",")
|
|
198
|
-
.map(s => s.trim())
|
|
199
|
-
.filter(Boolean),
|
|
200
|
-
);
|
|
196
|
+
} else if (spawnPolicy.allowedAgents !== null) {
|
|
197
|
+
const allowed = new Set(spawnPolicy.allowedAgents);
|
|
201
198
|
filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
|
|
202
199
|
}
|
|
203
200
|
const renderedAgents = filteredAgents.map(agent => ({
|
|
@@ -208,6 +205,9 @@ function renderDescription(
|
|
|
208
205
|
return prompt.render(taskDescriptionTemplate, {
|
|
209
206
|
agents: renderedAgents,
|
|
210
207
|
spawningDisabled,
|
|
208
|
+
defaultAgent: spawnPolicy.defaultAgent,
|
|
209
|
+
defaultAgentIsGeneric: spawnPolicy.defaultAgent === DEFAULT_SPAWN_AGENT,
|
|
210
|
+
allowedAgentsText: spawnPolicy.allowedPromptText,
|
|
211
211
|
MAX_CONCURRENCY: maxConcurrency,
|
|
212
212
|
isolationEnabled,
|
|
213
213
|
batchEnabled,
|
|
@@ -331,9 +331,6 @@ function spawnParamsFor(params: TaskParams, item: TaskItem): TaskParams {
|
|
|
331
331
|
return spawn;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
-
/** Agent type spawned when a `task` call omits `agent`; mirrors the schema default in `getTaskSchema`. */
|
|
335
|
-
const DEFAULT_TASK_AGENT = "task";
|
|
336
|
-
|
|
337
334
|
/** Generic worker agents whose output sharpens with a tailored `role` rather than the bare type. */
|
|
338
335
|
const GENERIC_SPAWN_AGENTS: ReadonlySet<string> = new Set(["task", "sonic"]);
|
|
339
336
|
|
|
@@ -511,7 +508,8 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
511
508
|
|
|
512
509
|
get parameters(): TaskToolSchemaInstance {
|
|
513
510
|
const isolationEnabled = this.session.settings.get("task.isolation.mode") !== "none";
|
|
514
|
-
|
|
511
|
+
const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
|
|
512
|
+
return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled(), defaultAgent });
|
|
515
513
|
}
|
|
516
514
|
|
|
517
515
|
renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
|
|
@@ -566,13 +564,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
566
564
|
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
|
|
567
565
|
): Promise<AgentToolResult<TaskToolDetails>> {
|
|
568
566
|
const repaired = repairTaskParams(rawParams as TaskParams);
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
567
|
+
// Schema defaults run for model calls, but internal callers and stale
|
|
568
|
+
// transcripts can bypass arktype. Normalize once so every downstream path
|
|
569
|
+
// sees the session's actual default agent.
|
|
570
|
+
const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
|
|
572
571
|
const params =
|
|
573
572
|
typeof repaired.agent === "string" && repaired.agent.trim() !== ""
|
|
574
573
|
? repaired
|
|
575
|
-
: { ...repaired, agent:
|
|
574
|
+
: { ...repaired, agent: defaultAgent };
|
|
576
575
|
const batchEnabled = this.#isBatchEnabled();
|
|
577
576
|
const validationError = validateShapeParams(batchEnabled, params) ?? validateSpawnParams(params, batchEnabled);
|
|
578
577
|
if (validationError) {
|
|
@@ -803,10 +802,19 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
803
802
|
async ({ jobId: ownJobId, signal: runSignal, reportProgress, markRunning }) => {
|
|
804
803
|
const startedAt = Date.now();
|
|
805
804
|
const semaphore = this.#getSpawnSemaphore();
|
|
806
|
-
|
|
805
|
+
let semaphoreHeld = false;
|
|
806
|
+
try {
|
|
807
|
+
await semaphore.acquire(runSignal);
|
|
808
|
+
semaphoreHeld = true;
|
|
809
|
+
} catch {
|
|
810
|
+
// Fall through so an acquire-time abort goes through the same
|
|
811
|
+
// path as the post-acquire race below: progress + onSettled
|
|
812
|
+
// have to fire even when the spawn never reached the executor,
|
|
813
|
+
// otherwise the batch aggregate state stays "running" forever.
|
|
814
|
+
}
|
|
807
815
|
const acquiredAt = Date.now();
|
|
808
|
-
if (runSignal.aborted) {
|
|
809
|
-
semaphore.release();
|
|
816
|
+
if (!semaphoreHeld || runSignal.aborted) {
|
|
817
|
+
if (semaphoreHeld) semaphore.release();
|
|
810
818
|
progress.status = "aborted";
|
|
811
819
|
onSettled?.(true);
|
|
812
820
|
throw new Error("Aborted before execution");
|
|
@@ -909,7 +917,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
909
917
|
const semaphore = this.#getSpawnSemaphore();
|
|
910
918
|
if (spawnItems.length === 1) {
|
|
911
919
|
const invokedAt = Date.now();
|
|
912
|
-
await semaphore.acquire();
|
|
920
|
+
await semaphore.acquire(signal);
|
|
913
921
|
const acquiredAt = Date.now();
|
|
914
922
|
try {
|
|
915
923
|
return await this.#executeSync(
|
|
@@ -948,7 +956,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
948
956
|
spawnItems.length,
|
|
949
957
|
async (item, index, workerSignal) => {
|
|
950
958
|
const invokedAt = Date.now();
|
|
951
|
-
await semaphore.acquire();
|
|
959
|
+
await semaphore.acquire(workerSignal);
|
|
952
960
|
const acquiredAt = Date.now();
|
|
953
961
|
try {
|
|
954
962
|
const itemOnUpdate: AgentToolUpdateCallback<TaskToolDetails> | undefined = onUpdate
|
|
@@ -1180,18 +1188,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1180
1188
|
}
|
|
1181
1189
|
|
|
1182
1190
|
// Check spawn restrictions from parent
|
|
1183
|
-
const
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
return allowedSpawns.includes(agentName);
|
|
1189
|
-
};
|
|
1190
|
-
|
|
1191
|
-
if (!isSpawnAllowed()) {
|
|
1192
|
-
const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
|
|
1191
|
+
const spawnPolicy = resolveSpawnPolicy(this.session.getSessionSpawns());
|
|
1192
|
+
const spawnAllowed =
|
|
1193
|
+
spawnPolicy.enabled &&
|
|
1194
|
+
(spawnPolicy.allowedAgents === null || spawnPolicy.allowedAgents.includes(agentName));
|
|
1195
|
+
if (!spawnAllowed) {
|
|
1193
1196
|
return {
|
|
1194
|
-
content: [
|
|
1197
|
+
content: [
|
|
1198
|
+
{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}` },
|
|
1199
|
+
],
|
|
1195
1200
|
details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
|
|
1196
1201
|
};
|
|
1197
1202
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { Settings } from "../config/settings";
|
|
3
|
+
import type { ToolSession } from "../tools";
|
|
4
|
+
import * as taskDiscovery from "./discovery";
|
|
5
|
+
import { TaskTool } from "./index";
|
|
6
|
+
import type { AgentDefinition } from "./types";
|
|
7
|
+
import { getTaskSchema } from "./types";
|
|
8
|
+
|
|
9
|
+
const factFinderAgent = {
|
|
10
|
+
name: "fact-finder",
|
|
11
|
+
description: "Find facts.",
|
|
12
|
+
systemPrompt: "Find facts.",
|
|
13
|
+
source: "project",
|
|
14
|
+
} satisfies AgentDefinition;
|
|
15
|
+
|
|
16
|
+
const oracleAgent = {
|
|
17
|
+
name: "oracle",
|
|
18
|
+
description: "Answer hard questions.",
|
|
19
|
+
systemPrompt: "Answer hard questions.",
|
|
20
|
+
source: "bundled",
|
|
21
|
+
} satisfies AgentDefinition;
|
|
22
|
+
|
|
23
|
+
function makeSession(spawns: string): ToolSession {
|
|
24
|
+
const settings = Settings.isolated({
|
|
25
|
+
"async.enabled": false,
|
|
26
|
+
"task.batch": true,
|
|
27
|
+
"task.isolation.mode": "none",
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
cwd: process.cwd(),
|
|
31
|
+
hasUI: false,
|
|
32
|
+
settings,
|
|
33
|
+
getSessionFile: () => null,
|
|
34
|
+
getSessionSpawns: () => spawns,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("task spawn policy surfaces", () => {
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("uses the first allowed spawn as the schema default", () => {
|
|
44
|
+
const schema = getTaskSchema({ isolationEnabled: false, batchEnabled: false, defaultAgent: "fact-finder" });
|
|
45
|
+
const parsed = schema({ assignment: "check" });
|
|
46
|
+
|
|
47
|
+
expect(parsed).toEqual({ agent: "fact-finder", assignment: "check" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders the restricted spawn default in the task description", async () => {
|
|
51
|
+
vi.spyOn(taskDiscovery, "discoverAgents").mockResolvedValue({
|
|
52
|
+
agents: [factFinderAgent, oracleAgent],
|
|
53
|
+
projectAgentsDir: null,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const tool = await TaskTool.create(makeSession("fact-finder,oracle"));
|
|
57
|
+
const description = tool.description;
|
|
58
|
+
|
|
59
|
+
expect(description).toContain("Defaults to `fact-finder`");
|
|
60
|
+
expect(description).toContain("Current spawn policy allows: `fact-finder`, `oracle`.");
|
|
61
|
+
expect(description).not.toContain("Defaults to `task`");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Default agent used when a session has unrestricted spawning. */
|
|
2
|
+
export const DEFAULT_SPAWN_AGENT = "task";
|
|
3
|
+
|
|
4
|
+
/** Spawn policy derived from a parent agent's `spawns` frontmatter. */
|
|
5
|
+
export interface ResolvedSpawnPolicy {
|
|
6
|
+
/** True when at least one subagent may be spawned. */
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
/** Agent used when the caller omits the agent field. */
|
|
9
|
+
defaultAgent: string;
|
|
10
|
+
/** Explicitly allowed agents, or `null` when the policy is unrestricted. */
|
|
11
|
+
allowedAgents: readonly string[] | null;
|
|
12
|
+
/** Text used in spawn rejection messages. */
|
|
13
|
+
allowedErrorText: string;
|
|
14
|
+
/** Backtick-quoted explicit agents for prompt descriptions. */
|
|
15
|
+
allowedPromptText?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolves spawn frontmatter into the default and prompt/error surfaces. */
|
|
19
|
+
export function resolveSpawnPolicy(parentSpawns: string | boolean | null | undefined): ResolvedSpawnPolicy {
|
|
20
|
+
let normalized: string;
|
|
21
|
+
if (parentSpawns === false) {
|
|
22
|
+
normalized = "";
|
|
23
|
+
} else if (parentSpawns === true || parentSpawns === null || parentSpawns === undefined) {
|
|
24
|
+
normalized = "*";
|
|
25
|
+
} else {
|
|
26
|
+
normalized = parentSpawns.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (normalized === "*") {
|
|
30
|
+
return {
|
|
31
|
+
enabled: true,
|
|
32
|
+
defaultAgent: DEFAULT_SPAWN_AGENT,
|
|
33
|
+
allowedAgents: null,
|
|
34
|
+
allowedErrorText: "*",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const allowedAgents = normalized
|
|
39
|
+
.split(",")
|
|
40
|
+
.map(spawn => spawn.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
if (allowedAgents.length === 0) {
|
|
43
|
+
return {
|
|
44
|
+
enabled: false,
|
|
45
|
+
defaultAgent: DEFAULT_SPAWN_AGENT,
|
|
46
|
+
allowedAgents,
|
|
47
|
+
allowedErrorText: "none (spawns disabled for this agent)",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
enabled: true,
|
|
53
|
+
defaultAgent: allowedAgents[0] ?? DEFAULT_SPAWN_AGENT,
|
|
54
|
+
allowedAgents,
|
|
55
|
+
allowedErrorText: allowedAgents.join(","),
|
|
56
|
+
allowedPromptText: allowedAgents.map(agent => `\`${agent}\``).join(", "),
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/task/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import type { Usage } from "@oh-my-pi/pi-ai";
|
|
3
3
|
import { $env } from "@oh-my-pi/pi-utils";
|
|
4
|
-
import { type } from "arktype";
|
|
4
|
+
import { type BaseType, type } from "arktype";
|
|
5
5
|
import type { AgentSessionEvent } from "../session/agent-session";
|
|
6
6
|
import type { NestedRepoPatch } from "./worktree";
|
|
7
7
|
|
|
@@ -144,13 +144,84 @@ const ALL_TASK_SCHEMAS = [taskSchema, taskSchemaNoIsolation, taskSchemaBatch, ta
|
|
|
144
144
|
type DynamicTaskSchema = (typeof ALL_TASK_SCHEMAS)[number];
|
|
145
145
|
export type TaskSchema = typeof taskSchema;
|
|
146
146
|
/** Active task tool parameter schema for the current isolation / batch flags */
|
|
147
|
-
export type TaskToolSchemaInstance = DynamicTaskSchema;
|
|
147
|
+
export type TaskToolSchemaInstance = DynamicTaskSchema | BaseType;
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
const TASK_AGENT_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
150
|
+
const taskSchemaCache = new Map<string, BaseType>();
|
|
151
|
+
|
|
152
|
+
function taskAgentSchemaRule(defaultAgent: string): string {
|
|
153
|
+
const trimmed = defaultAgent.trim();
|
|
154
|
+
if (TASK_AGENT_NAME_PATTERN.test(trimmed)) {
|
|
155
|
+
return `string = '${trimmed}'`;
|
|
156
|
+
}
|
|
157
|
+
return "string";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function createTaskSchema(options: {
|
|
161
|
+
isolationEnabled: boolean;
|
|
162
|
+
batchEnabled: boolean;
|
|
163
|
+
defaultAgent: string;
|
|
164
|
+
}): BaseType {
|
|
165
|
+
const agent = taskAgentSchemaRule(options.defaultAgent);
|
|
150
166
|
if (options.batchEnabled) {
|
|
151
|
-
|
|
167
|
+
if (options.isolationEnabled) {
|
|
168
|
+
return type.raw({
|
|
169
|
+
agent,
|
|
170
|
+
context: "string",
|
|
171
|
+
tasks: taskItemSchemaIsolated.array(),
|
|
172
|
+
"+": "delete",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return type.raw({
|
|
176
|
+
agent,
|
|
177
|
+
context: "string",
|
|
178
|
+
tasks: taskItemSchema.array(),
|
|
179
|
+
"+": "delete",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (options.isolationEnabled) {
|
|
183
|
+
return type.raw({
|
|
184
|
+
agent,
|
|
185
|
+
"id?": "string",
|
|
186
|
+
"description?": "string",
|
|
187
|
+
"role?": ROLE_INPUT_SCHEMA,
|
|
188
|
+
assignment: "string",
|
|
189
|
+
"isolated?": "boolean",
|
|
190
|
+
"+": "delete",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return type.raw({
|
|
194
|
+
agent,
|
|
195
|
+
"id?": "string",
|
|
196
|
+
"description?": "string",
|
|
197
|
+
"role?": ROLE_INPUT_SCHEMA,
|
|
198
|
+
assignment: "string",
|
|
199
|
+
"+": "delete",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema;
|
|
204
|
+
export function getTaskSchema(options: {
|
|
205
|
+
isolationEnabled: boolean;
|
|
206
|
+
batchEnabled: boolean;
|
|
207
|
+
defaultAgent: string;
|
|
208
|
+
}): TaskToolSchemaInstance;
|
|
209
|
+
export function getTaskSchema(options: {
|
|
210
|
+
isolationEnabled: boolean;
|
|
211
|
+
batchEnabled: boolean;
|
|
212
|
+
defaultAgent?: string;
|
|
213
|
+
}): TaskToolSchemaInstance {
|
|
214
|
+
const defaultAgent = options.defaultAgent ?? "task";
|
|
215
|
+
if (defaultAgent === "task") {
|
|
216
|
+
if (options.batchEnabled) return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation;
|
|
217
|
+
return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
|
|
152
218
|
}
|
|
153
|
-
|
|
219
|
+
const key = `${options.isolationEnabled ? "iso" : "flat"}:${options.batchEnabled ? "batch" : "single"}:${defaultAgent}`;
|
|
220
|
+
const cached = taskSchemaCache.get(key);
|
|
221
|
+
if (cached) return cached;
|
|
222
|
+
const schema = createTaskSchema({ ...options, defaultAgent });
|
|
223
|
+
taskSchemaCache.set(key, schema);
|
|
224
|
+
return schema;
|
|
154
225
|
}
|
|
155
226
|
|
|
156
227
|
/**
|
|
@@ -160,7 +231,7 @@ export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled
|
|
|
160
231
|
* transcripts using the flat form keep working under either setting.
|
|
161
232
|
*/
|
|
162
233
|
export interface TaskParams {
|
|
163
|
-
/** Agent type to spawn;
|
|
234
|
+
/** Agent type to spawn; omitted values resolve from the session spawn policy. */
|
|
164
235
|
agent?: string;
|
|
165
236
|
/** Stable agent id (flat form); default = generated AdjectiveNoun. */
|
|
166
237
|
id?: string;
|