@marcoscale98/pi-extensible-workflows 5.19.1-fork.1 → 6.2.1-fork.1

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 (123) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +2 -2
  3. package/dist/src/agent-execution.d.ts +43 -10
  4. package/dist/src/agent-execution.js +365 -127
  5. package/dist/src/background-widget.d.ts +0 -1
  6. package/dist/src/background-widget.js +4 -9
  7. package/dist/src/decoders.d.ts +2 -3
  8. package/dist/src/decoders.js +33 -37
  9. package/dist/src/execution.d.ts +2 -0
  10. package/dist/src/execution.js +5 -5
  11. package/dist/src/host-delivery.js +4 -8
  12. package/dist/src/host-navigator.js +5 -2
  13. package/dist/src/host-phases.d.ts +1 -0
  14. package/dist/src/host-recovery.d.ts +6 -6
  15. package/dist/src/host-recovery.js +14 -22
  16. package/dist/src/host-runtime.js +56 -5
  17. package/dist/src/host-view.d.ts +1 -1
  18. package/dist/src/host-view.js +17 -15
  19. package/dist/src/host.js +62 -87
  20. package/dist/src/index.d.ts +1 -2
  21. package/dist/src/index.js +1523 -1630
  22. package/dist/src/index.js.map +3 -3
  23. package/dist/src/paths.d.ts +7 -6
  24. package/dist/src/paths.js +24 -15
  25. package/dist/src/persistence.d.ts +1 -0
  26. package/dist/src/persistence.js +1 -0
  27. package/dist/src/pi-runtime-runner.js +4 -1
  28. package/dist/src/registry.d.ts +9 -7
  29. package/dist/src/registry.js +28 -70
  30. package/dist/src/runtime/agent-runner.d.ts +2 -0
  31. package/dist/src/session-lease.d.ts +1 -1
  32. package/dist/src/session-lease.js +45 -11
  33. package/dist/src/settings.d.ts +6 -3
  34. package/dist/src/settings.js +36 -47
  35. package/dist/src/store.d.ts +3 -1
  36. package/dist/src/store.js +17 -1
  37. package/dist/src/trajectory.d.ts +0 -1
  38. package/dist/src/trajectory.js +2 -3
  39. package/dist/src/types.d.ts +100 -44
  40. package/dist/src/types.js +4 -7
  41. package/dist/src/utils.d.ts +3 -2
  42. package/dist/src/utils.js +20 -10
  43. package/dist/src/validation.d.ts +8 -2
  44. package/dist/src/validation.js +122 -86
  45. package/dist/starter/index.js +8 -9
  46. package/dist/starter/index.js.map +1 -1
  47. package/dist/starter/review-loop.js +8 -7
  48. package/dist/subagents/index.js +654 -814
  49. package/dist/subagents/index.js.map +3 -3
  50. package/dist/subagents/src/contracts.d.ts +4 -3
  51. package/dist/subagents/src/contracts.js +2 -2
  52. package/dist/subagents/src/index.d.ts +1 -1
  53. package/dist/subagents/src/index.js +1 -2
  54. package/dist/subagents/src/manager.js +49 -70
  55. package/dist/subagents/src/navigator.js +3 -14
  56. package/dist/subagents/src/view.d.ts +0 -1
  57. package/dist/subagents/src/view.js +2 -16
  58. package/dist/subagents/src/worktree.js +1 -1
  59. package/dist/trajectory/assets/index.html +8 -8
  60. package/dist/trajectory/index.js +487 -891
  61. package/dist/trajectory/index.js.map +3 -3
  62. package/dist/trajectory/src/assets/index.html +8 -8
  63. package/dist/trajectory/src/index.js +126 -86
  64. package/dist/trajectory/src/server.js +264 -25
  65. package/dist/trajectory/src/server.js.map +3 -3
  66. package/dist/trajectory/src/startup-mutex.d.ts +17 -0
  67. package/dist/trajectory/src/startup-mutex.js +172 -0
  68. package/examples/workflow-extension-template/README.md +4 -7
  69. package/examples/workflow-extension-template/extension.test.mjs +4 -8
  70. package/examples/workflow-extension-template/index.js +0 -3
  71. package/package.json +5 -5
  72. package/skills/pi-extensible-workflows/SKILL.md +10 -8
  73. package/src/agent-execution.ts +339 -127
  74. package/src/background-widget.ts +4 -10
  75. package/src/decoders.ts +31 -37
  76. package/src/execution.ts +5 -5
  77. package/src/host-delivery.ts +4 -8
  78. package/src/host-navigator.ts +4 -2
  79. package/src/host-phases.ts +1 -1
  80. package/src/host-recovery.ts +20 -26
  81. package/src/host-runtime.ts +44 -6
  82. package/src/host-view.ts +18 -11
  83. package/src/host.ts +62 -113
  84. package/src/index.ts +1 -2
  85. package/src/paths.ts +18 -12
  86. package/src/persistence.ts +1 -0
  87. package/src/pi-runtime-runner.ts +3 -1
  88. package/src/registry.ts +27 -63
  89. package/src/runtime/agent-runner.ts +2 -0
  90. package/src/session-lease.ts +34 -6
  91. package/src/settings.ts +35 -47
  92. package/src/store.ts +18 -2
  93. package/src/trajectory.ts +2 -4
  94. package/src/types.ts +71 -30
  95. package/src/utils.ts +18 -9
  96. package/src/validation.ts +102 -79
  97. package/starter/index.ts +0 -1
  98. package/starter/review-loop.ts +8 -7
  99. package/starter/roles/developer.md +0 -1
  100. package/starter/roles/oracle.md +0 -1
  101. package/starter/roles/researcher.md +0 -1
  102. package/starter/roles/reviewer.md +0 -1
  103. package/starter/roles/scout.md +0 -1
  104. package/subagents/README.md +8 -14
  105. package/subagents/src/contracts.ts +3 -4
  106. package/subagents/src/index.ts +2 -3
  107. package/subagents/src/manager.ts +45 -125
  108. package/subagents/src/navigator.ts +4 -13
  109. package/subagents/src/view.ts +4 -14
  110. package/subagents/src/worktree.ts +1 -1
  111. package/trajectory/src/assets/index.html +8 -8
  112. package/trajectory/src/index.ts +106 -82
  113. package/trajectory/src/server.ts +44 -8
  114. package/trajectory/src/startup-mutex.ts +127 -0
  115. package/dist/src/roles.d.ts +0 -70
  116. package/dist/src/roles.js +0 -326
  117. package/dist/starter/roles/developer.md +0 -6
  118. package/dist/starter/roles/oracle.md +0 -18
  119. package/dist/starter/roles/researcher.md +0 -20
  120. package/dist/starter/roles/reviewer.md +0 -19
  121. package/dist/starter/roles/scout.md +0 -18
  122. package/examples/workflow-extension-template/roles/reviewer.md +0 -4
  123. package/src/roles.ts +0 -327
@@ -1,11 +1,12 @@
1
- import { existsSync, realpathSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { copyFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Type } from "@earendil-works/pi-ai";
7
+ import { Value } from "typebox/value";
7
8
  import { Compile } from "typebox/compile";
8
- import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultPackageManager, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type InlineExtension } from "@earendil-works/pi-coding-agent";
9
+ import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultPackageManager, DefaultResourceLoader, defineTool, getAgentDir, ModelRuntime, SessionManager, SettingsManager, loadSkills, type InlineExtension } from "@earendil-works/pi-coding-agent";
9
10
  import type { ExtensionContext, ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
10
11
  type HerdrModelContext = { readonly model: ExtensionContext["model"]; readonly modelRegistry: ModelRegistry | undefined };
11
12
  type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
@@ -45,30 +46,30 @@ export interface PiResourceInspection {
45
46
  readonly diagnostics: readonly { type: "warning" | "error" | "collision"; message: string; source?: string }[];
46
47
  readonly systemPromptSource?: string;
47
48
  }
48
- import type { AgentAccounting, AgentActivity, AgentContinuity, AgentIdentity, AgentResourceInspection, AgentResourcePolicy, AgentResourceSelectors, AgentResourceSelectorSources, AgentSetup, AgentSetupSummary, AgentTransport, AgentTransportContext, ContextFileScope, JsonSchema, JsonValue, LiveSessionHandoff, ModelSpec, PiRuntimeLaunchInfo, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult, WorkflowExtensionSettings, WorkflowExtensionSettingsValidatorContext, WorkflowRunContext, WorkflowSessionStartEvent } from "./types.js";
49
- import { SerialLane, assertModelThinking, byPriorityThenName, deepFreeze, errorText, jsonObject, jsonValue, mergeWorkflowExtensionSettings, object, physicalModel, resolveModelReference, resourcePatternHasMagic, unmatchedResourcePatterns } from "./utils.js";
50
- import { SETTLED_AGENT_STATES, WorkflowError, isContextFileScope, zeroAccounting, type AgentDefinition, type AgentState } from "./types.js";
49
+ import type { AgentPreparation, AgentPreparationContext, PreparedAgentConfiguration, RegisteredAgentPreparationHook, AgentAccounting, AgentActivity, AgentContinuity, AgentIdentity, AgentResourceInspection, AgentResourcePolicy, AgentResourceSelectors, AgentResourceSelectorSources, AgentSetup, AgentSetupSummary, AgentTransport, AgentTransportContext, ContextFileScope, JsonSchema, JsonValue, LiveSessionHandoff, ModelSpec, PiRuntimeLaunchInfo, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult, WorkflowExtensionSettings, WorkflowExtensionSettingsValidatorContext, WorkflowRunContext, WorkflowSessionStartEvent } from "./types.js";
50
+ import { SerialLane, assertModelThinking, byPriorityThenName, deepFreeze, errorCode, errorText, jsonObject, jsonValue, object, physicalModel, resolveModelReference, resourcePatternHasMagic, selectResourcesByLayers, modelCapability, unmatchedResourcePatterns } from "./utils.js";
51
+ import { SETTLED_AGENT_STATES, WorkflowError, isContextFileScope, zeroAccounting, type AgentState, type WorkflowErrorCode } from "./types.js";
51
52
  import { createLiveSessionHandoff } from "./session-handoff.js";
52
53
  import { createToolTimingExtension } from "./tool-timing.js";
53
54
  import { isEmptyAbortedAssistant, normalizePiMessage, normalizePiSessionEvent, runtimeProgressToAgentProgress } from "./pi-runtime-adapter.js";
54
55
  import { createPiRuntimeAgentRunner, isRuntimeAgentProviderError, normalizePiRuntimeError } from "./pi-runtime-runner.js";
55
56
  import type { RuntimeAgentProgress, RuntimeUsage } from "./runtime/agent-runner.js";
56
57
  import { defaultWorkflowResultSchema } from "./runtime/workflow-result.js";
57
- import { validateAgentOptions, validateSchema } from "./validation.js";
58
- import { canonicalPath, extensionIdentity } from "./paths.js";
58
+ import { isCoreAgentOption, validateAgentOptions, validateSchema } from "./validation.js";
59
+ import { canonicalPath, extensionIdentity, piResourceContains, piResourcePath, sameFilesystemPath } from "./paths.js";
59
60
  import { isCorePackageName } from "./package-identity.js";
60
- import { canonicalExtensionSelector, resolveRole } from "./roles.js";
61
+ import { canonicalExtensionSelector, validateContextFileScopes, validateSelectorList, validateWorkflowExtensionSettings } from "./settings.js";
62
+ import { agentIdentityPath } from "./execution.js";
61
63
  import type { RunStore } from "./persistence.js";
62
- type AgentExecutionRunStore = Pick<RunStore, "recordSystemPrompt" | "validateWorktree" | "worktree" | "snapshotWorktree">;
64
+ type AgentExecutionRunStore = Pick<RunStore, "recordSystemPrompt" | "validateWorktree" | "worktree" | "snapshotWorktree"> & Partial<Pick<RunStore, "load" | "saveAgentConfiguration">>;
63
65
  const localToolTimingExtension = createToolTimingExtension();
64
- export type { AgentAccounting, AgentActivity, AgentInspectionMode, AgentSetup, AgentSetupContext, AgentSetupHook, AgentTransport, AgentTransportContext, PiRuntimeLaunchInfo, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult } from "./types.js";
66
+ export type { AgentPreparation, AgentPreparationContext, PreparedAgentConfiguration, RegisteredAgentPreparationHook, AgentAccounting, AgentActivity, AgentInspectionMode, AgentSetup, AgentSetupContext, AgentSetupHook, AgentTransport, AgentTransportContext, PiRuntimeLaunchInfo, PreparedAgentSession, RegisteredAgentSetupHook, SessionInput, WorkflowAgentMessage, WorkflowAgentSession, WorkflowAgentSessionEvent, WorkflowAgentSessionReference, WorkflowAgentSessionState, WorkflowAgentSessionStats, WorkflowAgentTurnResult } from "./types.js";
65
67
  export interface AgentBudgetHooks {
66
68
  beforeAttempt(): void;
67
69
  beforeTurn(): void;
68
70
  afterTurn(accounting: AgentAccounting, final: boolean): void;
69
71
  instruction(): string | undefined;
70
72
  }
71
- export type { AgentDefinition } from "./types.js";
72
73
  export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
73
74
  export type AgentProviderRecovery = "retry" | "abort" | { model: string };
74
75
  export interface AgentExecutionOptions {
@@ -87,7 +88,6 @@ export interface AgentExecutionOptions {
87
88
  skills?: readonly string[];
88
89
  extensions?: readonly string[];
89
90
  effectiveTools?: readonly string[];
90
- role?: string;
91
91
  contextFiles?: readonly ContextFileScope[];
92
92
  schema?: JsonSchema;
93
93
  retries?: number;
@@ -101,31 +101,46 @@ export interface AgentExecutionOptions {
101
101
  agentIdentity?: AgentIdentity;
102
102
  inheritedExtensionSettings?: Readonly<WorkflowExtensionSettings>;
103
103
  agentNodeId?: string;
104
+ capabilities?: Readonly<{ tools: readonly string[]; skills: readonly string[]; extensions: readonly string[] }>;
105
+ parentSkills?: readonly string[];
106
+ /** Tool names that ancestors excluded; with this agent's own `excludeTools`, they are removed from root and custom tools alike. */
107
+ inheritedExcludeTools?: readonly string[];
108
+ projectTrusted?: boolean;
109
+ configuration?: PreparedAgentConfiguration;
110
+ onConfiguration?: (configuration: PreparedAgentConfiguration) => void | Promise<void>;
104
111
  }
105
112
  export interface AgentExecutionRoot {
113
+ projectTrusted?: boolean;
106
114
  cwd: string;
107
115
  model: ModelSpec;
108
116
  tools: ReadonlySet<string>;
109
117
  extensionSettings?: Readonly<WorkflowExtensionSettings> | undefined;
110
118
  validateExtensionSettings?: (settings: Readonly<WorkflowExtensionSettings> | undefined, context: WorkflowExtensionSettingsValidatorContext) => void;
111
119
  onAgentSettings?: (agentNodeId: string, settings: Readonly<WorkflowExtensionSettings>) => void;
120
+ onAgentCapabilities?: (agentNodeId: string, capabilities: AgentPreparationContext["capabilities"], projectTrusted: boolean) => void;
112
121
  resourceSelectors?: AgentResourceSelectors;
113
- agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
114
122
  agentDir?: string;
115
123
  additionalSkillPaths?: readonly string[];
116
124
  availableModels?: ReadonlySet<string>;
117
125
  knownModels?: ReadonlySet<string>;
118
126
  modelAliases?: Readonly<Record<string, string>>;
127
+ dynamicModelAliasNames?: readonly string[];
119
128
  blockedAliases?: ReadonlySet<string>;
120
129
  blockedAliasTargets?: Readonly<Record<string, string>>;
121
130
  settingsPath?: string;
122
131
  extensionSettingsPath?: string;
123
132
  runStore?: AgentExecutionRunStore;
124
133
  providerPause?: () => Promise<void>;
134
+ agentPreparationHooks?: readonly RegisteredAgentPreparationHook[];
125
135
  agentSetupHooks?: readonly RegisteredAgentSetupHook[];
126
136
  agentResourcePolicy?: () => AgentResourcePolicy | Promise<AgentResourcePolicy>;
127
137
  runContext?: Readonly<WorkflowRunContext>;
128
138
  onResourceWarning?: (message: string) => void;
139
+ /**
140
+ * Load failures this host tolerated and the identities of the extensions it did load. With failures, a non-core option
141
+ * counts as owned only by the schema of a preparation hook whose `source` is a loaded extension; others fail closed.
142
+ */
143
+ extensionLoad?: Readonly<{ errors: readonly string[]; loaded: readonly string[] }>;
129
144
  }
130
145
  export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
131
146
  export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; state?: WorkflowAgentSessionState; activity?: AgentActivity; lastEventAt?: number; persist: boolean }
@@ -138,6 +153,46 @@ function parseModel(value: string | undefined, fallback: ModelSpec, aliases: Rea
138
153
  return resolveModelReference(value, aliases, knownModels, settingsPath);
139
154
  }
140
155
 
156
+ async function authorizedResources(root: AgentExecutionRoot, cwd: string, options: AgentExecutionOptions): Promise<{ tools: string[]; skills: string[]; extensions: string[] }> {
157
+ const agentDir = root.agentDir ?? getAgentDir();
158
+ const trusted = root.projectTrusted ?? false;
159
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
160
+ settingsManager.setProjectTrusted(trusted);
161
+ const resolved = await new DefaultPackageManager({ cwd, agentDir, settingsManager, builtinExtensions: ["codemode", "tool-search", "mcp"] }).resolve();
162
+ const extensions = resolved.extensions.filter(({ enabled, metadata }) => enabled && (trusted || metadata.scope !== "project")).map(({ path }) => extensionIdentity(path));
163
+ const skillPaths = resolved.skills.filter(({ enabled, metadata }) => enabled && (trusted || metadata.scope !== "project")).map(({ path }) => path);
164
+ const skills = loadSkills({ cwd, agentDir, skillPaths: [...skillPaths, ...(root.additionalSkillPaths ?? [])], includeDefaults: false }).skills.map(({ name }) => name);
165
+ const parent = options.capabilities;
166
+ return { tools: [...root.tools].filter((tool) => options.effectiveTools === undefined || options.effectiveTools.includes(tool)).filter((tool) => parent === undefined || parent.tools.includes(tool)), skills: [...new Set(skills)].filter((name) => parent === undefined || parent.skills.includes(name)), extensions: [...new Set(extensions)].filter((path) => parent === undefined || parent.extensions.includes(path)) };
167
+ }
168
+ function validatePreparedConfiguration(root: AgentExecutionRoot, configuration: PreparedAgentConfiguration, capabilities: { tools: readonly string[]; skills: readonly string[]; extensions: readonly string[] }, cwd: string, resumed: boolean): PreparedAgentConfiguration {
169
+ const code = resumed ? "RESUME_INCOMPATIBLE" : "INVALID_METADATA";
170
+ if (!jsonObject(configuration) || typeof configuration.projectTrusted !== "boolean" || typeof configuration.systemPromptAppend !== "string" || configuration.systemPrompt !== undefined && typeof configuration.systemPrompt !== "string") throw new WorkflowError(code, "Invalid prepared agent configuration");
171
+ if (configuration.projectTrusted && !root.projectTrusted) throw new WorkflowError("RESUME_INCOMPATIBLE", "Prepared agent configuration lost project trust");
172
+ if (!object(configuration.model) || typeof configuration.model.provider !== "string" || typeof configuration.model.model !== "string") throw new WorkflowError(code, "Invalid prepared model");
173
+ const model = resolveModelReference(`${configuration.model.provider}/${configuration.model.model}${configuration.model.thinking === undefined ? "" : `:${configuration.model.thinking}`}`);
174
+ if (root.availableModels !== undefined && !root.availableModels.has(modelCapability(model))) throw new WorkflowError(resumed ? "RESUME_INCOMPATIBLE" : "UNKNOWN_MODEL", `Required model is unavailable: ${modelCapability(model)}`);
175
+ const tools = validateSelectorList(configuration.tools, "configuration", "tools", "INVALID_METADATA", false);
176
+ if (tools === undefined) throw new WorkflowError(code, "Prepared tools are missing");
177
+ const missing = tools.find((tool) => !capabilities.tools.includes(tool));
178
+ if (missing) throw new WorkflowError(resumed ? "RESUME_INCOMPATIBLE" : "UNKNOWN_TOOL", `Required tool is unavailable: ${missing}`);
179
+ const excludeTools = configuration.excludeTools;
180
+ try { validateAgentOptions({ excludeTools }); } catch { throw new WorkflowError(code, "Invalid prepared excludeTools"); }
181
+ if (!Array.isArray(excludeTools) || tools.some((tool) => excludeTools.includes(tool))) throw new WorkflowError(code, "Prepared excludeTools contradict the prepared tools");
182
+ validateSelectorList(configuration.skills, "configuration", "skills", "INVALID_METADATA", false);
183
+ validateSelectorList(configuration.extensions, "configuration", "extensions", "INVALID_METADATA", false);
184
+ if (resumed) for (const key of ["skills", "extensions"] as const) {
185
+ const selectors = configuration[key];
186
+ const selected = selectors.slice(selectors.lastIndexOf("!*") + 1);
187
+ const missing = selected.find((name) => !capabilities[key].includes(name));
188
+ if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required ${key} capability is unavailable: ${missing}`);
189
+ }
190
+ validateContextFileScopes(configuration.contextFiles, "configuration");
191
+ validateWorkflowExtensionSettings(configuration.settings, "configuration", "INVALID_METADATA");
192
+ root.validateExtensionSettings?.(configuration.settings, { source: "effective", cwd, projectTrusted: root.projectTrusted ?? false });
193
+ return deepFreeze(structuredClone(configuration));
194
+ }
195
+
141
196
  function accounting(stats: WorkflowAgentSessionStats): AgentAccounting {
142
197
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
143
198
  }
@@ -311,6 +366,40 @@ function builtinExtensions(): InlineExtension[] {
311
366
  }
312
367
  const BUILTIN_EXTENSION_PREFIX = "builtin:";
313
368
  function isLlamaModel(model: ModelSpec): boolean { return model.provider === "llama.cpp"; }
369
+ /**
370
+ * Pi only extends resources with paths that loaded extensions return from resources_discover. Recording them, normalized as
371
+ * Pi does, lets selection tell those skills from the static inventory. Like Pi's own source attribution, the record belongs
372
+ * to one resource generation: reload() starts a new one, so withdrawn contributions stop counting as discovered.
373
+ */
374
+ class WorkflowResourceLoader extends DefaultResourceLoader {
375
+ readonly extensionSkillPaths: string[] = [];
376
+ constructor(private readonly loaderCwd: string, options: ConstructorParameters<typeof DefaultResourceLoader>[0]) { super(options); }
377
+ override async reload(...args: Parameters<DefaultResourceLoader["reload"]>): Promise<void> {
378
+ this.extensionSkillPaths.length = 0;
379
+ await super.reload(...args);
380
+ }
381
+ override extendResources(paths: Parameters<DefaultResourceLoader["extendResources"]>[0]): void {
382
+ this.extensionSkillPaths.push(...(paths.skillPaths ?? []).map(({ path }) => piResourcePath(path, this.loaderCwd)));
383
+ super.extendResources(paths);
384
+ }
385
+ }
386
+ /**
387
+ * Prepared selectors end with a frozen segment: `!*` followed by the static inventory. Extension-discovered skills keep every
388
+ * other selector, including negations setup hooks inserted anywhere (a terminal `!*` too), but not that frozen restriction.
389
+ * Setup can only insert negations, so the inventory names are matched from the right and the boundary is the nearest `!*`
390
+ * before them; any `!*` with only negations up to the inventory selects the same skills.
391
+ */
392
+ function discoveredSkillSelectors(selectors: readonly string[] | undefined, inventory: readonly string[] | undefined): readonly string[] | undefined {
393
+ if (selectors === undefined || inventory === undefined) return selectors;
394
+ const frozen = new Set<number>();
395
+ let index = selectors.length;
396
+ for (const name of [...inventory].reverse().concat("!*")) {
397
+ do index -= 1; while (index >= 0 && selectors[index] !== name);
398
+ if (index < 0) return selectors;
399
+ frozen.add(index);
400
+ }
401
+ return selectors.filter((_selector, position) => !frozen.has(position));
402
+ }
314
403
 
315
404
  async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent?: WorkflowSessionStartEvent): Promise<LocalPiSessionHandle> {
316
405
  const agentDir = input.agentDir ?? getAgentDir();
@@ -335,25 +424,23 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
335
424
  const resolved = await packageManager.resolve();
336
425
  const discoveredExtensions = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => extensionIdentity(path)))];
337
426
  const selectorSources = policy.selectorSources;
338
- const selection = resolveRole(undefined, {
339
- cwd: input.cwd,
340
- selectorSources,
341
- resources: { extensions: discoveredExtensions },
342
- });
343
- const selectedExtensions = selection.selectedExtensions ?? [];
427
+ const selectedExtensions = selectResourcesByLayers(resourceSelectorLayers(selectorSources, "extensions"), discoveredExtensions).filter((path) => policy.capabilities === undefined || policy.capabilities.extensions.includes(path));
344
428
  const extensionPaths = selectedExtensions.filter((path) => path.startsWith(BUILTIN_EXTENSION_PREFIX) || !isWorkflowHostEntry(path));
345
429
  policy.selectedExtensions = selectedExtensions;
346
- policy.unmatchedExtensions = selection.unmatchedExtensions ?? [];
430
+ policy.unmatchedExtensions = unmatchedResourcePatterns(policy.effective.extensions, discoveredExtensions);
347
431
  const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
348
- const updateSkillMatches = (skills: readonly { name: string }[]): Set<string> => {
432
+ const updateSkillMatches = (skills: readonly { name: string; filePath: string }[], extensionSkillPaths: readonly string[]): Set<string> => {
349
433
  const names = [...new Set(skills.map(({ name }) => name))];
350
- const selected = resolveRole(undefined, { cwd: input.cwd, selectorSources, resources: { skills: names } });
351
- const selectedSkills = selected.selectedSkills ?? [];
434
+ const discovered = new Set(skills.filter(({ filePath }) => extensionSkillPaths.some((path) => piResourceContains(path, filePath))).map(({ name }) => name));
435
+ const [global, project, call] = resourceSelectorLayers(selectorSources, "skills");
436
+ const selectedStatic = selectResourcesByLayers([global, project, call], names.filter((name) => !discovered.has(name))).filter((name) => policy.capabilities === undefined || policy.capabilities.skills.includes(name));
437
+ const selectedDiscovered = selectResourcesByLayers([discoveredSkillSelectors(global, policy.capabilities?.skills), project, call], [...discovered]).filter((name) => policy.parentSkills === undefined || policy.parentSkills.includes(name));
438
+ const selectedSkills = [...selectedStatic, ...selectedDiscovered];
352
439
  policy.selectedSkills = selectedSkills;
353
- policy.unmatchedSkills = selected.unmatchedSkills ?? [];
440
+ policy.unmatchedSkills = unmatchedResourcePatterns(policy.effective.skills, names);
354
441
  return new Set(names.filter((name) => !selectedSkills.includes(name)));
355
442
  };
356
- resourceLoader = new DefaultResourceLoader({
443
+ const workflowLoader: WorkflowResourceLoader = new WorkflowResourceLoader(input.cwd, {
357
444
  cwd: input.cwd,
358
445
  agentDir,
359
446
  settingsManager,
@@ -364,12 +451,13 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
364
451
  extensionFactories,
365
452
  ...(contextFilesOverride ? { agentsFilesOverride: contextFilesOverride } : {}),
366
453
  skillsOverride: (base) => {
367
- const disabledSkills = updateSkillMatches(base.skills);
454
+ const disabledSkills = updateSkillMatches(base.skills, workflowLoader.extensionSkillPaths);
368
455
  return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
369
456
  },
370
457
  ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
371
458
  ...systemPromptOptions,
372
459
  });
460
+ resourceLoader = workflowLoader;
373
461
  await resourceLoader.reload();
374
462
  } else {
375
463
  settingsManager = SettingsManager.create(input.cwd, agentDir, { projectTrusted: true });
@@ -391,7 +479,7 @@ async function createLocalPiSessionHandle(input: SessionInput, sessionStartEvent
391
479
  throw new WorkflowError("UNKNOWN_MODEL", `Unknown model: ${input.model.provider}/${input.model.model}${because}`);
392
480
  }
393
481
  const effectiveSessionStartEvent = input.settings === undefined && sessionStartEvent === undefined ? undefined : { ...(sessionStartEvent ?? { type: "session_start" as const, reason: "startup" as const }), settings: input.settings ?? Object.freeze({}) };
394
- const { session } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, settingsManager, ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), resourceLoader, ...(effectiveSessionStartEvent ? { sessionStartEvent: effectiveSessionStartEvent } : {}), sessionManager: manager });
482
+ const { session } = await createAgentSession({ cwd: input.cwd, agentDir, modelRuntime, model, settingsManager, ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), resourceLoader, ...(effectiveSessionStartEvent ? { sessionStartEvent: effectiveSessionStartEvent } : {}), sessionManager: manager });
395
483
  const nativeDispose = session.dispose.bind(session);
396
484
  let disposal: Promise<void> | undefined;
397
485
  const shutdown = (reason: LocalSessionShutdownReason, targetSessionFile?: string): Promise<void> => {
@@ -671,6 +759,11 @@ export async function createLocalWorkflowAgentSession(prepared: Readonly<Prepare
671
759
  return session;
672
760
  }
673
761
  export const localAgentTransport: AgentTransport = Object.freeze({ id: "local", createSession: createLocalWorkflowAgentSession });
762
+ /** Like Pi's excludeTools, exclusions name tools exactly and apply to every tool the session would get, custom tools included. */
763
+ function excludedToolNames(options: Pick<AgentExecutionOptions, "agentOptions" | "inheritedExcludeTools">): Set<string> {
764
+ const own = options.agentOptions?.excludeTools;
765
+ return new Set([...(options.inheritedExcludeTools ?? []), ...(Array.isArray(own) ? own.filter((tool): tool is string => typeof tool === "string") : [])]);
766
+ }
674
767
  function changedOption(options: Readonly<Record<string, JsonValue>>, baseline: Readonly<Record<string, JsonValue>>, key: string): boolean { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
675
768
  interface ChildAgentToolParams {
676
769
  prompt: string;
@@ -679,7 +772,6 @@ interface ChildAgentToolParams {
679
772
  skills?: string[];
680
773
  extensions?: string[];
681
774
  model?: string;
682
- role?: string;
683
775
  contextFiles?: ContextFileScope[];
684
776
  outputSchema?: unknown;
685
777
  retries?: number;
@@ -692,7 +784,6 @@ function isChildAgentToolParams(value: unknown): value is ChildAgentToolParams &
692
784
  if (value.skills !== undefined && (!Array.isArray(value.skills) || value.skills.some((skill) => typeof skill !== "string"))) return false;
693
785
  if (value.extensions !== undefined && (!Array.isArray(value.extensions) || value.extensions.some((extension) => typeof extension !== "string"))) return false;
694
786
  if (value.model !== undefined && typeof value.model !== "string") return false;
695
- if (value.role !== undefined && (typeof value.role !== "string" || !value.role.trim())) return false;
696
787
  if (value.contextFiles !== undefined && (!Array.isArray(value.contextFiles) || !value.contextFiles.every(isContextFileScope))) return false;
697
788
  if (value.outputSchema !== undefined && !jsonObject(value.outputSchema)) return false;
698
789
  if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0)) return false;
@@ -715,7 +806,7 @@ function resourcePolicySummary(policy: AgentResourcePolicy, tools: readonly stri
715
806
  const RESOURCE_SELECTOR_KEYS = ["skills", "extensions", "tools"] as const;
716
807
  type ResourceSelectorKey = (typeof RESOURCE_SELECTOR_KEYS)[number];
717
808
  function resourceSelectorLayers(sources: AgentResourceSelectorSources, key: ResourceSelectorKey): readonly (readonly string[] | undefined)[] {
718
- return [sources.global[key], sources.project[key], sources.role?.[key], sources.call?.[key]];
809
+ return [sources.global[key], sources.project[key], sources.call?.[key]];
719
810
  }
720
811
  function selectorListWidened(ceiling: readonly string[], candidate: readonly string[]): boolean {
721
812
  let candidateIndex = 0;
@@ -772,7 +863,7 @@ function appendResourcePolicyNarrowing(ceiling: AgentResourcePolicy | undefined,
772
863
  };
773
864
  }
774
865
  function packageRoot(start: string): string | undefined {
775
- let current = dirname(realpathSync(start));
866
+ let current = dirname(canonicalPath(start));
776
867
  for (;;) {
777
868
  const candidates = [current, join(current, "..", "@earendil-works", "pi-coding-agent"), join(current, "..", "pi-coding-agent")];
778
869
  for (const candidate of candidates) {
@@ -819,10 +910,9 @@ async function resolvePiRuntime(): Promise<PiRuntimeResolution> {
819
910
  }
820
911
  }
821
912
  const piRuntimeResolution = resolvePiRuntime();
822
- function workflowAgentPrompt(task: string, options: AgentExecutionOptions, attempt: number, previousError?: string): string {
823
- const basePrompt = [`Workflow: ${options.workflowName}`, `Agent: ${options.label}`, options.phase ? `Phase: ${options.phase}` : "", options.parent ? `Parent: ${options.parent}` : "", "You own this task and any direct child agents you create. Return child results to your parent; do not leave descendants running.", options.schema ? "Call workflow_result exactly once with the final result matching its schema. Do not return prose as the final result." : "Call workflow_result exactly once with { result: \"final response\" } to submit the final response. Do not return prose as the final result.", attempt > 1 ? `Retry attempt ${String(attempt)}. Previous state: ${options.retryState ?? previousError ?? "failed attempt"}` : ""].filter(Boolean).join("\n");
824
- const instruction = options.budget?.instruction();
825
- return `${basePrompt}\n\nTask:\n${task}${instruction ? `\n\n${instruction}` : ""}`;
913
+ /** Transient per-attempt control text. It is queued as a separate steered message after the task, so the system prompt stays stable and the task message stays exact. */
914
+ function attemptControlText(options: AgentExecutionOptions, attempt: number, previousError?: string): string {
915
+ return [attempt > 1 ? `Retry attempt ${String(attempt)}. Previous state: ${options.retryState ?? previousError ?? "failed attempt"}` : "", options.budget?.instruction() ?? ""].filter(Boolean).join("\n");
826
916
  }
827
917
  async function preparedAgentSession(input: SessionInput, initialPrompt?: string): Promise<Readonly<PreparedAgentSession>> {
828
918
  const { runtime: piRuntime, error: piRuntimeError } = await piRuntimeResolution;
@@ -846,45 +936,26 @@ async function attemptSessionInput(source: string, attempt: number): Promise<str
846
936
  await copyFile(source, target);
847
937
  return target;
848
938
  }
849
- async function prepareAgentSetup(root: AgentExecutionRoot, transport: AgentTransport, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string; contextFiles?: readonly ContextFileScope[] }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, inspection = false, previousError?: string): Promise<PreparedAgentSetup> {
939
+ async function prepareAgentSetup(root: AgentExecutionRoot, transport: AgentTransport, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string; contextFiles?: readonly ContextFileScope[] }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined, inspection = false): Promise<PreparedAgentSetup> {
850
940
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
851
941
  const baselineOptions = structuredClone(options.agentOptions ?? {});
852
942
  const baseResourcePolicy = await root.agentResourcePolicy?.();
853
- const roleName = typeof options.role === "string" ? options.role : undefined;
854
- const roleDefinition = roleName ? root.agentDefinitions?.[roleName] : undefined;
855
- const roleSelectors: AgentResourceSelectors | undefined = roleDefinition ? {
856
- ...(roleDefinition.skills === undefined ? {} : { skills: roleDefinition.skills }),
857
- ...(roleDefinition.extensions === undefined ? {} : { extensions: roleDefinition.extensions.map((selector) => canonicalExtensionSelector(selector, cwd)) }),
858
- ...(roleDefinition.tools === undefined ? {} : { tools: roleDefinition.tools }),
859
- } : undefined;
860
- const selectorValue = (key: "skills" | "extensions" | "tools", fallback: readonly string[] | undefined): readonly string[] | undefined => {
861
- const value = baselineOptions[key];
862
- if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) return value;
863
- return options.agentOptions === undefined ? fallback : undefined;
943
+ const configuration = options.configuration;
944
+ const resourcePolicy: AgentResourcePolicy = {
945
+ ...(baseResourcePolicy ?? { globalSettingsPath: "", projectSettingsPath: "", projectTrusted: root.projectTrusted ?? false, global: { skills: [], extensions: [] }, project: { skills: [], extensions: [] }, unmatchedSkills: [], unmatchedExtensions: [] }),
946
+ projectTrusted: configuration?.projectTrusted ?? root.projectTrusted ?? baseResourcePolicy?.projectTrusted ?? false,
947
+ effective: { skills: configuration?.skills ?? [], extensions: configuration?.extensions ?? [], tools: resolved.tools },
948
+ selectorSources: { global: { skills: configuration?.skills ?? [], extensions: configuration?.extensions ?? [], tools: resolved.tools }, project: {} },
949
+ ...(options.capabilities ? { capabilities: options.capabilities } : {}),
950
+ ...(options.parentSkills ? { parentSkills: [...options.parentSkills] } : {}),
951
+ selectedTools: resolved.tools,
864
952
  };
865
- const rawCallSkills = selectorValue("skills", options.skills);
866
- const rawCallExtensions = selectorValue("extensions", options.extensions)?.map((selector) => canonicalExtensionSelector(selector, cwd));
867
- const rawCallTools = selectorValue("tools", options.tools);
868
- const callSelectors: AgentResourceSelectors = { ...(rawCallSkills === undefined ? {} : { skills: rawCallSkills }), ...(rawCallExtensions === undefined ? {} : { extensions: rawCallExtensions }), ...(rawCallTools === undefined ? {} : { tools: rawCallTools }) };
869
- const resourcePolicy: AgentResourcePolicy | undefined = baseResourcePolicy ? {
870
- ...baseResourcePolicy,
871
- effective: {
872
- skills: [...baseResourcePolicy.effective.skills, ...(roleSelectors?.skills ?? []), ...(rawCallSkills ?? [])],
873
- extensions: [...baseResourcePolicy.effective.extensions, ...(roleSelectors?.extensions ?? []), ...(rawCallExtensions ?? [])],
874
- ...(baseResourcePolicy.effective.tools === undefined && roleSelectors?.tools === undefined && rawCallTools === undefined ? {} : { tools: [...(baseResourcePolicy.effective.tools ?? []), ...(roleSelectors?.tools ?? []), ...(rawCallTools ?? [])] }),
875
- },
876
- selectorSources: { global: baseResourcePolicy.selectorSources.global, project: baseResourcePolicy.selectorSources.project, ...(roleSelectors ? { role: roleSelectors } : {}), ...(Object.keys(callSelectors).length ? { call: callSelectors } : {}) },
877
- } : undefined;
878
- if (resourcePolicy) {
879
- resourcePolicy.selectedTools = resolved.tools;
880
- resourcePolicy.unmatchedTools = unmatchedResourcePatterns(resourceSelectorLayers(resourcePolicy.selectorSources, "tools").flatMap((layer) => layer ?? []), [...root.tools]);
881
- }
882
- const resourcePolicyCeiling = resourcePolicy ? structuredClone(resourcePolicy) : undefined;
953
+ const resourcePolicyCeiling = structuredClone(resourcePolicy);
883
954
  const sessionPath = options.sessionPath === undefined || inspection ? options.sessionPath : await attemptSessionInput(options.sessionPath, attempt);
884
- const extensionSettings = mergeWorkflowExtensionSettings(options.inheritedExtensionSettings ?? root.extensionSettings, roleDefinition?.extensionSettings) ?? Object.freeze({});
885
- root.validateExtensionSettings?.(extensionSettings, { source: roleName === undefined ? "effective" : "role", cwd, projectTrusted: resourcePolicy?.projectTrusted ?? true, ...((root.extensionSettingsPath ?? root.settingsPath) ? { settingsPath: root.extensionSettingsPath ?? root.settingsPath } : {}), ...(roleName === undefined ? {} : { role: roleName }) });
955
+ const extensionSettings = configuration?.settings ?? options.inheritedExtensionSettings ?? root.extensionSettings ?? Object.freeze({});
956
+ root.validateExtensionSettings?.(extensionSettings, { source: "effective", cwd, projectTrusted: resourcePolicy.projectTrusted, ...((root.extensionSettingsPath ?? root.settingsPath) ? { settingsPath: root.extensionSettingsPath ?? root.settingsPath } : {}) });
886
957
  if (options.agentNodeId !== undefined) root.onAgentSettings?.(options.agentNodeId, extensionSettings);
887
- const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(sessionPath ? { sessionPath } : {}), ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(root.additionalSkillPaths?.length ? { additionalSkillPaths: [...root.additionalSkillPaths] } : {}), ...(resolved.contextFiles === undefined ? {} : { contextFiles: [...resolved.contextFiles] }), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPrompt !== undefined ? { systemPrompt: resolved.systemPrompt } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), settings: extensionSettings, options: structuredClone(baselineOptions) };
958
+ const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(sessionPath ? { sessionPath } : {}), ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(root.additionalSkillPaths?.length ? { additionalSkillPaths: [...root.additionalSkillPaths] } : {}), ...(resolved.contextFiles === undefined ? {} : { contextFiles: [...resolved.contextFiles] }), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPrompt !== undefined ? { systemPrompt: resolved.systemPrompt } : {}), systemPromptAppend: resolved.systemPromptAppend, resourcePolicy, settings: extensionSettings, options: structuredClone(baselineOptions) };
888
959
  const setup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, prepared: await preparedAgentSession(sessionInput, task), transport };
889
960
  const base = fallbackSetupContext(root, options, setupSignal);
890
961
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal, settings: extensionSettings, ...(base.tuiIndex === undefined ? {} : { tuiIndex: base.tuiIndex }), ...(base.tuiLabel === undefined ? {} : { tuiLabel: base.tuiLabel }), ...(inspection ? { mode: "inspection" as const } : {}) });
@@ -902,25 +973,55 @@ async function prepareAgentSetup(root: AgentExecutionRoot, transport: AgentTrans
902
973
  try {
903
974
  if (resourcePolicyWidened(resourcePolicyCeiling, setup.sessionInput.resourcePolicy)) throw new WorkflowError("INVALID_METADATA", "Agent setup widened the prepared resource policy");
904
975
  appendResourcePolicyNarrowing(resourcePolicyCeiling, setup.sessionInput.resourcePolicy);
976
+ if (setup.sessionInput.resourcePolicy) {
977
+ const policy = setup.sessionInput.resourcePolicy;
978
+ policy.effective.extensions = policy.effective.extensions.map((selector) => canonicalExtensionSelector(selector, cwd));
979
+ for (const layer of [policy.selectorSources.global, policy.selectorSources.project, policy.selectorSources.call]) if (layer?.extensions !== undefined) layer.extensions = layer.extensions.map((selector) => canonicalExtensionSelector(selector, cwd));
980
+ if (resourcePolicyCeiling.parentSkills === undefined) Reflect.deleteProperty(policy, "parentSkills");
981
+ else policy.parentSkills = [...resourcePolicyCeiling.parentSkills];
982
+ }
905
983
  setup.sessionInput.options = setup.options;
906
984
  if (changedOption(setup.options, baselineOptions, "model") && typeof setup.options.model === "string") setup.sessionInput.model = parseModel(setup.options.model, setup.sessionInput.model, root.modelAliases, root.knownModels ?? root.availableModels, root.settingsPath);
907
985
  if (changedOption(setup.options, baselineOptions, "tools") && Array.isArray(setup.options.tools) && setup.options.tools.every((tool) => typeof tool === "string")) setup.sessionInput.tools = [...setup.options.tools];
908
- if (changedOption(setup.options, baselineOptions, "cwd") && typeof setup.options.cwd === "string") setup.sessionInput.cwd = setup.options.cwd;
986
+ // Pi derives trust, settings and .pi resources from cwd, so neither the session input nor a cwd option may move it.
987
+ if (changedOption(setup.options, baselineOptions, "cwd") || !sameFilesystemPath(cwd, setup.sessionInput.cwd)) throw new WorkflowError("INVALID_METADATA", "Agent setup cannot change cwd");
988
+ if (!root.availableModels?.has(modelCapability(setup.sessionInput.model)) && root.availableModels !== undefined) throw new WorkflowError("UNKNOWN_MODEL", "Agent setup selected an unavailable model");
989
+ // A custom tool must not reintroduce, by name, a root tool the prepared policy left out (excludeTools, parent ceilings, selectors).
990
+ const shadowed = (setup.sessionInput.customTools ?? []).map(({ name }) => name).find((name) => root.tools.has(name) && !resolved.tools.includes(name));
991
+ if (shadowed) throw new WorkflowError("UNKNOWN_TOOL", `Custom tool reintroduces a tool outside the prepared agent policy: ${shadowed}`);
992
+ // Like Pi's excludeTools, own and inherited exclusions also remove custom tools of those names, including ones setup hooks
993
+ // add, before materialization. A name that matches no root or custom tool excludes nothing and only warns. The names are
994
+ // the frozen configuration's, so re-evaluated options of a later turn, retry or resume cannot change them.
995
+ const excludedTools = new Set(configuration?.excludeTools ?? excludedToolNames(options));
996
+ const excludedCustomTools = (setup.sessionInput.customTools ?? []).filter(({ name }) => excludedTools.has(name)).map(({ name }) => name);
997
+ if (excludedCustomTools.length) {
998
+ setup.sessionInput.customTools = setup.sessionInput.customTools?.filter(({ name }) => !excludedTools.has(name)) ?? [];
999
+ // Hooks may also list their custom tool by name; root names stay subject to the policy checks below.
1000
+ setup.sessionInput.tools = setup.sessionInput.tools.filter((tool) => root.tools.has(tool) || !excludedCustomTools.includes(tool));
1001
+ }
1002
+ for (const tool of excludedTools) if (!options.inheritedExcludeTools?.includes(tool) && !root.tools.has(tool) && !excludedCustomTools.includes(tool)) root.onResourceWarning?.(`excludeTools names no root or custom tool of this agent: ${tool}`);
909
1003
  const customToolNames = new Set([...(setup.sessionInput.customTools ?? []).map(({ name }) => name), ...(setup.sessionInput.resultTool ? [setup.sessionInput.resultTool.name] : [])]);
910
1004
  const widened = setup.sessionInput.tools.find((tool) => !resolved.tools.includes(tool) && !customToolNames.has(tool));
911
1005
  const outsideTool = widened ?? setup.sessionInput.tools.find((tool) => !root.tools.has(tool) && !customToolNames.has(tool));
912
1006
  if (outsideTool) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${outsideTool}`);
1007
+ if (setup.sessionInput.resourcePolicy) {
1008
+ const policy = setup.sessionInput.resourcePolicy;
1009
+ setup.sessionInput.tools = selectResourcesByLayers(resourceSelectorLayers(policy.selectorSources, "tools"), setup.sessionInput.tools);
1010
+ if (policy.projectTrusted !== resourcePolicyCeiling.projectTrusted) policy.capabilities = await authorizedResources({ ...root, projectTrusted: policy.projectTrusted }, cwd, options);
1011
+ }
1012
+ if (!setup.sessionInput.tools.includes("agent")) setup.sessionInput.customTools = setup.sessionInput.customTools?.filter(({ name }) => name !== "agent" && name !== "get_subagent_result" && name !== "steer_subagent") ?? [];
1013
+
913
1014
  } catch (error) {
914
1015
  setup.prepared = await preparedAgentSession(setup.sessionInput, task);
915
1016
  return { setup, summary: agentSetupSummary(setup, hookNames), failure: { error } };
916
1017
  }
917
- setup.prepared = await preparedAgentSession(setup.sessionInput, inspection ? task : workflowAgentPrompt(setup.prompt, options, attempt, previousError));
1018
+ setup.prepared = await preparedAgentSession(setup.sessionInput, inspection ? task : setup.prompt);
918
1019
  return { setup, summary: agentSetupSummary(setup, hookNames) };
919
1020
  }
920
1021
  export async function prepareAgentSetupForInspection(root: AgentExecutionRoot, task: string, options: AgentExecutionOptions, transport: AgentTransport): Promise<PreparedAgentSetup> {
921
1022
  const executor = new WorkflowAgentExecutor(root);
922
- const resolved = executor.resolve(options);
923
- return prepareAgentSetup(root, transport, task, options, resolved, root.cwd, 1, root.runContext?.signal, [], undefined, true);
1023
+ const configuration = await executor.prepare(options, root.cwd, root.runContext?.signal, "inspection");
1024
+ return prepareAgentSetup(root, transport, task, { ...options, label: configuration.label ?? options.label, configuration, capabilities: { tools: configuration.tools, skills: configuration.skills.slice(configuration.skills.lastIndexOf("!*") + 1), extensions: configuration.extensions.slice(configuration.extensions.lastIndexOf("!*") + 1) } }, configuration, root.cwd, 1, root.runContext?.signal, [], undefined, true);
924
1025
  }
925
1026
  function attemptRecord(transport: string, attempt: number, session: WorkflowAgentSession, setup: AgentSetupSummary, stats: AgentAccounting, result?: JsonValue, error?: { code: string; message: string }): AgentAttempt {
926
1027
  return { attempt, transport, session: session.reference, ...(result === undefined ? {} : { result }), ...(error ? { error } : {}), accounting: stats, setup };
@@ -937,52 +1038,89 @@ export function getAgentAttempts(error: unknown): readonly AgentAttempt[] | unde
937
1038
  }
938
1039
  export class WorkflowAgentExecutor {
939
1040
  private readonly transport: AgentTransport;
940
- private readonly warnedRoleTools = new Set<string>();
941
1041
  constructor(private readonly root: AgentExecutionRoot, transport: AgentTransport = localAgentTransport) { this.transport = transport; }
942
1042
  setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
943
1043
 
944
1044
  resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string; contextFiles?: readonly ContextFileScope[] } {
945
- const roleName = typeof options.role === "string" ? options.role : undefined;
946
- const roleDefinition = roleName ? this.root.agentDefinitions?.[roleName] : undefined;
947
- if (roleName && !roleDefinition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${roleName}`);
948
- if (inheritedTools === undefined) for (const pattern of roleDefinition?.tools ?? []) {
949
- const body = pattern.startsWith("!") ? pattern.slice(1) : pattern;
950
- if (!pattern.startsWith("!") && !resourcePatternHasMagic(pattern) && !this.root.tools.has(body)) {
951
- const key = `${roleName ?? "agent"}:${body}`;
952
- if (!this.warnedRoleTools.has(key)) { this.warnedRoleTools.add(key); this.root.onResourceWarning?.(`Tool not available in this session for role ${roleName ?? "agent"}: ${body}. The agent runs without it.`); }
953
- }
1045
+ if (options.configuration) return { ...options.configuration, ...(options.modelOverride ? { model: options.modelOverride } : {}) };
1046
+ const ceiling = inheritedTools ?? [...this.root.tools];
1047
+ for (const pattern of options.tools ?? []) if (!pattern.startsWith("!") && !resourcePatternHasMagic(pattern) && !ceiling.includes(pattern)) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${pattern}`);
1048
+ const tools = options.effectiveTools ?? selectResourcesByLayers([this.root.resourceSelectors?.tools, options.tools], [...(inheritedTools ?? this.root.tools)]);
1049
+ const missing = tools.find((tool) => !ceiling.includes(tool));
1050
+ if (missing) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${missing}`);
1051
+ const model = options.modelOverride ?? parseModel(options.model, this.root.model, this.root.modelAliases, this.root.knownModels, this.root.settingsPath);
1052
+ return { model: physicalModel(model, this.root.modelAliases, this.root.knownModels, this.root.settingsPath), tools, systemPromptAppend: "", ...(options.contextFiles === undefined ? {} : { contextFiles: options.contextFiles }) };
1053
+ }
1054
+
1055
+ async prepare(options: AgentExecutionOptions, cwd = this.root.cwd, signal = this.root.runContext?.signal ?? new AbortController().signal, mode: "execution" | "inspection" = "execution"): Promise<PreparedAgentConfiguration> {
1056
+ const root = { ...this.root, projectTrusted: Boolean(this.root.projectTrusted) && options.projectTrusted !== false };
1057
+ const policy = await this.root.agentResourcePolicy?.();
1058
+ const capabilities = await authorizedResources(root, cwd, options);
1059
+ // Children are new spawns of their parent's attempt; their scheduler node keys the configuration their own retries reuse.
1060
+ const persistedKey = options.parent !== undefined ? options.agentNodeId : options.agentIdentity?.handle !== undefined ? `handle:${options.agentIdentity.handle}` : options.agentIdentity ? agentIdentityPath(options.agentIdentity) : options.agentNodeId;
1061
+ const stored = options.configuration ?? (mode === "execution" && persistedKey && this.root.runStore?.load ? (await this.root.runStore.load()).snapshot.agentConfigurations[persistedKey] : undefined);
1062
+ if (stored) {
1063
+ // Without the field, the configuration was prepared before exclusions were frozen. Re-evaluated options cannot prove
1064
+ // which exclusions it had, so it never resumes.
1065
+ if (stored.excludeTools === undefined) throw new WorkflowError("RESUME_INCOMPATIBLE", "Prepared agent configuration predates frozen excludeTools; relaunch the workflow");
1066
+ return validatePreparedConfiguration(root, stored, capabilities, cwd, true);
954
1067
  }
955
- const resolved = resolveRole(roleName, {
956
- cwd: this.root.cwd,
957
- ...(roleDefinition === undefined ? {} : { definition: roleDefinition }),
958
- ...(this.root.agentDefinitions === undefined ? {} : { definitions: this.root.agentDefinitions }),
959
- ...(this.root.agentDir === undefined ? {} : { agentDir: this.root.agentDir }),
960
- selectorSources: { global: this.root.resourceSelectors ?? {}, project: {} },
961
- ...(this.root.modelAliases === undefined ? {} : { modelAliases: this.root.modelAliases }),
962
- ...(this.root.knownModels === undefined ? {} : { knownModels: this.root.knownModels }),
963
- ...(this.root.availableModels === undefined ? {} : { availableModels: this.root.availableModels }),
964
- ...(this.root.blockedAliases === undefined ? {} : { blockedAliases: this.root.blockedAliases }),
965
- ...(this.root.blockedAliasTargets === undefined ? {} : { blockedAliasTargets: this.root.blockedAliasTargets }),
966
- ...(this.root.settingsPath === undefined ? {} : { settingsPath: this.root.settingsPath }),
967
- rootModel: this.root.model,
968
- rootTools: this.root.tools,
969
- ...(inheritedTools === undefined ? {} : { inheritedTools }),
970
- ...(options.model === undefined ? {} : { model: options.model }),
971
- ...(options.modelOverride === undefined ? {} : { modelOverride: options.modelOverride }),
972
- ...(options.tools === undefined ? {} : { tools: options.tools }),
973
- ...(options.skills === undefined ? {} : { skills: options.skills }),
974
- ...(options.extensions === undefined ? {} : { extensions: options.extensions }),
975
- ...(options.contextFiles === undefined ? {} : { contextFiles: options.contextFiles }),
976
- ...(options.effectiveTools === undefined ? {} : { effectiveTools: options.effectiveTools }),
977
- });
978
- return { model: physicalModel(resolved.model ?? this.root.model, this.root.modelAliases, this.root.knownModels, this.root.settingsPath), ...(resolved.requestedModel === undefined ? {} : { requestedModel: resolved.requestedModel }), tools: resolved.tools ?? [], ...(resolved.overrideSystemPrompt ? { systemPrompt: resolved.prompt } : {}), systemPromptAppend: resolved.overrideSystemPrompt ? "" : resolved.prompt, ...(resolved.contextFiles === undefined ? {} : { contextFiles: resolved.contextFiles }) };
1068
+ const original = deepFreeze(structuredClone(validateAgentOptions(options.agentOptions ?? {
1069
+ ...(options.model === undefined ? {} : { model: options.model }), ...(options.tools === undefined ? {} : { tools: [...options.tools] }), ...(options.skills === undefined ? {} : { skills: [...options.skills] }), ...(options.extensions === undefined ? {} : { extensions: [...options.extensions] }), ...(options.contextFiles === undefined ? {} : { contextFiles: [...options.contextFiles] }),
1070
+ })));
1071
+ const dynamicModelAliasNames = this.root.dynamicModelAliasNames;
1072
+ const defaults = deepFreeze({ model: { ...this.root.model }, modelAliases: { ...this.root.modelAliases }, ...(dynamicModelAliasNames === undefined ? {} : { dynamicModelAliasNames: [...dynamicModelAliasNames] }), selectorSources: structuredClone(policy?.selectorSources ?? { global: this.root.resourceSelectors ?? {}, project: {} }), settings: structuredClone(options.inheritedExtensionSettings ?? this.root.extensionSettings ?? {}) });
1073
+ const context: AgentPreparationContext = Object.freeze({ options: original, cwd, projectCwd: this.root.cwd, agentDir: this.root.agentDir ?? getAgentDir(), projectTrusted: root.projectTrusted, defaults, capabilities: deepFreeze(structuredClone(capabilities)), knownModels: new Set(this.root.knownModels), availableModels: new Set(this.root.availableModels), signal, mode });
1074
+ const configuration: AgentPreparation = {
1075
+ ...(typeof original.label === "string" ? { label: original.label } : {}), ...(typeof original.model === "string" ? { model: original.model } : {}),
1076
+ tools: [...(policy?.effective.tools ?? this.root.resourceSelectors?.tools ?? []), ...(options.tools ?? [])], skills: [...(policy?.effective.skills ?? this.root.resourceSelectors?.skills ?? []), ...(options.skills ?? [])], extensions: [...(policy?.effective.extensions ?? this.root.resourceSelectors?.extensions ?? []), ...(options.extensions ?? []).map((selector) => canonicalExtensionSelector(selector, cwd))],
1077
+ ...(options.contextFiles === undefined ? {} : { contextFiles: [...options.contextFiles] }), systemPromptAppend: "", settings: structuredClone(defaults.settings),
1078
+ };
1079
+ for (const key of ["tools", "skills", "extensions"] as const) if (Array.isArray(original[key])) {
1080
+ const selectors = validateSelectorList(original[key], "agent options", key, "INVALID_METADATA", false) ?? [];
1081
+ configuration[key] = [...(policy?.effective[key] ?? this.root.resourceSelectors?.[key] ?? []), ...selectors.map((selector) => key === "extensions" ? canonicalExtensionSelector(selector, cwd) : selector)];
1082
+ }
1083
+ if (original.contextFiles !== undefined) configuration.contextFiles = [...(validateContextFileScopes(original.contextFiles, "agent options") ?? [])];
1084
+ const hooks = [...(this.root.agentPreparationHooks ?? [])].sort(byPriorityThenName);
1085
+ const load = this.root.extensionLoad;
1086
+ if (load?.errors.length) {
1087
+ // A failed extension may have owned an option, and its factory may have registered hooks before it failed. Only a
1088
+ // schema whose registration names a loaded extension as `source` proves ownership.
1089
+ const loaded = new Set(load.loaded.map(extensionIdentity));
1090
+ const declared = new Set(hooks.flatMap(({ optionsSchema, source }) => source !== undefined && loaded.has(extensionIdentity(source)) && object(optionsSchema?.properties) ? Object.keys(optionsSchema.properties) : []));
1091
+ const unowned = Object.keys(original).find((key) => !isCoreAgentOption(key) && !declared.has(key));
1092
+ if (unowned !== undefined) throw new WorkflowError("INVALID_METADATA", `Agent option ${unowned} has no owner proven to have loaded while extensions failed to load: ${load.errors.join("; ")}`);
1093
+ }
1094
+ for (const hook of hooks) if (hook.optionsSchema !== undefined && !Value.Check(hook.optionsSchema, original)) throw new WorkflowError("INVALID_METADATA", `Agent options do not match preparation schema: ${hook.name}`);
1095
+ if (typeof original.model === "string" && this.root.blockedAliases?.has(original.model.split(":")[0] ?? "")) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${original.model} resolved to ${this.root.blockedAliasTargets?.[original.model.split(":")[0] ?? ""] ?? "an unavailable target"}`);
1096
+ for (const hook of hooks) { if (signal.aborted) throw new WorkflowError("CANCELLED", "Agent preparation cancelled"); await hook.prepare(configuration, context); }
1097
+ if (signal.aborted) throw new WorkflowError("CANCELLED", "Agent preparation cancelled");
1098
+ const { model: preparedModel, ...preparedOptions } = configuration;
1099
+ validateAgentOptions(preparedOptions);
1100
+ if (preparedModel !== undefined && (typeof preparedModel !== "string" || !preparedModel.trim())) throw new WorkflowError("INVALID_METADATA", "Prepared agent model must be a non-empty string");
1101
+ if (preparedModel && this.root.blockedAliases?.has(preparedModel.split(":")[0] ?? "")) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${preparedModel} resolved to ${this.root.blockedAliasTargets?.[preparedModel.split(":")[0] ?? ""] ?? "an unavailable target"}`);
1102
+ configuration.extensions = configuration.extensions.map((selector) => canonicalExtensionSelector(selector, cwd));
1103
+ // A hook that passes on an inherited virtual `workflow/<alias>` root model gets the same physical target as no hook.
1104
+ const model = physicalModel(preparedModel === undefined ? this.root.model : resolveModelReference(preparedModel, this.root.modelAliases, this.root.knownModels, this.root.settingsPath), this.root.modelAliases, this.root.knownModels, this.root.settingsPath);
1105
+ const excludedTools = excludedToolNames({ agentOptions: original, ...(options.inheritedExcludeTools ? { inheritedExcludeTools: options.inheritedExcludeTools } : {}) });
1106
+ const tools = selectResourcesByLayers([configuration.tools], capabilities.tools).filter((tool) => !excludedTools.has(tool));
1107
+ for (const pattern of validateSelectorList(original.tools, "agent options", "tools", "INVALID_METADATA", false) ?? []) if (!pattern.startsWith("!") && !resourcePatternHasMagic(pattern) && !capabilities.tools.includes(pattern)) throw new WorkflowError("UNKNOWN_TOOL", `Tool is outside the prepared agent policy: ${pattern}`);
1108
+ for (const pattern of unmatchedResourcePatterns(configuration.tools, capabilities.tools)) this.root.onResourceWarning?.(`Tool selector currently matches no authorized tool: ${pattern}`);
1109
+ const skills = selectResourcesByLayers([configuration.skills], capabilities.skills);
1110
+ const extensions = selectResourcesByLayers([configuration.extensions], capabilities.extensions);
1111
+ const concrete = validatePreparedConfiguration(root, { ...configuration, model, tools, excludeTools: [...excludedTools], skills: [...configuration.skills, "!*", ...skills], extensions: [...configuration.extensions, "!*", ...extensions], projectTrusted: context.projectTrusted }, capabilities, cwd, false);
1112
+ if (mode === "execution") {
1113
+ if (persistedKey && this.root.runStore?.saveAgentConfiguration) await this.root.runStore.saveAgentConfiguration(persistedKey, concrete);
1114
+ await options.onConfiguration?.(concrete);
1115
+ }
1116
+ return concrete;
979
1117
  }
980
1118
 
981
1119
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
982
1120
  const executionSignal = signal ?? this.root.runContext?.signal;
983
1121
  if (!Number.isInteger(options.retries ?? 0) || (options.retries ?? 0) < 0) throw new WorkflowError("INVALID_METADATA", "retries must be a non-negative integer");
984
1122
  if (options.timeoutMs !== undefined && options.timeoutMs !== null && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new WorkflowError("INVALID_METADATA", "timeoutMs must be null or a positive integer");
985
- let resolved = this.resolve(options);
1123
+ let resolved: ReturnType<WorkflowAgentExecutor["resolve"]>;
986
1124
  let recoveryModel: ModelSpec | undefined;
987
1125
  let cwd: string;
988
1126
  if (options.parent) {
@@ -1004,6 +1142,14 @@ export class WorkflowAgentExecutor {
1004
1142
  cwd = this.root.cwd;
1005
1143
  }
1006
1144
 
1145
+ const configuration = await this.prepare(options, cwd, executionSignal);
1146
+ const parentSkills = options.parent === undefined ? undefined : options.capabilities?.skills;
1147
+ options = { ...options, label: configuration.label ?? options.label, configuration, ...(parentSkills ? { parentSkills } : {}), capabilities: { tools: configuration.tools, skills: configuration.skills.slice(configuration.skills.lastIndexOf("!*") + 1), extensions: configuration.extensions.slice(configuration.extensions.lastIndexOf("!*") + 1) } };
1148
+ resolved = this.resolve(options);
1149
+ if (!resolved.tools.includes("agent")) customTools = customTools.filter(({ name }) => name !== "agent" && name !== "get_subagent_result" && name !== "steer_subagent");
1150
+ // A child's skill ceiling is what its parent session has loaded when it spawns, so withdrawn skills never stay in it.
1151
+ let publishLoadedSkills: (() => void) | undefined;
1152
+ customTools = customTools.map((tool) => tool.name !== "agent" ? tool : { ...tool, execute: (...args: Parameters<typeof tool.execute>) => { publishLoadedSkills?.(); return tool.execute(...args); } } as ToolDefinition);
1007
1153
  const attempts: AgentAttempt[] = [];
1008
1154
  let maxAttempts = (options.retries ?? 0) + 1;
1009
1155
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
@@ -1030,7 +1176,7 @@ export class WorkflowAgentExecutor {
1030
1176
  const resultSchema = Compile(options.schema ?? defaultWorkflowResultSchema);
1031
1177
  let resultAccepted = false;
1032
1178
  const resultTool = defineTool({
1033
- name: "workflow_result", label: "Workflow Result", description: "Submit the terminal workflow result", parameters: resultSchema.Type(),
1179
+ name: "workflow_result", label: "Workflow Result", description: options.schema ? "Submit the terminal workflow result. Call exactly once to finish the current task or message, with a value matching the required schema. Do not return prose as the final result." : "Submit the terminal workflow result. Call exactly once to finish the current task or message, with { result: \"final response\" }. Do not return prose as the final result.", parameters: resultSchema.Type(),
1034
1180
  async execute(_id: string, value: unknown) {
1035
1181
  if (!resultSchema.Check(value) || !jsonValue(value)) return { content: [{ type: "text" as const, text: "Result does not match the required schema." }], details: {}, isError: true };
1036
1182
  if (resultAccepted) return { content: [{ type: "text" as const, text: "Result has already been accepted." }], details: {}, isError: true };
@@ -1062,11 +1208,23 @@ export class WorkflowAgentExecutor {
1062
1208
 
1063
1209
  try {
1064
1210
  setupFailed = true;
1065
- const prepared = await prepareAgentSetup(this.root, this.transport, task, options, resolved, cwd, attempt, attemptSignal, customTools, resultTool, false, attempts.at(-1)?.error?.message);
1211
+ const prepared = await prepareAgentSetup(this.root, this.transport, task, options, resolved, cwd, attempt, attemptSignal, customTools, resultTool, false);
1066
1212
  setup = prepared.setup;
1067
1213
  const attemptSetup = prepared.setup;
1068
1214
  setupSummary = prepared.summary;
1069
1215
  if (prepared.failure) throw prepared.failure.error;
1216
+ let attemptCapabilities: AgentPreparationContext["capabilities"] | undefined;
1217
+ if (options.agentNodeId !== undefined) {
1218
+ const policy = setup.prepared.resourcePolicy;
1219
+ const sources = policy?.selectorSources;
1220
+ const capabilities = policy?.capabilities ?? options.capabilities;
1221
+ attemptCapabilities = {
1222
+ tools: setup.prepared.tools,
1223
+ skills: selectResourcesByLayers(sources ? resourceSelectorLayers(sources, "skills") : [], capabilities?.skills ?? []),
1224
+ extensions: selectResourcesByLayers(sources ? resourceSelectorLayers(sources, "extensions") : [], capabilities?.extensions ?? []),
1225
+ };
1226
+ this.root.onAgentCapabilities?.(options.agentNodeId, attemptCapabilities, policy?.projectTrusted ?? false);
1227
+ }
1070
1228
  setupFailed = false;
1071
1229
  if (attemptSignal.aborted) throw new WorkflowError("CANCELLED", "Agent cancelled");
1072
1230
  await options.onAttempt?.({ attempt, transport: attemptSetup.transport.id, accounting: zeroAccounting(), setup: setupSummary });
@@ -1101,6 +1259,13 @@ export class WorkflowAgentExecutor {
1101
1259
  } catch { /* Frozen prepared policies still receive the inspection overlay below. */ }
1102
1260
  }
1103
1261
  if (policy) setupSummary = { ...setupSummary, resourceSelectors: { ...resourcePolicySummary(policy, attemptSetup.sessionInput.tools), ...(inspection ? { skills: [...inspection.skills], extensions: [...inspection.extensions] } : {}) } };
1262
+ // The session's currently loaded skills, within its authorization, are the skill ceiling its children inherit.
1263
+ const nodeId = options.agentNodeId, selected = attemptCapabilities, live = session;
1264
+ publishLoadedSkills = nodeId === undefined || selected === undefined || live?.getResourceInspection === undefined ? undefined : () => {
1265
+ const loaded = live.getResourceInspection?.();
1266
+ if (loaded) this.root.onAgentCapabilities?.(nodeId, { ...selected, skills: [...loaded.skills] }, attemptSetup.prepared.resourcePolicy?.projectTrusted ?? false);
1267
+ };
1268
+ publishLoadedSkills?.();
1104
1269
  },
1105
1270
  onBeforeComplete: async () => {
1106
1271
  if (options.worktreeOwner) await runStore?.snapshotWorktree(options.worktreeOwner);
@@ -1127,6 +1292,7 @@ export class WorkflowAgentExecutor {
1127
1292
  const runtimeRun = { id: transportBase.run.runId, namespaceId: transportBase.run.sessionId, workflowName: transportBase.run.workflow.name };
1128
1293
  const providerRecovery = options.providerErrorRecovery;
1129
1294
  const runtimeAgent = { id: transportBase.identity.callSite, structuralPath: [...transportBase.identity.structuralPath], ...(options.parent ? { parentId: options.parent } : {}) };
1295
+ const attemptControl = attemptControlText(options, attempt, attempts.at(-1)?.error?.message);
1130
1296
  const runtimeRequest = {
1131
1297
  task: setup.prepared.initialPrompt ?? setup.prompt,
1132
1298
  cwd: setup.prepared.cwd,
@@ -1135,6 +1301,7 @@ export class WorkflowAgentExecutor {
1135
1301
  // Pi sessions execute the prepared native definitions; the executor does not duplicate them as neutral request tools.
1136
1302
  customTools: [],
1137
1303
  ...(options.schema ? { resultSchema: options.schema } : {}),
1304
+ ...(attemptControl ? { turnControl: attemptControl } : {}),
1138
1305
  run: runtimeRun,
1139
1306
  agent: runtimeAgent,
1140
1307
  signal: attemptSignal,
@@ -1201,7 +1368,6 @@ export interface ScheduledAgentOptions {
1201
1368
  extensions?: readonly string[];
1202
1369
  worktreeOwner?: string;
1203
1370
  model?: string;
1204
- role?: string;
1205
1371
  contextFiles?: readonly ContextFileScope[];
1206
1372
  schema?: JsonSchema;
1207
1373
  retries?: number;
@@ -1211,6 +1377,10 @@ export interface ScheduledAgentOptions {
1211
1377
  agentOptions?: Readonly<Record<string, JsonValue>>;
1212
1378
  agentIdentity?: AgentIdentity;
1213
1379
  extensionSettings?: Readonly<WorkflowExtensionSettings>;
1380
+ capabilities?: Readonly<{ tools: readonly string[]; skills: readonly string[]; extensions: readonly string[] }>;
1381
+ projectTrusted?: boolean;
1382
+ configuration?: PreparedAgentConfiguration;
1383
+ inheritedExcludeTools?: readonly string[];
1214
1384
  }
1215
1385
 
1216
1386
  export type ScheduledAgentResult =
@@ -1248,13 +1418,41 @@ type ScheduledNode = {
1248
1418
  resolveCompletion: () => void;
1249
1419
  task: () => Promise<void>;
1250
1420
  restored: boolean;
1421
+ //NOTE: attempt narrowing is ephemeral; the persisted logical configuration remains unchanged.
1422
+ attemptCapabilities?: AgentPreparationContext["capabilities"];
1423
+ attemptProjectTrusted?: boolean;
1251
1424
  steer?: (message: string) => void | Promise<void>;
1425
+ /** Children spawned so far per label; numbers the occurrence of each child's own identity. */
1426
+ childOccurrences?: Map<string, number>;
1252
1427
  };
1253
1428
 
1254
1429
  type ScheduledRun = { limit: number; beforeLaunch?: () => void; extensionSettings?: Readonly<WorkflowExtensionSettings> | undefined; logical: number; active: number; nextIndex: number; queue: Array<{ node?: ScheduledNode; start: () => void }> };
1255
1430
  export type OwnershipRecord = { id: string; parentId?: string; prompt?: string; label: string; state: ScheduledNode["state"]; options: Readonly<ScheduledAgentOptions> };
1256
1431
  type OwnershipWriter = (runId: string, ownership: readonly OwnershipRecord[]) => void | Promise<void>;
1257
1432
 
1433
+ /**
1434
+ * A child spawned by its parent's `agent` tool shares the parent's structural scope, breadcrumb and worktree, but owns its
1435
+ * call site under the parent's identity and spawn generation, with a per-label occurrence. It never carries the parent's
1436
+ * handle or turn. Children are new spawns: a retried or resumed parent spawns them again under new identities.
1437
+ */
1438
+ function childIdentity(parent: ScheduledNode, scope: AgentIdentity, label: string): AgentIdentity {
1439
+ const occurrences = parent.childOccurrences ?? new Map<string, number>();
1440
+ parent.childOccurrences = occurrences;
1441
+ const occurrence = (occurrences.get(label) ?? 0) + 1;
1442
+ occurrences.set(label, occurrence);
1443
+ // The parent's scheduler node number marks the spawn generation: a cold-resumed or retried parent runs as a new node.
1444
+ const generation = parent.id.slice(parent.id.lastIndexOf(":") + 1);
1445
+ return { structuralPath: [...scope.structuralPath], callSite: `child:${agentIdentityPath(scope)}/spawn:${generation}/${label}`, occurrence, ...(scope.parentBreadcrumb ? { parentBreadcrumb: scope.parentBreadcrumb } : {}), ...(scope.worktreeOwner ? { worktreeOwner: scope.worktreeOwner } : {}) };
1446
+ }
1447
+ /**
1448
+ * Foreign errors keep descriptive core codes such as UNKNOWN_MODEL. Cancellation and budget exhaustion drive node and run
1449
+ * state, so a foreign error may claim CANCELLED only for an aborted node and never BUDGET_EXHAUSTED.
1450
+ */
1451
+ function scheduledErrorCode(error: unknown, aborted: boolean): WorkflowErrorCode {
1452
+ if (error instanceof WorkflowError) return error.code;
1453
+ const code = errorCode(error);
1454
+ return code === undefined || code === "BUDGET_EXHAUSTED" || (code === "CANCELLED" && !aborted) ? "AGENT_FAILED" : code;
1455
+ }
1258
1456
  export class FairAgentScheduler {
1259
1457
  readonly #runs = new Map<string, ScheduledRun>();
1260
1458
  readonly #nodes = new Map<string, ScheduledNode>();
@@ -1287,6 +1485,17 @@ export class FairAgentScheduler {
1287
1485
  if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
1288
1486
  run.extensionSettings = settings === undefined ? undefined : deepFreeze(structuredClone(settings));
1289
1487
  }
1488
+ async setConfiguration(agentId: string, configuration: PreparedAgentConfiguration): Promise<void> {
1489
+ const node = this.#node(agentId);
1490
+ node.options = Object.freeze({ ...node.options, configuration: deepFreeze(structuredClone(configuration)), tools: Object.freeze([...configuration.tools]) });
1491
+ this.#persist(node.runId);
1492
+ await this.flush(node.runId);
1493
+ }
1494
+ setAttemptCapabilities(agentId: string, capabilities: AgentPreparationContext["capabilities"], projectTrusted: boolean): void {
1495
+ const node = this.#node(agentId);
1496
+ node.attemptCapabilities = deepFreeze(structuredClone(capabilities));
1497
+ node.attemptProjectTrusted = projectTrusted;
1498
+ }
1290
1499
  setExtensionSettings(agentId: string, settings: Readonly<WorkflowExtensionSettings>): void {
1291
1500
  const node = this.#node(agentId);
1292
1501
  node.options = Object.freeze({ ...node.options, extensionSettings: deepFreeze(structuredClone(settings)) });
@@ -1297,7 +1506,7 @@ export class FairAgentScheduler {
1297
1506
  const run = this.#runs.get(runId);
1298
1507
  if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
1299
1508
  const parent = parentId ? this.#nodes.get(parentId) : undefined;
1300
- if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Parent agent is not owned by this run");
1509
+ if (parentId && (!parent || parent.runId !== runId)) throw new WorkflowError("UNKNOWN_AGENT", "Parent agent is not owned by this run");
1301
1510
  const effective = this.#inherit(run, parent, options);
1302
1511
  const id = `${runId}:${String(++this.#nextId)}`;
1303
1512
  const tuiIndex = ++run.nextIndex;
@@ -1311,11 +1520,10 @@ export class FairAgentScheduler {
1311
1520
  node.state = "running";
1312
1521
  this.#persist(runId);
1313
1522
  try {
1314
- const value = await this.runner({ id, runId, tuiIndex, ...(parentId ? { parentId } : {}), prompt, options: effective, signal: node.controller.signal, setSteer: (handler) => { node.steer = handler; } });
1523
+ const value = await this.runner({ id, runId, tuiIndex, ...(parentId ? { parentId } : {}), prompt, options: node.options, signal: node.controller.signal, setSteer: (handler) => { node.steer = handler; } });
1315
1524
  this.#settle(node, { id, ok: true, value });
1316
1525
  } catch (error) {
1317
- const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", errorText(error));
1318
- this.#settle(node, { id, ok: false, error: { code: typed.code, message: typed.message } });
1526
+ this.#settle(node, { id, ok: false, error: { code: scheduledErrorCode(error, node.controller.signal.aborted), message: errorText(error) } });
1319
1527
  }
1320
1528
  };
1321
1529
  this.#nodes.set(id, node);
@@ -1328,7 +1536,7 @@ export class FairAgentScheduler {
1328
1536
  async result(parentId: string, childId: string): Promise<ScheduledAgentResult> {
1329
1537
  const parent = this.#node(parentId);
1330
1538
  const child = this.#node(childId);
1331
- if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Results are scoped to direct children");
1539
+ if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT", "Results are scoped to direct children");
1332
1540
  if (child.collected || !child.promise) throw new WorkflowError("AGENT_RESULT_COLLECTED", "Child result has already been collected; nested results are one-shot");
1333
1541
  if (child.collecting) throw new WorkflowError("AGENT_FAILED", "Child result is already being collected");
1334
1542
  child.collecting = true;
@@ -1362,7 +1570,7 @@ export class FairAgentScheduler {
1362
1570
 
1363
1571
  async steer(parentId: string, childId: string, message: string): Promise<void> {
1364
1572
  const child = this.#node(childId);
1365
- if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT_TYPE", "Steering is scoped to direct children");
1573
+ if (child.parentId !== parentId) throw new WorkflowError("UNKNOWN_AGENT", "Steering is scoped to direct children");
1366
1574
  if (child.state !== "running" && child.state !== "waiting_for_child") throw new WorkflowError("AGENT_FAILED", "Child is not running");
1367
1575
  if (!child.steer) throw new WorkflowError("AGENT_FAILED", "Child has not registered a steering handler");
1368
1576
  await child.steer(message);
@@ -1414,22 +1622,22 @@ export class FairAgentScheduler {
1414
1622
  this.#dispatch();
1415
1623
  }
1416
1624
 
1417
- toolsFor(parentId: string, resolveTools?: (role: string | undefined, tools: readonly string[] | undefined, model: string | undefined, inheritedTools: readonly string[], skills: readonly string[] | undefined, extensions: readonly string[] | undefined) => readonly string[]): ToolDefinition[] {
1625
+ toolsFor(parentId: string): ToolDefinition[] {
1418
1626
  const parent = this.#node(parentId);
1419
- if (!parent.options.tools.includes("agent")) return [];
1627
+ if (!(parent.attemptCapabilities?.tools ?? parent.options.tools).includes("agent")) return [];
1420
1628
  const agentTool = defineTool({
1421
- name: "agent", label: "Child Agent", description: "Start a direct child agent",
1422
- parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), skills: Type.Optional(Type.Array(Type.String())), extensions: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), role: Type.Optional(Type.String()), contextFiles: Type.Optional(Type.Array(Type.String())), outputSchema: Type.Optional(Type.Record(Type.String(), Type.Unknown())), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
1629
+ name: "agent", label: "Child Agent", description: "Start a direct child agent. You own the children you create: collect each result with get_subagent_result and do not leave descendants running.",
1630
+ parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), excludeTools: Type.Optional(Type.Array(Type.String(), { description: "Exact tool names removed after preparation; cannot remove workflow_result" })), skills: Type.Optional(Type.Array(Type.String())), extensions: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), contextFiles: Type.Optional(Type.Array(Type.String())), outputSchema: Type.Optional(Type.Record(Type.String(), Type.Unknown())), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
1423
1631
  outputSchema: Type.Object({ id: Type.String() }),
1424
1632
  execute: async (_id, params) => {
1425
1633
  if (!isChildAgentToolParams(params)) throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
1426
1634
  validateAgentOptions(params);
1427
1635
  const outputSchema = params.outputSchema;
1428
1636
  if (outputSchema !== undefined) validateSchema(outputSchema, "agent outputSchema");
1429
- const tools = (params.tools !== undefined || params.role !== undefined || params.skills !== undefined || params.extensions !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.skills, params.extensions) : undefined) ?? params.tools ?? parent.options.tools;
1637
+ const tools = parent.attemptCapabilities?.tools ?? parent.options.tools;
1430
1638
  const agentOptions = { ...params };
1431
1639
  Reflect.deleteProperty(agentOptions, "prompt");
1432
- const options: ScheduledAgentOptions = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.skills ? { skills: params.skills } : {}), ...(params.extensions ? { extensions: params.extensions } : {}), ...(params.model ? { model: params.model } : {}), ...(params.role ? { role: params.role } : {}), ...(params.contextFiles ? { contextFiles: params.contextFiles } : {}), ...(outputSchema === undefined ? {} : { schema: outputSchema }), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
1640
+ const options: ScheduledAgentOptions = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.skills ? { skills: params.skills } : {}), ...(params.extensions ? { extensions: params.extensions } : {}), ...(params.model ? { model: params.model } : {}), ...(params.contextFiles ? { contextFiles: params.contextFiles } : {}), ...(outputSchema === undefined ? {} : { schema: outputSchema }), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
1433
1641
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
1434
1642
  return { content: [{ type: "text" as const, text: JSON.stringify({ id: child.id }) }], details: { id: child.id }, structuredContent: { id: child.id } };
1435
1643
  },
@@ -1488,10 +1696,14 @@ export class FairAgentScheduler {
1488
1696
  const extensionSettings = options.extensionSettings ?? parent?.options.extensionSettings ?? run.extensionSettings;
1489
1697
  if (!parent) return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(extensionSettings === undefined ? {} : { extensionSettings }), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
1490
1698
  if (options.cwd !== parent.options.cwd) throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
1491
- const forbidden = inheritedTools.find((tool: string) => !parent.options.tools.includes(tool));
1699
+ const parentCapabilities = parent.attemptCapabilities ?? (parent.options.configuration ? { tools: parent.options.configuration.tools, skills: parent.options.configuration.skills.slice(parent.options.configuration.skills.lastIndexOf("!*") + 1), extensions: parent.options.configuration.extensions.slice(parent.options.configuration.extensions.lastIndexOf("!*") + 1) } : parent.options.capabilities);
1700
+ const forbidden = inheritedTools.find((tool: string) => !(parentCapabilities?.tools ?? parent.options.tools).includes(tool));
1492
1701
  if (forbidden) throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
1493
- const identity = options.agentIdentity ?? parent.options.agentIdentity;
1494
- return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(extensionSettings === undefined ? {} : { extensionSettings }), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
1702
+ const identity = options.agentIdentity ?? (parent.options.agentIdentity ? childIdentity(parent, parent.options.agentIdentity, options.label) : undefined);
1703
+ // Children inherit the exclusions frozen in their parent's configuration, not its re-evaluated options.
1704
+ const inheritedExcludeTools = [...(parent.options.configuration?.excludeTools ?? excludedToolNames(parent.options))];
1705
+ const projectTrusted = (parent.attemptProjectTrusted ?? parent.options.configuration?.projectTrusted ?? parent.options.projectTrusted) !== false && options.projectTrusted !== false;
1706
+ return Object.freeze({ ...options, projectTrusted, ...(inheritedExcludeTools.length ? { inheritedExcludeTools: Object.freeze(inheritedExcludeTools) } : {}), ...(parentCapabilities ? { capabilities: parentCapabilities } : {}), cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(extensionSettings === undefined ? {} : { extensionSettings }), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
1495
1707
  }
1496
1708
 
1497
1709
  #enqueue(runId: string, node: ScheduledNode | undefined, start: () => void): void { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
@@ -1510,7 +1722,7 @@ export class FairAgentScheduler {
1510
1722
  const item = run.queue.shift() as { node?: ScheduledNode; start: () => void };
1511
1723
  if (item.node) {
1512
1724
  try { run.beforeLaunch?.(); }
1513
- catch (error) { const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", errorText(error)); this.#settle(item.node, { id: item.node.id, ok: false, error: { code: typed.code, message: typed.message } }); continue; }
1725
+ catch (error) { this.#settle(item.node, { id: item.node.id, ok: false, error: { code: scheduledErrorCode(error, item.node.controller.signal.aborted), message: errorText(error) } }); continue; }
1514
1726
  }
1515
1727
  run.active += 1; this.#active += 1; item.start();
1516
1728
  }
@@ -1546,7 +1758,7 @@ export class FairAgentScheduler {
1546
1758
 
1547
1759
  #node(id: string): ScheduledNode {
1548
1760
  const node = this.#nodes.get(id);
1549
- if (!node) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown owned agent: ${id}`);
1761
+ if (!node) throw new WorkflowError("UNKNOWN_AGENT", `Unknown owned agent: ${id}`);
1550
1762
  return node;
1551
1763
  }
1552
1764