@oh-my-pi/pi-coding-agent 16.2.11 → 16.2.13

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli.js +2982 -2825
  3. package/dist/types/cli/bench-cli.d.ts +0 -5
  4. package/dist/types/cli/dry-balance-cli.d.ts +0 -5
  5. package/dist/types/cli/models-cli.d.ts +1 -1
  6. package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
  7. package/dist/types/config/model-registry.d.ts +1 -23
  8. package/dist/types/config/model-resolver.d.ts +10 -15
  9. package/dist/types/config/models-config-schema.d.ts +12 -6
  10. package/dist/types/config/models-config.d.ts +9 -6
  11. package/dist/types/config/settings-schema.d.ts +31 -1
  12. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +15 -0
  14. package/dist/types/lsp/client.d.ts +9 -3
  15. package/dist/types/modes/controllers/tool-args-reveal.d.ts +0 -5
  16. package/dist/types/task/spawn-policy.d.ts +17 -0
  17. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  18. package/dist/types/task/types.d.ts +8 -2
  19. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  20. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  21. package/dist/types/tools/browser/registry.d.ts +2 -0
  22. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  24. package/dist/types/tools/eval.d.ts +3 -6
  25. package/dist/types/tools/write.d.ts +1 -1
  26. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  27. package/package.json +12 -12
  28. package/src/advisor/__tests__/advisor.test.ts +1 -1
  29. package/src/auto-thinking/classifier.ts +1 -1
  30. package/src/cli/bench-cli.ts +3 -12
  31. package/src/cli/dry-balance-cli.ts +1 -6
  32. package/src/cli/models-cli.ts +3 -81
  33. package/src/commands/models.ts +1 -2
  34. package/src/commit/model-selection.ts +4 -4
  35. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  36. package/src/config/model-discovery.ts +32 -6
  37. package/src/config/model-registry.ts +11 -225
  38. package/src/config/model-resolver.ts +23 -135
  39. package/src/config/models-config-schema.ts +13 -22
  40. package/src/config/prompt-templates.ts +20 -0
  41. package/src/config/settings-schema.ts +35 -1
  42. package/src/config/settings.ts +60 -24
  43. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  44. package/src/eval/agent-bridge.ts +7 -9
  45. package/src/eval/completion-bridge.ts +1 -1
  46. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  47. package/src/eval/py/runner.py +169 -18
  48. package/src/extensibility/extensions/model-api.ts +1 -3
  49. package/src/extensibility/extensions/runner.ts +62 -5
  50. package/src/lsp/client.ts +162 -45
  51. package/src/lsp/index.ts +31 -21
  52. package/src/main.ts +0 -1
  53. package/src/mcp/transports/http.ts +19 -17
  54. package/src/mcp/transports/stdio.test.ts +51 -1
  55. package/src/mcp/transports/stdio.ts +19 -9
  56. package/src/memories/index.ts +0 -1
  57. package/src/mnemopi/backend.ts +1 -1
  58. package/src/modes/components/model-selector.ts +32 -186
  59. package/src/modes/controllers/event-controller.ts +8 -39
  60. package/src/modes/controllers/selector-controller.ts +0 -1
  61. package/src/modes/controllers/tool-args-reveal.ts +0 -12
  62. package/src/prompts/system/subagent-system-prompt.md +2 -2
  63. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  64. package/src/prompts/tools/eval.md +2 -2
  65. package/src/prompts/tools/ssh.md +1 -0
  66. package/src/prompts/tools/task.md +1 -1
  67. package/src/sdk.ts +2 -9
  68. package/src/session/agent-session.ts +89 -8
  69. package/src/session/session-context.ts +2 -1
  70. package/src/session/session-manager.ts +2 -1
  71. package/src/session/unexpected-stop-classifier.ts +1 -1
  72. package/src/task/index.ts +23 -27
  73. package/src/task/spawn-policy.test.ts +63 -0
  74. package/src/task/spawn-policy.ts +58 -0
  75. package/src/task/types.ts +77 -6
  76. package/src/tools/__tests__/eval-description.test.ts +19 -0
  77. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  78. package/src/tools/browser/registry.ts +25 -0
  79. package/src/tools/browser/run-cancellation.ts +47 -0
  80. package/src/tools/browser/tab-supervisor.ts +120 -7
  81. package/src/tools/browser/tab-worker.ts +22 -10
  82. package/src/tools/browser.ts +1 -0
  83. package/src/tools/eval.ts +15 -10
  84. package/src/tools/inspect-image.ts +1 -1
  85. package/src/tools/ssh.ts +8 -1
  86. package/src/tools/write.ts +49 -9
  87. package/src/utils/commit-message-generator.ts +0 -1
  88. package/src/utils/image-vision-fallback.ts +2 -3
  89. 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, this.#modelRegistry);
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.unshift(current);
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.unshift(cursor);
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(), deps.registry);
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 spawningDisabled = parentSpawns === "";
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 (parentSpawns !== "*") {
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
- return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled() });
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
- // The schema defaults `agent` to `task` for model calls, but internal
570
- // callers and stale transcripts build params directly and bypass arktype.
571
- // Normalize once here so every downstream path sees the resolved agent.
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: DEFAULT_TASK_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 parentSpawns = this.session.getSessionSpawns() ?? "*";
1193
- const allowedSpawns = parentSpawns.split(",").map(s => s.trim());
1194
- const isSpawnAllowed = (): boolean => {
1195
- if (parentSpawns === "") return false; // Empty = deny all
1196
- if (parentSpawns === "*") return true; // Wildcard = allow all
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: [{ type: "text", text: `Cannot spawn '${agentName}'. Allowed: ${allowed}` }],
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
- export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema {
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
- return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation;
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
- return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
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; defaults to `"task"` (the general-purpose worker) when omitted. */
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
+ });