@oh-my-pi/pi-coding-agent 16.2.11 → 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 +31 -0
- package/dist/cli.js +3005 -2848
- 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/lsp/client.d.ts +9 -3
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +0 -5
- 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 +12 -12
- package/src/advisor/__tests__/advisor.test.ts +1 -1
- 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/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 +32 -6
- 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/lsp/client.ts +162 -45
- 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/components/model-selector.ts +32 -186
- package/src/modes/controllers/event-controller.ts +8 -39
- package/src/modes/controllers/selector-controller.ts +0 -1
- package/src/modes/controllers/tool-args-reveal.ts +0 -12
- 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/task/index.ts +23 -27
- 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/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/inspect-image.ts +1 -1
- package/src/tools/write.ts +49 -9
- package/src/utils/commit-message-generator.ts +0 -1
- package/src/utils/image-vision-fallback.ts +2 -3
- package/src/utils/title-generator.ts +1 -1
package/src/sdk.ts
CHANGED
|
@@ -1244,7 +1244,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1244
1244
|
resolveModelRoleValue(settings.getModelRole("default"), allowedModels, {
|
|
1245
1245
|
settings,
|
|
1246
1246
|
matchPreferences: modelMatchPreferences,
|
|
1247
|
-
modelRegistry,
|
|
1248
1247
|
}),
|
|
1249
1248
|
);
|
|
1250
1249
|
let model = options.model;
|
|
@@ -1946,9 +1945,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1946
1945
|
if (!model && options.modelPattern) {
|
|
1947
1946
|
const availableModels = modelRegistry.getAll();
|
|
1948
1947
|
const matchPreferences = getModelMatchPreferences(settings);
|
|
1949
|
-
const { model: resolved } = parseModelPattern(options.modelPattern, availableModels, matchPreferences
|
|
1950
|
-
modelRegistry,
|
|
1951
|
-
});
|
|
1948
|
+
const { model: resolved } = parseModelPattern(options.modelPattern, availableModels, matchPreferences);
|
|
1952
1949
|
if (resolved) {
|
|
1953
1950
|
model = resolved;
|
|
1954
1951
|
modelFallbackMessage = undefined;
|
|
@@ -1978,7 +1975,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1978
1975
|
const reResolvedRoleSpec = resolveModelRoleValue(settings.getModelRole("default"), fallbackCandidates, {
|
|
1979
1976
|
settings,
|
|
1980
1977
|
matchPreferences: modelMatchPreferences,
|
|
1981
|
-
modelRegistry,
|
|
1982
1978
|
});
|
|
1983
1979
|
if (reResolvedRoleSpec.model) {
|
|
1984
1980
|
defaultRoleSpec = reResolvedRoleSpec;
|
|
@@ -2179,10 +2175,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2179
2175
|
// `auto` enforces the per-model policy (inline for Gemini, off otherwise);
|
|
2180
2176
|
// like the rest of the prune machinery this is fixed for the session, so a
|
|
2181
2177
|
// mid-session model switch keeps the start-time decision.
|
|
2182
|
-
const inlineToolDescriptors = shouldInlineToolDescriptors(
|
|
2183
|
-
settings.get("inlineToolDescriptors"),
|
|
2184
|
-
model ? (modelRegistry.getCanonicalId(model) ?? model.id) : undefined,
|
|
2185
|
-
);
|
|
2178
|
+
const inlineToolDescriptors = shouldInlineToolDescriptors(settings.get("inlineToolDescriptors"), model?.id);
|
|
2186
2179
|
const eagerTasks = settings.get("task.eager") !== "default";
|
|
2187
2180
|
const eagerTasksAlways = settings.get("task.eager") === "always";
|
|
2188
2181
|
const intentField = $flag("PI_INTENT_TRACING", settings.get("tools.intentTracing")) ? INTENT_FIELD : undefined;
|
|
@@ -115,6 +115,7 @@ import {
|
|
|
115
115
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
116
116
|
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
117
117
|
import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
|
|
118
|
+
import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
|
|
118
119
|
import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
|
|
119
120
|
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
120
121
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
@@ -251,6 +252,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
|
|
|
251
252
|
};
|
|
252
253
|
import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" };
|
|
253
254
|
import thinkingLoopRedirectTemplate from "../prompts/system/thinking-loop-redirect.md" with { type: "text" };
|
|
255
|
+
import toolCallLoopRedirectTemplate from "../prompts/system/tool-call-loop-redirect.md" with { type: "text" };
|
|
254
256
|
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
|
255
257
|
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
|
|
256
258
|
import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
|
|
@@ -285,6 +287,7 @@ import {
|
|
|
285
287
|
selectDiscoverableToolNamesByServer,
|
|
286
288
|
} from "../tool-discovery/tool-index";
|
|
287
289
|
import { assertEditableFile } from "../tools/auto-generated-guard";
|
|
290
|
+
import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
|
|
288
291
|
import { normalizeToolNames } from "../tools/builtin-names";
|
|
289
292
|
import type { CheckpointState } from "../tools/checkpoint";
|
|
290
293
|
import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
|
|
@@ -350,6 +353,7 @@ const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
|
|
|
350
353
|
/** `customType` for the hidden redirect notice injected into a turn retried after a
|
|
351
354
|
* thinking/response loop. Steers the model off the repeated content; never displayed. */
|
|
352
355
|
const THINKING_LOOP_REDIRECT_TYPE = "thinking-loop-redirect";
|
|
356
|
+
const TOOL_CALL_LOOP_REDIRECT_TYPE = "tool-call-loop-redirect";
|
|
353
357
|
|
|
354
358
|
// A side-channel assistant response is signed for the hidden prompt/history that
|
|
355
359
|
// produced it. If we persist that response under a different user turn, native
|
|
@@ -1554,6 +1558,8 @@ export class AgentSession {
|
|
|
1554
1558
|
* `#geminiHeaderGuardActive`); undefined for non-Gemini models or when the
|
|
1555
1559
|
* guard is off. Fed thinking deltas in the assistant-message interceptor. */
|
|
1556
1560
|
#geminiHeaderDetector: GeminiHeaderRunDetector | undefined;
|
|
1561
|
+
#toolCallLoopGuard: ToolCallLoopGuard | undefined;
|
|
1562
|
+
#toolCallLoopGuardSettingsKey: string | undefined;
|
|
1557
1563
|
#promptInFlightCount = 0;
|
|
1558
1564
|
#abortInProgress = false;
|
|
1559
1565
|
// Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
|
|
@@ -1848,6 +1854,13 @@ export class AgentSession {
|
|
|
1848
1854
|
this.#pendingRewindReport = undefined;
|
|
1849
1855
|
await this.#applyRewind(rewindReport, messages);
|
|
1850
1856
|
}
|
|
1857
|
+
if (context?.message.role === "assistant") {
|
|
1858
|
+
const detection = this.#activeToolCallLoopGuard()?.recordTurn({
|
|
1859
|
+
message: context.message,
|
|
1860
|
+
toolResults: context.toolResults,
|
|
1861
|
+
});
|
|
1862
|
+
if (detection) this.#maybeInjectToolCallLoopRedirect(messages, detection);
|
|
1863
|
+
}
|
|
1851
1864
|
this.#advisorPrimaryTurnsCompleted++;
|
|
1852
1865
|
if (this.#advisors.length > 0) {
|
|
1853
1866
|
for (const a of this.#advisors) {
|
|
@@ -2095,11 +2108,7 @@ export class AgentSession {
|
|
|
2095
2108
|
continue;
|
|
2096
2109
|
}
|
|
2097
2110
|
} else {
|
|
2098
|
-
const sel = resolveAdvisorRoleSelection(
|
|
2099
|
-
this.settings,
|
|
2100
|
-
this.#modelRegistry.getAvailable(),
|
|
2101
|
-
this.#modelRegistry,
|
|
2102
|
-
);
|
|
2111
|
+
const sel = resolveAdvisorRoleSelection(this.settings, this.#modelRegistry.getAvailable());
|
|
2103
2112
|
if (!sel) {
|
|
2104
2113
|
logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
|
|
2105
2114
|
advisor: config.name,
|
|
@@ -4299,6 +4308,58 @@ export class AgentSession {
|
|
|
4299
4308
|
this.#streamingEditFileCache.clear();
|
|
4300
4309
|
}
|
|
4301
4310
|
|
|
4311
|
+
#activeToolCallLoopGuard(): ToolCallLoopGuard | undefined {
|
|
4312
|
+
if (this.settings.get("model.toolCallLoopGuard.enabled") !== true) {
|
|
4313
|
+
this.#toolCallLoopGuard = undefined;
|
|
4314
|
+
this.#toolCallLoopGuardSettingsKey = undefined;
|
|
4315
|
+
return undefined;
|
|
4316
|
+
}
|
|
4317
|
+
|
|
4318
|
+
const threshold = this.settings.get("model.toolCallLoopGuard.threshold");
|
|
4319
|
+
const exemptTools = this.settings
|
|
4320
|
+
.get("model.toolCallLoopGuard.exemptTools")
|
|
4321
|
+
.filter((tool): tool is string => typeof tool === "string" && tool.length > 0);
|
|
4322
|
+
const settingsKey = `${threshold}:${JSON.stringify(exemptTools)}`;
|
|
4323
|
+
if (!this.#toolCallLoopGuard || this.#toolCallLoopGuardSettingsKey !== settingsKey) {
|
|
4324
|
+
this.#toolCallLoopGuard = new ToolCallLoopGuard({ threshold, exemptTools });
|
|
4325
|
+
this.#toolCallLoopGuardSettingsKey = settingsKey;
|
|
4326
|
+
}
|
|
4327
|
+
return this.#toolCallLoopGuard;
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4330
|
+
#maybeInjectToolCallLoopRedirect(messages: AgentMessage[], detection: RepeatedToolCallDetection): void {
|
|
4331
|
+
const content = prompt.render(toolCallLoopRedirectTemplate, {
|
|
4332
|
+
tool_name: detection.toolName,
|
|
4333
|
+
count: detection.count,
|
|
4334
|
+
arguments_summary: detection.argumentsSummary,
|
|
4335
|
+
result_summary: detection.resultSummary || "(no text result)",
|
|
4336
|
+
});
|
|
4337
|
+
const details = {
|
|
4338
|
+
toolName: detection.toolName,
|
|
4339
|
+
count: detection.count,
|
|
4340
|
+
argumentsSummary: detection.argumentsSummary,
|
|
4341
|
+
resultSummary: detection.resultSummary,
|
|
4342
|
+
};
|
|
4343
|
+
logger.warn("cross-turn tool-call loop detected", {
|
|
4344
|
+
toolName: detection.toolName,
|
|
4345
|
+
count: detection.count,
|
|
4346
|
+
});
|
|
4347
|
+
const redirectMessage: CustomMessage = {
|
|
4348
|
+
role: "custom",
|
|
4349
|
+
customType: TOOL_CALL_LOOP_REDIRECT_TYPE,
|
|
4350
|
+
content,
|
|
4351
|
+
display: false,
|
|
4352
|
+
details,
|
|
4353
|
+
attribution: "agent",
|
|
4354
|
+
timestamp: Date.now(),
|
|
4355
|
+
};
|
|
4356
|
+
messages.push(redirectMessage);
|
|
4357
|
+
if (this.agent.state.messages !== messages) {
|
|
4358
|
+
this.agent.appendMessage(redirectMessage);
|
|
4359
|
+
}
|
|
4360
|
+
this.sessionManager.appendCustomMessageEntry(TOOL_CALL_LOOP_REDIRECT_TYPE, content, false, details, "agent");
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4302
4363
|
/**
|
|
4303
4364
|
* Whether the Gemini header-runaway guard applies to the current model: the loop
|
|
4304
4365
|
* guard is on (settings + `PI_NO_THINKING_LOOP_GUARD`), the tool-call reminder is
|
|
@@ -5093,6 +5154,28 @@ export class AgentSession {
|
|
|
5093
5154
|
await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
|
|
5094
5155
|
await disposeRubyKernelSessionsByOwner(this.#evalKernelOwnerId);
|
|
5095
5156
|
await disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId);
|
|
5157
|
+
// Release headless / spawned Chromium and worker tabs this session
|
|
5158
|
+
// opened via the browser tool. The tool's `tabs`/`browsers` maps are
|
|
5159
|
+
// module-global — subagents and future sessions share them — so we
|
|
5160
|
+
// walk by `ownerSessionId` (assigned at `acquireTab` creation, never on
|
|
5161
|
+
// reuse) and touch only what THIS session created. Bounded so a broken
|
|
5162
|
+
// CDP close cannot stall `/exit`; mirrors the async-job/MCP pattern.
|
|
5163
|
+
// (Issue #3963.)
|
|
5164
|
+
const browserOwnerId = this.sessionManager.getSessionId();
|
|
5165
|
+
if (browserOwnerId) {
|
|
5166
|
+
try {
|
|
5167
|
+
const released = await withTimeout(
|
|
5168
|
+
releaseTabsForOwner(browserOwnerId, { kill: true }),
|
|
5169
|
+
3_000,
|
|
5170
|
+
"Timed out releasing owned browser tabs during dispose",
|
|
5171
|
+
);
|
|
5172
|
+
if (released > 0) {
|
|
5173
|
+
logger.debug("Released owned browser tabs during dispose", { ownerId: browserOwnerId, released });
|
|
5174
|
+
}
|
|
5175
|
+
} catch (error) {
|
|
5176
|
+
logger.warn("Failed to release owned browser tabs during dispose", { error: String(error) });
|
|
5177
|
+
}
|
|
5178
|
+
}
|
|
5096
5179
|
await shutdownTinyTitleClient();
|
|
5097
5180
|
this.#releasePowerAssertion();
|
|
5098
5181
|
// Clean up an empty session created by this session's /move so it doesn't accumulate.
|
|
@@ -8158,7 +8241,6 @@ export class AgentSession {
|
|
|
8158
8241
|
const resolved = resolveModelRoleValue(roleModelStr, availableModels, {
|
|
8159
8242
|
settings: this.settings,
|
|
8160
8243
|
matchPreferences,
|
|
8161
|
-
modelRegistry: this.#modelRegistry,
|
|
8162
8244
|
});
|
|
8163
8245
|
if (!resolved.model) continue;
|
|
8164
8246
|
|
|
@@ -8299,7 +8381,7 @@ export class AgentSession {
|
|
|
8299
8381
|
const all = this.#modelRegistry.getAvailable();
|
|
8300
8382
|
const patterns = this.settings.get("enabledModels");
|
|
8301
8383
|
if (!patterns || patterns.length === 0) return all;
|
|
8302
|
-
return filterAvailableModelsByEnabledPatterns(all, patterns
|
|
8384
|
+
return filterAvailableModelsByEnabledPatterns(all, patterns);
|
|
8303
8385
|
}
|
|
8304
8386
|
|
|
8305
8387
|
// =========================================================================
|
|
@@ -10713,7 +10795,6 @@ export class AgentSession {
|
|
|
10713
10795
|
return resolveModelRoleValue(roleModelStr, availableModels, {
|
|
10714
10796
|
settings: this.settings,
|
|
10715
10797
|
matchPreferences: getModelMatchPreferences(this.settings),
|
|
10716
|
-
modelRegistry: this.#modelRegistry,
|
|
10717
10798
|
});
|
|
10718
10799
|
}
|
|
10719
10800
|
|
|
@@ -142,9 +142,10 @@ export function buildSessionContext(
|
|
|
142
142
|
const path: SessionEntry[] = [];
|
|
143
143
|
let current: SessionEntry | undefined = leaf;
|
|
144
144
|
while (current) {
|
|
145
|
-
path.
|
|
145
|
+
path.push(current);
|
|
146
146
|
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
147
147
|
}
|
|
148
|
+
path.reverse();
|
|
148
149
|
|
|
149
150
|
// Extract settings and find compaction
|
|
150
151
|
let thinkingLevel: string | undefined = "off";
|
|
@@ -242,9 +242,10 @@ class SessionEntryIndex {
|
|
|
242
242
|
|
|
243
243
|
while (cursor && !seen.has(cursor.id)) {
|
|
244
244
|
seen.add(cursor.id);
|
|
245
|
-
branch.
|
|
245
|
+
branch.push(cursor);
|
|
246
246
|
cursor = cursor.parentId ? this.#entriesById.get(cursor.parentId) : undefined;
|
|
247
247
|
}
|
|
248
|
+
branch.reverse();
|
|
248
249
|
|
|
249
250
|
return branch;
|
|
250
251
|
}
|
|
@@ -64,7 +64,7 @@ export async function classifyUnexpectedStop(
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): Promise<boolean | undefined> {
|
|
67
|
-
const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable()
|
|
67
|
+
const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable());
|
|
68
68
|
const model = resolved?.model;
|
|
69
69
|
if (!model) {
|
|
70
70
|
throw new Error("unexpected-stop: no tiny/smol model available for classification");
|
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) {
|
|
@@ -1189,18 +1188,15 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
1189
1188
|
}
|
|
1190
1189
|
|
|
1191
1190
|
// Check spawn restrictions from parent
|
|
1192
|
-
const
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
return allowedSpawns.includes(agentName);
|
|
1198
|
-
};
|
|
1199
|
-
|
|
1200
|
-
if (!isSpawnAllowed()) {
|
|
1201
|
-
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) {
|
|
1202
1196
|
return {
|
|
1203
|
-
content: [
|
|
1197
|
+
content: [
|
|
1198
|
+
{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${spawnPolicy.allowedErrorText}` },
|
|
1199
|
+
],
|
|
1204
1200
|
details: { projectAgentsDir, results: [], totalDurationMs: Date.now() - startTime },
|
|
1205
1201
|
};
|
|
1206
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;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { getEvalToolDescription } from "../eval";
|
|
3
|
+
|
|
4
|
+
describe("eval tool description", () => {
|
|
5
|
+
it("advertises the first allowed spawn as the agent() default", () => {
|
|
6
|
+
const description = getEvalToolDescription({ py: true, js: false, spawns: "fact-finder,oracle" });
|
|
7
|
+
|
|
8
|
+
expect(description).toContain('agent(prompt, agent?="fact-finder"');
|
|
9
|
+
expect(description).toContain("omit it to use `fact-finder`");
|
|
10
|
+
expect(description).toContain("Allowed agents: `fact-finder`, `oracle`.");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("omits agent() when spawning is disabled", () => {
|
|
14
|
+
const description = getEvalToolDescription({ py: true, js: false, spawns: "" });
|
|
15
|
+
|
|
16
|
+
expect(description).not.toContain("agent(prompt");
|
|
17
|
+
expect(description).not.toContain("<dag>");
|
|
18
|
+
});
|
|
19
|
+
});
|