@marcoscale98/pi-extensible-workflows 5.17.0-fork.1 → 5.19.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.
- package/CHANGELOG.md +68 -0
- package/dist/src/agent-execution.js +81 -39
- package/dist/src/background-widget.d.ts +5 -0
- package/dist/src/background-widget.js +1 -1
- package/dist/src/decoders.js +26 -0
- package/dist/src/execution.d.ts +4 -2
- package/dist/src/execution.js +43 -2
- package/dist/src/host-delivery.js +1 -0
- package/dist/src/host-recovery.d.ts +5 -2
- package/dist/src/host-recovery.js +3 -3
- package/dist/src/host-view.d.ts +5 -0
- package/dist/src/host-view.js +29 -5
- package/dist/src/host.d.ts +2 -2
- package/dist/src/host.js +190 -37
- package/dist/src/index.js +495 -93
- package/dist/src/index.js.map +3 -3
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/registry.js +1 -1
- package/dist/src/roles.js +4 -3
- package/dist/src/script-tools.d.ts +27 -0
- package/dist/src/script-tools.js +87 -0
- package/dist/src/settings.d.ts +13 -0
- package/dist/src/settings.js +27 -3
- package/dist/src/trajectory.d.ts +12 -2
- package/dist/src/trajectory.js +30 -8
- package/dist/src/types.d.ts +30 -2
- package/dist/src/types.js +1 -1
- package/dist/src/utils.d.ts +19 -0
- package/dist/src/utils.js +30 -0
- package/dist/src/validation.d.ts +6 -1
- package/dist/src/validation.js +21 -1
- package/dist/starter/index.js +0 -2
- package/dist/starter/index.js.map +1 -1
- package/dist/starter/review-loop.d.ts +0 -13
- package/dist/starter/review-loop.js +0 -2
- package/dist/subagents/index.js +267 -159
- package/dist/subagents/index.js.map +2 -2
- package/dist/subagents/src/contracts.d.ts +45 -29
- package/dist/subagents/src/contracts.js +18 -7
- package/dist/subagents/src/index.d.ts +1 -1
- package/dist/subagents/src/index.js +13 -6
- package/dist/subagents/src/manager.js +3 -3
- package/dist/subagents/src/view.d.ts +1 -0
- package/dist/subagents/src/view.js +13 -52
- package/dist/trajectory/assets/index.html +27 -5
- package/dist/trajectory/index.js +76 -28
- package/dist/trajectory/index.js.map +2 -2
- package/dist/trajectory/src/assets/index.html +27 -5
- package/dist/trajectory/src/index.js +8 -18
- package/dist/trajectory/src/server.js +4 -2
- package/dist/trajectory/src/server.js.map +2 -2
- package/package.json +6 -6
- package/skills/pi-extensible-workflows/SKILL.md +2 -0
- package/src/agent-execution.ts +77 -41
- package/src/background-widget.ts +1 -1
- package/src/decoders.ts +20 -0
- package/src/execution.ts +41 -4
- package/src/host-delivery.ts +1 -0
- package/src/host-recovery.ts +8 -7
- package/src/host-view.ts +23 -5
- package/src/host.ts +173 -35
- package/src/paths.ts +3 -0
- package/src/registry.ts +1 -1
- package/src/roles.ts +4 -3
- package/src/script-tools.ts +76 -0
- package/src/settings.ts +22 -4
- package/src/trajectory.ts +30 -11
- package/src/types.ts +11 -4
- package/src/utils.ts +28 -0
- package/src/validation.ts +19 -1
- package/starter/review-loop.ts +0 -2
- package/subagents/README.md +2 -2
- package/subagents/src/contracts.ts +18 -7
- package/subagents/src/index.ts +16 -6
- package/subagents/src/manager.ts +3 -3
- package/subagents/src/view.ts +13 -50
- package/trajectory/src/assets/index.html +27 -5
- package/trajectory/src/index.ts +6 -9
- package/trajectory/src/server.ts +2 -2
package/src/host.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { copyFile, mkdir } from "node:fs/promises";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { Type, type Api, type Model, type Static, type TSchema } from "@earendil-works/pi-ai";
|
|
7
|
-
import { copyToClipboard, getAgentDir, ModelSelectorComponent, type ExtensionAPI, type ExtensionContext, type ModelRuntime, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { copyToClipboard, getAgentDir, ModelSelectorComponent, type ExtensionAPI, type ExtensionContext, type ExtensionToolContext, type ModelRuntime, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { FairAgentScheduler, getAgentAttempts, WorkflowAgentExecutor, localAgentTransport, type AgentActivity, type AgentAttempt, type AgentDefinition, type AgentExecutionRoot, type AgentProgress, type AgentProviderFailure, type AgentProviderRecovery } from "./agent-execution.js";
|
|
9
9
|
import { RunLifecycle, WorkflowEventPublisher, hostSessionContext, nextNamedOccurrence, withWorkflowFunctions, withoutActiveShells, workflowRunContext, type WorkflowEventSink, type WorkflowRunRecord, type WorkflowToolUpdate } from "./host-runtime.js";
|
|
10
10
|
import { createWorkflowRecovery, persistedFailure, type ModelRegistryCapability } from "./host-recovery.js";
|
|
@@ -13,17 +13,18 @@ import { acquireSessionLease, isPersistedRun, listPersistedSessionIds, listRunId
|
|
|
13
13
|
import { retainTerminalRuns } from "./retention.js";
|
|
14
14
|
import type { PersistedRun, WorktreeReference } from "./persistence.js";
|
|
15
15
|
import { validateBudget, WorkflowBudgetRuntime } from "./budget.js";
|
|
16
|
-
import { SerialLane, asWorkflowError, createLaunchSnapshot, errorCode, errorText, fail, isNodeError, jsonValue, mergeWorkflowExtensionSettings, modelAliasErrorName, modelCapability, object, parseModelReference, positiveInteger, sanitizeDisplayText, validateModelAliases } from "./utils.js";
|
|
17
|
-
import { loadSettings, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, validateCheckpoint, validateModelAliasAvailability, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowSettingsPath } from "./validation.js";
|
|
16
|
+
import { SerialLane, asWorkflowError, createLaunchSnapshot, errorCode, errorText, fail, isNodeError, jsonValue, mergeWorkflowExtensionSettings, modelAliasErrorName, modelCapability, object, parseModelReference, positiveInteger, reachableTools, resolveModelReference, VIRTUAL_MODEL_PROVIDER, sanitizeDisplayText, validateModelAliases } from "./utils.js";
|
|
17
|
+
import { loadCodemodeToolsSetting, loadSettings, preflight, resolveAgentResourcePolicy, resolveWorkflowSettings, validateCheckpoint, validateModelAliasAvailability, validateWorkflowLaunchWithRegistry, workflowProjectSettingsPath, workflowSettingsPath, workflowToolExposure } from "./validation.js";
|
|
18
18
|
import { loadAgentDefinitions, loadProjectAgentDefinitions } from "./roles.js";
|
|
19
19
|
import { beginWorkflowExtensionLoading, loadingRegistry, resetWorkflowRegistryIfIdle, retainWorkflowRegistry, type WorkflowRegistryApi } from "./registry.js";
|
|
20
|
-
import { agentHandleTurnPath, agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath } from "./execution.js";
|
|
20
|
+
import { agentHandleTurnPath, agentIdentityPath, agentWorktree, encoded, executeShellCommand, persistActiveAgentAttempt, persistAgentAttempts, readShellResult, runWorkflow, shellIdentityPath, toolIdentityPath } from "./execution.js";
|
|
21
|
+
import { prepareScriptToolLoadout, scriptTool, scriptToolContext, scriptToolValue, validateScriptToolReferences } from "./script-tools.js";
|
|
21
22
|
import backgroundWidget, { type BackgroundWidgetAPI } from "./background-widget.js";
|
|
22
23
|
import { showChangelogNotice } from "./changelog.js";
|
|
23
24
|
import { createTrajectoryRunLoader, createTrajectoryRunMetadataLoader, createTrajectorySubagentLoader, createTrajectorySubagentMetadataLoader, createTrajectoryTranscriptLoader, type TrajectoryActionRequest, type TrajectoryActionResult, type TrajectorySubagent } from "./trajectory.js";
|
|
24
25
|
import { getTrajectoryHost, type TrajectoryPublisherProvider } from "./trajectory-host-handle.js";
|
|
25
26
|
import { getSubagentManager } from "./subagent-manager-handle.js";
|
|
26
|
-
import { HARD_TERMINAL_RUN_STATES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WORKFLOW_BLOCKED_EVENT, WorkflowError, isContextFileScope, isExternallyEndedRunState, isHardTerminalRunState, roleNameOf, type AgentAccounting, type AgentIdentity, type AgentRecord, type AgentResourcePolicy, type AgentTransport, type JsonValue, type LaunchSnapshot, type LiveSessionHandoff, type HardTerminalRunState, type ModelSpec, type PreparedAgentSession, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type WorkflowAgentSession, type WorkflowErrorCode, type WorkflowMetadata, type WorkflowModelAliasResolverContext, type WorkflowSettings, type WorkflowSettingsResolution, type WorkflowWorktreeReference } from "./types.js";
|
|
27
|
+
import { HARD_TERMINAL_RUN_STATES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, THINKING_LEVELS, WORKFLOW_BLOCKED_EVENT, WorkflowError, isContextFileScope, isExternallyEndedRunState, isHardTerminalRunState, roleNameOf, type AgentAccounting, type AgentIdentity, type AgentRecord, type AgentResourcePolicy, type AgentTransport, type JsonValue, type LaunchSnapshot, type LiveSessionHandoff, type HardTerminalRunState, type ModelSpec, type PreparedAgentSession, type RunState, type ShellIdentity, type ShellOptions, type ShellResult, type ToolIdentity, type WorkflowAgentSession, type WorkflowErrorCode, type WorkflowMetadata, type WorkflowModelAliasResolverContext, type WorkflowSettings, type WorkflowScriptCall, type WorkflowSettingsResolution, type WorkflowWorktreeReference } from "./types.js";
|
|
27
28
|
import type { SubagentManagerContext, SubagentRunRequest, SubagentStatus } from "../subagents/src/contracts.js";
|
|
28
29
|
import {
|
|
29
30
|
SETTLED_AGENT_STATES,
|
|
@@ -59,7 +60,7 @@ import {
|
|
|
59
60
|
type WorkflowLogEntry,
|
|
60
61
|
} from "./host-delivery.js";
|
|
61
62
|
|
|
62
|
-
export type WorkflowExtensionAPI = Pick<ExtensionAPI, "appendEntry" | "getActiveTools" | "getThinkingLevel" | "on" | "registerCommand" | "registerTool" | "sendMessage"> & Pick<BackgroundWidgetAPI, "events" | "registerEntryRenderer" | "registerShortcut">;
|
|
63
|
+
export type WorkflowExtensionAPI = Pick<ExtensionAPI, "appendEntry" | "getActiveTools" | "getThinkingLevel" | "on" | "registerCommand" | "registerTool" | "sendMessage"> & Partial<Pick<ExtensionAPI, "getAllTools">> & Pick<BackgroundWidgetAPI, "events" | "registerEntryRenderer" | "registerShortcut">;
|
|
63
64
|
|
|
64
65
|
export {
|
|
65
66
|
agentBreadcrumb,
|
|
@@ -117,6 +118,13 @@ function mainAgentError(error: unknown): WorkflowError {
|
|
|
117
118
|
Object.assign(presented, typed);
|
|
118
119
|
return presented;
|
|
119
120
|
}
|
|
121
|
+
/** The structured counterpart of {@link completionControlContent}: the result without the persisted run, with the full value. */
|
|
122
|
+
function completionControlValue(result: Readonly<Record<string, unknown>>, controlRunId?: string): Record<string, unknown> {
|
|
123
|
+
const record: Record<string, unknown> = { ...(controlRunId === undefined ? {} : { runId: controlRunId }), ...result };
|
|
124
|
+
delete record.run;
|
|
125
|
+
delete record.completion;
|
|
126
|
+
return record;
|
|
127
|
+
}
|
|
120
128
|
function completionControlContent(result: unknown, controlRunId?: string): string {
|
|
121
129
|
const record = object(result) ? { ...result } : undefined;
|
|
122
130
|
if (record && controlRunId !== undefined && record.runId === undefined) record.runId = controlRunId;
|
|
@@ -159,7 +167,7 @@ export function formatWorkflowPreview(args: { script?: unknown; scriptPath?: unk
|
|
|
159
167
|
return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
|
|
160
168
|
}
|
|
161
169
|
export const WORKFLOW_TOOL_LABEL = "Workflow";
|
|
162
|
-
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline or file-backed parallel-to-summary path by default"
|
|
170
|
+
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow with a named inline or file-backed parallel-to-summary path by default. Scripts call this session's tools as `await tools.<name>(args)`, outside withWorktree: a tool resolves to its text unless its description gives a `tools.<name>(args)` result shape, and failures reject"
|
|
163
171
|
export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow. Prefer a named inline script that fans out independent work with parallel(...), awaits the keyed results before interpolating them into one summarizing agent(...), and returns. Provide exactly one of script or scriptPath and a non-empty name. Registered catalog functions are available as globals inside the script; call them there, for example return await someFunction(args). Advanced controls include registered functions, outputSchema, budgets, checkpoints, worktrees, retry/resume, CLI export, and pipelines. Runs are in the background by default; completion arrives as a follow-up message. Set foreground: true when the caller must wait for the final value. Manage runs from the interactive /workflow picker; use workflow_status, workflow_resume, workflow_retry, workflow_stop, and workflow_respond for explicit tool controls. If a foreground call detaches before its result is accepted, its terminal success or failure is promoted to one follow-up message. Foreground results include the completed run ID. Recovery inherits the source launch mode; legacy snapshots without launchMode recover in the background. Set foreground: true or false on workflow_resume/workflow_retry to override it; foreground recovery waits for terminal value and run details, while background recovery returns immediately and delivers completion or failure as a follow-up. After failure follow-ups, especially CANCELLED or interrupted runs, call workflow_status({ runId }) before recovery or replacement work, then pass its state as expectedState to workflow_retry/workflow_resume so recovery cannot act on a state that changed. Recovery map: agent(..., { retries }) reruns one agent call in the same run for transient failures; workflow_retry({ runId, expectedState?, foreground? }) replays a failed run into a child; workflow_resume({ runId, expectedState?, budget?, foreground? }) continues a budget_exhausted run; parentRunId on a new launch only borrows named worktrees and never replays or resumes."
|
|
164
172
|
export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
165
173
|
name: Type.String({ description: "Required non-empty workflow name" }),
|
|
@@ -174,6 +182,21 @@ export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
|
174
182
|
}, { additionalProperties: false });
|
|
175
183
|
export const WORKFLOW_STATUS_PARAMETERS = Type.Object({ runId: Type.String({ description: "Workflow run ID visible in the current project" }) }, { additionalProperties: false });
|
|
176
184
|
export const WORKFLOW_RETRY_PARAMETERS = Type.Object({ runId: Type.String({ description: "Explicit failed workflow run ID" }), expectedState: Type.Optional(Type.String({ description: "Persisted source state observed before recovery" })), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) });
|
|
185
|
+
// Structured results codemode scripts receive instead of the JSON text; nested records stay loose so they cannot drift from persistence.
|
|
186
|
+
const WORKFLOW_ERROR_OUTPUT = Type.Object({ code: Type.String(), message: Type.String() });
|
|
187
|
+
const WORKFLOW_LAUNCH_OUTPUT = Type.Object({ runId: Type.String(), state: Type.String(), detached: Type.Optional(Type.Boolean()), value: Type.Optional(Type.Unknown()) });
|
|
188
|
+
const WORKFLOW_STATUS_OUTPUT = Type.Object({ runId: Type.String(), workflowName: Type.String(), state: Type.String(), error: Type.Optional(WORKFLOW_ERROR_OUTPUT), failedAt: Type.Optional(Type.String()), budget: Type.Optional(Type.Unknown()), usage: Type.Optional(Type.Unknown()), phase: Type.Optional(Type.String()), delivery: Type.Optional(Type.Object({ mode: Type.String(), state: Type.String() })), agents: Type.Array(Type.Object({ id: Type.String(), label: Type.Optional(Type.String()), path: Type.String(), state: Type.String(), lastEventAt: Type.Optional(Type.Number()), accounting: Type.Optional(Type.Unknown()) })) });
|
|
189
|
+
const WORKFLOW_STOP_OUTPUT = Type.Object({ runId: Type.String(), state: Type.String(), stopped: Type.Boolean(), reason: Type.Optional(Type.String()) });
|
|
190
|
+
const WORKFLOW_RECOVERY_OUTPUT = Type.Object({ state: Type.String(), runId: Type.Optional(Type.String()), parentRunId: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), value: Type.Optional(Type.Unknown()) });
|
|
191
|
+
const WORKFLOW_RESPOND_OUTPUT = Type.Object({ state: Type.String(), approved: Type.Boolean(), reason: Type.String(), accepted: Type.Optional(Type.Boolean()), runId: Type.Optional(Type.String()), value: Type.Optional(Type.Unknown()) });
|
|
192
|
+
const WORKFLOW_CATALOG_ALIAS_OUTPUT = Type.Object({ name: Type.String(), kind: Type.String(), provenance: Type.String(), version: Type.Optional(Type.String()), headline: Type.Optional(Type.String()) });
|
|
193
|
+
// The index without a name; with one, the function, the model alias, or { error }.
|
|
194
|
+
const WORKFLOW_CATALOG_OUTPUT = Type.Union([
|
|
195
|
+
Type.Object({ functions: Type.Array(Type.Object({ name: Type.String(), description: Type.String(), input: Type.Unknown() })), modelAliasEntries: Type.Optional(Type.Array(WORKFLOW_CATALOG_ALIAS_OUTPUT)), settings: Type.Optional(Type.Unknown()) }),
|
|
196
|
+
Type.Object({ name: Type.String(), version: Type.String(), headline: Type.String(), description: Type.String(), input: Type.Unknown(), output: Type.Unknown() }),
|
|
197
|
+
WORKFLOW_CATALOG_ALIAS_OUTPUT,
|
|
198
|
+
Type.Object({ error: Type.Object({ code: Type.String(), name: Type.String(), message: Type.String() }) }),
|
|
199
|
+
]);
|
|
177
200
|
|
|
178
201
|
function workflowToolUpdate(run: PersistedRun): WorkflowToolUpdate {
|
|
179
202
|
//NOTE: renderers read details.run; the partial text stays O(1) so high-frequency updates never format the whole tree.
|
|
@@ -257,9 +280,46 @@ function modelInventory(root: ModelSpec | undefined, registry: ModelRegistryCapa
|
|
|
257
280
|
if (rootName) { knownModels.add(rootName); availableModels.add(rootName); }
|
|
258
281
|
return { knownModels, availableModels };
|
|
259
282
|
}
|
|
260
|
-
function resumeHostContext(ctx: unknown): { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; deliveryContext: CompletionDeliveryContext } {
|
|
283
|
+
function resumeHostContext(ctx: unknown): { model: { provider: string; id: string } | undefined; modelRegistry: ModelRegistryCapability | undefined; deliveryContext: CompletionDeliveryContext; toolContext: ExtensionToolContext | undefined } {
|
|
261
284
|
const model = object(ctx) && object(ctx.model) && typeof ctx.model.provider === "string" && typeof ctx.model.id === "string" ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
|
|
262
|
-
return { model, modelRegistry: contextHostCapabilities(ctx).modelRegistry, deliveryContext: completionContext(ctx) };
|
|
285
|
+
return { model, modelRegistry: contextHostCapabilities(ctx).modelRegistry, deliveryContext: completionContext(ctx), toolContext: scriptToolContext(ctx) };
|
|
286
|
+
}
|
|
287
|
+
// State is rewritten on every update, so large shell inputs and errors are kept as prefixes.
|
|
288
|
+
const SCRIPT_CALL_TEXT_LIMIT_BYTES = 16 * 1024;
|
|
289
|
+
function scriptCallInput(input: JsonValue): Pick<WorkflowScriptCall, "input" | "inputBytes"> {
|
|
290
|
+
const text = JSON.stringify(input);
|
|
291
|
+
const bytes = Buffer.byteLength(text);
|
|
292
|
+
return bytes <= SCRIPT_CALL_TEXT_LIMIT_BYTES ? { input } : { input: utf8Prefix(text, SCRIPT_CALL_TEXT_LIMIT_BYTES), inputBytes: bytes };
|
|
293
|
+
}
|
|
294
|
+
/** Records a script call as started, replacing the record of an attempt that an interruption left unfinished. */
|
|
295
|
+
function startScriptCall(run: PersistedRun, call: WorkflowScriptCall): PersistedRun {
|
|
296
|
+
const recorded = { ...call, ...(run.phase === undefined ? {} : { phase: run.phase }) };
|
|
297
|
+
const calls = run.scriptCalls ?? [];
|
|
298
|
+
return { ...run, scriptCalls: calls.some(({ path }) => path === call.path) ? calls.map((current) => current.path === call.path ? recorded : current) : [...calls, recorded] };
|
|
299
|
+
}
|
|
300
|
+
function finishScriptCall(run: PersistedRun, path: string, error?: WorkflowError): PersistedRun {
|
|
301
|
+
const finishedAt = Date.now();
|
|
302
|
+
return { ...run, scriptCalls: (run.scriptCalls ?? []).map((call) => call.path === path ? { ...call, finishedAt, ...(error ? { error: { code: error.code, ...(call.kind === "shell" ? { message: utf8Prefix(error.message, SCRIPT_CALL_TEXT_LIMIT_BYTES) } : {}) } } : {}) } : call) };
|
|
303
|
+
}
|
|
304
|
+
type AliasVirtualModel = { provider: string; id: string; name: string; thinkingLevels: readonly string[]; route: (request: { thinkingLevel: string }, ctx: ExtensionContext) => { model: Model<Api>; thinkingLevel: string } };
|
|
305
|
+
/** Lists workflow model aliases in `/model` as virtual models; needs a Pi host with `registerVirtualModel`. */
|
|
306
|
+
function registerAliasVirtualModels(pi: unknown, names: readonly string[], settingsPath: string): void {
|
|
307
|
+
const register = object(pi) ? asFn(pi.registerVirtualModel) : undefined;
|
|
308
|
+
if (!register) return;
|
|
309
|
+
for (const name of names) {
|
|
310
|
+
const definition: AliasVirtualModel = {
|
|
311
|
+
provider: VIRTUAL_MODEL_PROVIDER, id: name, name: `${name} (workflow alias)`, thinkingLevels: THINKING_LEVELS,
|
|
312
|
+
route: (request, ctx) => {
|
|
313
|
+
// Settings are read per request so alias edits apply without a reload.
|
|
314
|
+
const aliases = resolveWorkflowSettings(ctx.cwd, projectTrusted(ctx), settingsPath).effective.modelAliases ?? {};
|
|
315
|
+
const target = resolveModelReference(name, aliases, new Set(ctx.modelRegistry.getAll().map((model) => `${model.provider}/${model.id}`)), settingsPath);
|
|
316
|
+
const model = ctx.modelRegistry.find(target.provider, target.model);
|
|
317
|
+
if (!model) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model alias ${name} resolved to ${target.provider}/${target.model}`);
|
|
318
|
+
return { model, thinkingLevel: request.thinkingLevel };
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
Reflect.apply(register, pi, [definition]);
|
|
322
|
+
}
|
|
263
323
|
}
|
|
264
324
|
async function resolveLaunchAliases(registry: WorkflowRegistryApi, staticAliases: Readonly<Record<string, string>>, context: Readonly<WorkflowModelAliasResolverContext>, availableModels: ReadonlySet<string>, knownModels: ReadonlySet<string>, settingsPath: string): Promise<{ aliases: Readonly<Record<string, string>>; dynamicNames: readonly string[] }> {
|
|
265
325
|
const dynamic = typeof registry.resolveModelAliases === "function" ? await registry.resolveModelAliases(context, new Set(Object.keys(staticAliases))) : {};
|
|
@@ -290,8 +350,14 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
290
350
|
return textBlock(data ? `Warning: ${data.message}` : "");
|
|
291
351
|
});
|
|
292
352
|
let backgroundWidgetEnabled = true;
|
|
293
|
-
try {
|
|
353
|
+
try {
|
|
354
|
+
const globalSettings = loadSettings(workflowSettingsPath(extensionAgentDir));
|
|
355
|
+
backgroundWidgetEnabled = globalSettings.backgroundWidget ?? true;
|
|
356
|
+
// Registered at load, before Pi resolves --model and restores the session model. Project aliases need project trust, which is unknown here.
|
|
357
|
+
registerAliasVirtualModels(pi, Object.keys(globalSettings.modelAliases ?? {}), workflowSettingsPath(extensionAgentDir));
|
|
358
|
+
} catch { /* Keep the optional UI enabled; the launch path reports settings errors. */ }
|
|
294
359
|
const backgroundWidgetController = backgroundWidget(pi, backgroundWidgetEnabled);
|
|
360
|
+
const codemodeTools = loadCodemodeToolsSetting(extensionAgentDir);
|
|
295
361
|
const logBridge = (store: RunStore, lifecycle: RunLifecycle, workflowName: string) => async (message: string) => {
|
|
296
362
|
const timestamp = Date.now();
|
|
297
363
|
const bounded = utf8Prefix(message, DELIVERY_LIMIT_BYTES);
|
|
@@ -534,7 +600,11 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
534
600
|
if (replayed) return readShellResult(replayed.value);
|
|
535
601
|
const shellStartedAt = Date.now();
|
|
536
602
|
let shellPhaseIndex = -1;
|
|
537
|
-
|
|
603
|
+
// Env values stay out of the record: they may carry secrets, and a shared trajectory publishes run state.
|
|
604
|
+
const call: WorkflowScriptCall = { kind: "shell", name: "shell", path, ...scriptCallInput({ command, ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.env === undefined ? {} : { env: Object.keys(options.env) }), ...(identity.worktreeOwner ? { worktree: identity.worktreeOwner } : {}) }), startedAt: shellStartedAt };
|
|
605
|
+
let failure: WorkflowError | undefined;
|
|
606
|
+
const started = await persistRunState(store, metadata, (run) => {
|
|
607
|
+
const current = startScriptCall(run, call);
|
|
538
608
|
const history = current.phaseHistory ?? [];
|
|
539
609
|
if (current.phase !== undefined) shellPhaseIndex = current.phaseHistoryIndex ?? (history.length ? history.length - 1 : 0);
|
|
540
610
|
const phaseActivities = [...(current.activeShellsByPhase ?? [])];
|
|
@@ -552,8 +622,12 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
552
622
|
if (!jsonValue(result)) fail("SHELL_FAILED", "Shell result is not JSON-compatible");
|
|
553
623
|
await store.complete(path, result);
|
|
554
624
|
return result;
|
|
625
|
+
} catch (error) {
|
|
626
|
+
failure = asWorkflowError(error);
|
|
627
|
+
throw error;
|
|
555
628
|
} finally {
|
|
556
|
-
const stopped = await persistRunState(store, metadata, (
|
|
629
|
+
const stopped = await persistRunState(store, metadata, (run) => {
|
|
630
|
+
const current = finishScriptCall(run, path, failure);
|
|
557
631
|
const phaseActivities = [...(current.activeShellsByPhase ?? [])];
|
|
558
632
|
const phaseActivityIndex = phaseActivities.findIndex(({ phaseIndex }) => phaseIndex === shellPhaseIndex);
|
|
559
633
|
const phaseActivity = phaseActivities[phaseActivityIndex];
|
|
@@ -572,6 +646,44 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
572
646
|
}
|
|
573
647
|
} finally { await lifecycle.leave(); }
|
|
574
648
|
};
|
|
649
|
+
/** A script `tools.<id>(args)` call: replayed from the journal, otherwise run through the session's tool pipeline and journaled. */
|
|
650
|
+
const toolForRun = async (store: RunStore, lifecycle: RunLifecycle, context: ExtensionToolContext | undefined, identifier: string, args: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: ToolIdentity): Promise<JsonValue> => {
|
|
651
|
+
await lifecycle.enter();
|
|
652
|
+
try {
|
|
653
|
+
const path = toolIdentityPath(identifier, args, identity);
|
|
654
|
+
const replayed = await store.replay(path);
|
|
655
|
+
if (replayed) return replayed.value;
|
|
656
|
+
if (!context) fail("UNKNOWN_TOOL", `tools.${identifier} needs a run launched or recovered by a Pi tool call; runs resumed from /workflow or at session start, and headless launches, only replay journaled tool calls`);
|
|
657
|
+
// Rendering is best-effort: it must never lose or mask a completed call.
|
|
658
|
+
const render = async () => { try { const update = runs.get(store.runId)?.update; if (update) update(workflowToolUpdate(withLiveActivities((await store.load()).run))); } catch { /* best-effort progress */ } };
|
|
659
|
+
// Inspection writes must not prevent execution, mask errors, or reject a journaled success.
|
|
660
|
+
const record = async (update: (run: PersistedRun) => PersistedRun) => { try { await store.updateState(update); } catch { /* best-effort call metadata */ } };
|
|
661
|
+
let value: JsonValue;
|
|
662
|
+
try {
|
|
663
|
+
const tool = scriptTool(context, identifier);
|
|
664
|
+
if (!tool) fail("UNKNOWN_TOOL", `tools.${identifier} is not a tool this session can call`);
|
|
665
|
+
const cancelled = (): boolean => signal.aborted;
|
|
666
|
+
if (cancelled()) fail("CANCELLED", "Workflow cancelled");
|
|
667
|
+
await record((run) => startScriptCall(run, { kind: "tool", name: tool.name, path, startedAt: Date.now() }));
|
|
668
|
+
const finished = liveAgents.toolStarted(store.runId, tool.name);
|
|
669
|
+
try {
|
|
670
|
+
await render();
|
|
671
|
+
const outcome = await context.executeTool(tool.name, args, { signal });
|
|
672
|
+
if (cancelled()) fail("CANCELLED", "Workflow cancelled");
|
|
673
|
+
value = scriptToolValue(tool, outcome);
|
|
674
|
+
} finally { finished(); }
|
|
675
|
+
} catch (error) {
|
|
676
|
+
// A stale context throws plain errors from ctx.tools; report them as tool failures.
|
|
677
|
+
const failure = error instanceof WorkflowError ? error : new WorkflowError("TOOL_FAILED", `tools.${identifier}: ${errorText(error)}`);
|
|
678
|
+
await record((run) => finishScriptCall(run, path, failure));
|
|
679
|
+
throw failure;
|
|
680
|
+
}
|
|
681
|
+
await store.complete(path, value);
|
|
682
|
+
await record((run) => finishScriptCall(run, path));
|
|
683
|
+
await render();
|
|
684
|
+
return value;
|
|
685
|
+
} finally { await lifecycle.leave(); }
|
|
686
|
+
};
|
|
575
687
|
const lifecycleFor = (store: RunStore, state: RunState, metadata: WorkflowMetadata) => new RunLifecycle(state, async (next, previous, reason) => {
|
|
576
688
|
const run = runs.get(store.runId);
|
|
577
689
|
if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${store.runId}`);
|
|
@@ -822,17 +934,20 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
822
934
|
label: string,
|
|
823
935
|
description: string,
|
|
824
936
|
parameters: P,
|
|
825
|
-
|
|
937
|
+
outputSchema: TSchema,
|
|
938
|
+
run: (params: Static<P>, signal: AbortSignal, ctx: unknown) => Promise<{ text: string; details: unknown; structured: unknown }>,
|
|
826
939
|
) => {
|
|
827
940
|
pi.registerTool({
|
|
828
941
|
name,
|
|
829
942
|
label,
|
|
830
943
|
description,
|
|
831
944
|
parameters,
|
|
945
|
+
outputSchema,
|
|
946
|
+
...workflowToolExposure(name, codemodeTools),
|
|
832
947
|
async execute(_id, params, signal: AbortSignal, _onUpdate, ctx) {
|
|
833
948
|
try {
|
|
834
949
|
const result = await run(params, signal, ctx);
|
|
835
|
-
return { content: [{ type: "text" as const, text: result.text }], details: result.details };
|
|
950
|
+
return { content: [{ type: "text" as const, text: result.text }], details: result.details, structuredContent: result.structured as JsonValue };
|
|
836
951
|
} catch (error) {
|
|
837
952
|
throw mainAgentError(error);
|
|
838
953
|
}
|
|
@@ -847,15 +962,18 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
847
962
|
"Workflow Respond",
|
|
848
963
|
"Approve or reject one pending workflow checkpoint or budget decision",
|
|
849
964
|
Type.Object({ runId: Type.String(), name: Type.Optional(Type.String()), proposalId: Type.Optional(Type.String()), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
965
|
+
WORKFLOW_RESPOND_OUTPUT,
|
|
850
966
|
async (params, signal, ctx) => {
|
|
851
967
|
if (params.proposalId) {
|
|
852
968
|
const result = await recovery.answerBudgetDecision(params.runId, params.proposalId, params.approved, false, ctx, signal);
|
|
853
|
-
if (!result) { const denied = { state: "budget_exhausted" as const, approved: false, reason: "proposal_not_pending" }; return { text: JSON.stringify(denied), details: denied }; }
|
|
854
|
-
|
|
969
|
+
if (!result) { const denied = { state: "budget_exhausted" as const, approved: false, reason: "proposal_not_pending" }; return { text: JSON.stringify(denied), details: denied, structured: denied }; }
|
|
970
|
+
const reason = params.approved ? "approved" : "rejected";
|
|
971
|
+
return { text: completionControlContent(result, params.runId), details: { ...result, reason }, structured: { ...completionControlValue(result, params.runId), reason } };
|
|
855
972
|
}
|
|
856
973
|
if (!params.name) throw new WorkflowError("INVALID_METADATA", "workflow_respond requires name or proposalId");
|
|
857
974
|
const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
|
|
858
|
-
|
|
975
|
+
const details = { accepted, state: accepted ? "checkpoint_answered" : "not_pending", approved: params.approved, reason: "checkpoint" };
|
|
976
|
+
return { text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response.", details, structured: details };
|
|
859
977
|
},
|
|
860
978
|
);
|
|
861
979
|
registerControlTool(
|
|
@@ -863,9 +981,10 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
863
981
|
"Workflow Stop",
|
|
864
982
|
"Stop an active workflow run by ID",
|
|
865
983
|
Type.Object({ runId: Type.String() }, { additionalProperties: false }),
|
|
984
|
+
WORKFLOW_STOP_OUTPUT,
|
|
866
985
|
async (params) => {
|
|
867
986
|
const result = await stopWorkflowRun(params.runId);
|
|
868
|
-
return { text: JSON.stringify(result), details: result };
|
|
987
|
+
return { text: JSON.stringify(result), details: result, structured: result };
|
|
869
988
|
},
|
|
870
989
|
);
|
|
871
990
|
registerControlTool(
|
|
@@ -873,9 +992,10 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
873
992
|
"Workflow Status",
|
|
874
993
|
"Read a compact summary of a workflow run in the current project",
|
|
875
994
|
WORKFLOW_STATUS_PARAMETERS,
|
|
995
|
+
WORKFLOW_STATUS_OUTPUT,
|
|
876
996
|
async (params, _signal, ctx) => {
|
|
877
997
|
const result = await workflowStatusRun(params.runId, ctx);
|
|
878
|
-
return { text: JSON.stringify(result), details: result };
|
|
998
|
+
return { text: JSON.stringify(result), details: result, structured: result };
|
|
879
999
|
},
|
|
880
1000
|
);
|
|
881
1001
|
let catalogRegistered = false;
|
|
@@ -888,13 +1008,15 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
888
1008
|
if (!catalog.functions.length && !hasAliases && !hasSettings) return;
|
|
889
1009
|
pi.registerTool({
|
|
890
1010
|
name: "workflow_catalog",
|
|
1011
|
+
...workflowToolExposure("workflow_catalog", codemodeTools),
|
|
1012
|
+
outputSchema: WORKFLOW_CATALOG_OUTPUT,
|
|
891
1013
|
label: "Workflow Catalog",
|
|
892
1014
|
description: "List reusable workflow functions and model aliases; pass `name` to load one entry in full",
|
|
893
1015
|
parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Registered function or model alias name for full detail" })) }, { additionalProperties: false }),
|
|
894
1016
|
async execute(_id, params = {}) {
|
|
895
1017
|
const context = { cwd, projectTrusted: trustedProject, globalSettingsPath: workflowSettingsPath(extensionAgentDir) };
|
|
896
1018
|
const result = params.name === undefined ? registry.catalogIndex(context) : registry.catalogDetail(params.name, context);
|
|
897
|
-
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result };
|
|
1019
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }], details: result, structuredContent: result as unknown as JsonValue };
|
|
898
1020
|
},
|
|
899
1021
|
renderCall(args, theme) {
|
|
900
1022
|
const title = theme.fg("toolTitle", theme.bold("workflow_catalog"));
|
|
@@ -921,7 +1043,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
921
1043
|
return { definitions, capture: (role: string, model: ModelSpec) => captured.capture(role, model) };
|
|
922
1044
|
};
|
|
923
1045
|
const activeSnapshotTools = (tools: readonly string[], active: ReadonlySet<string> | "session") => active === "session"
|
|
924
|
-
? new Set(tools.filter((tool) => pi
|
|
1046
|
+
? new Set(tools.filter((tool) => reachableTools(pi).includes(tool) && tool !== "workflow_catalog"))
|
|
925
1047
|
: new Set(tools.filter((tool) => active.has(tool) || tool === "workflow_catalog"));
|
|
926
1048
|
const resumeLaunchPrologue = async (input: {
|
|
927
1049
|
snapshot: Readonly<LaunchSnapshot>;
|
|
@@ -935,7 +1057,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
935
1057
|
blockedAliasTargets?: Readonly<Record<string, string>>;
|
|
936
1058
|
withPreflight: boolean;
|
|
937
1059
|
}) => {
|
|
938
|
-
const active = new Set(pi
|
|
1060
|
+
const active = new Set(reachableTools(pi).filter((tool) => !INTERNAL_WORKFLOW_TOOLS.includes(tool)));
|
|
939
1061
|
const missing = input.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
940
1062
|
if (missing) throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
941
1063
|
const settingsPath = workflowSettingsPath(extensionAgentDir);
|
|
@@ -1002,7 +1124,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1002
1124
|
};
|
|
1003
1125
|
};
|
|
1004
1126
|
const recovery = createWorkflowRecovery({
|
|
1005
|
-
pi, home, runs, scheduler, eventPublisher, persistRunState, projectTrusted, resumeHostContext, ensureSessionLease, coordinateRunMutation, createAgentExecutor, activeSnapshotTools, frozenResourcePolicy, resolveLaunchPrologue: resumeLaunchPrologue, resumeRoles, workflowAgentHandler, shellForRun, resolveWorktree, checkpointBridge, phaseBridge, logBridge, lifecycleFor, createProviderErrorRecovery, cleanupTerminalRun, deliver: (content) => { deliver(pi, content); }, deliverTerminal: deliveryController.deliverTerminal, workflowToolUpdate, registry, modelSpec,
|
|
1127
|
+
pi, home, runs, scheduler, eventPublisher, persistRunState, projectTrusted, resumeHostContext, ensureSessionLease, coordinateRunMutation, createAgentExecutor, activeSnapshotTools, frozenResourcePolicy, resolveLaunchPrologue: resumeLaunchPrologue, resumeRoles, workflowAgentHandler, shellForRun, toolForRun, resolveWorktree, checkpointBridge, phaseBridge, logBridge, lifecycleFor, createProviderErrorRecovery, cleanupTerminalRun, deliver: (content) => { deliver(pi, content); }, deliverTerminal: deliveryController.deliverTerminal, workflowToolUpdate, registry, modelSpec,
|
|
1006
1128
|
});
|
|
1007
1129
|
const resumeSelectedWorkflow = async (runId: string, foreground: boolean, context: unknown, budgetPatch?: unknown): Promise<{ workflowName: string; state: "running" | "completed" | "awaiting_approval"; attached: boolean; value?: JsonValue }> => {
|
|
1008
1130
|
const run = runs.get(runId);
|
|
@@ -1058,9 +1180,10 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1058
1180
|
"Workflow Retry",
|
|
1059
1181
|
"Retry a failed workflow run by replaying its completed structural operations",
|
|
1060
1182
|
WORKFLOW_RETRY_PARAMETERS,
|
|
1183
|
+
WORKFLOW_RECOVERY_OUTPUT,
|
|
1061
1184
|
async (params, signal, ctx) => {
|
|
1062
1185
|
const result = await recovery.retryWorkflowRun(params.runId, ctx, signal, params.foreground, params.expectedState);
|
|
1063
|
-
return { text: completionControlContent(result), details: result };
|
|
1186
|
+
return { text: completionControlContent(result), details: result, structured: completionControlValue(result) };
|
|
1064
1187
|
},
|
|
1065
1188
|
);
|
|
1066
1189
|
registerControlTool(
|
|
@@ -1068,9 +1191,10 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1068
1191
|
"Workflow Resume",
|
|
1069
1192
|
"Resume an exhausted workflow with unchanged or patched aggregate budgets",
|
|
1070
1193
|
Type.Object({ runId: Type.String(), expectedState: Type.Optional(Type.String({ description: "Persisted source state observed before recovery" })), budget: Type.Optional(Type.Unknown()), foreground: Type.Optional(Type.Boolean({ description: "Override the source launch mode for this recovery" })) }, { additionalProperties: false }),
|
|
1194
|
+
WORKFLOW_RECOVERY_OUTPUT,
|
|
1071
1195
|
async (params, signal, ctx) => {
|
|
1072
1196
|
const result = await recovery.resumeWorkflowRun(params.runId, params.budget, ctx, signal, params.foreground, true, params.expectedState);
|
|
1073
|
-
return { text: completionControlContent(result), details: result };
|
|
1197
|
+
return { text: completionControlContent(result), details: result, structured: completionControlValue(result) };
|
|
1074
1198
|
},
|
|
1075
1199
|
);
|
|
1076
1200
|
const deliverStaleTerminal = async (store: RunStore, run: PersistedRun): Promise<void> => {
|
|
@@ -1172,6 +1296,8 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1172
1296
|
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
1173
1297
|
promptSnippet: WORKFLOW_TOOL_PROMPT_SNIPPET,
|
|
1174
1298
|
parameters: WORKFLOW_TOOL_PARAMETERS,
|
|
1299
|
+
outputSchema: WORKFLOW_LAUNCH_OUTPUT,
|
|
1300
|
+
prepareLoadout: prepareScriptToolLoadout,
|
|
1175
1301
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1176
1302
|
let resolveDetached: ((result: ForegroundDetachResult) => void) | undefined;
|
|
1177
1303
|
let foregroundStore: RunStore | undefined;
|
|
@@ -1179,7 +1305,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1179
1305
|
const detachedResult = params.foreground ? new Promise<ForegroundDetachResult>((resolve) => { resolveDetached = resolve; }) : undefined;
|
|
1180
1306
|
const detachedToolResult = (run: PersistedRun) => {
|
|
1181
1307
|
const detached = { runId: run.id, state: "running" as const, detached: true as const };
|
|
1182
|
-
return { content: [{ type: "text" as const, text: JSON.stringify(detached) }], details: { ...detached, run, preview: `Moved workflow ${run.id} to background.` } };
|
|
1308
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(detached) }], details: { ...detached, run, preview: `Moved workflow ${run.id} to background.` }, structuredContent: detached };
|
|
1183
1309
|
};
|
|
1184
1310
|
try {
|
|
1185
1311
|
const headless = object(ctx) && ctx.headless === true;
|
|
@@ -1192,7 +1318,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1192
1318
|
const inventory = modelInventory(rootModel, modelRegistry);
|
|
1193
1319
|
const knownModels = inventory.knownModels;
|
|
1194
1320
|
const availableModels = inventory.availableModels;
|
|
1195
|
-
const rootTools = pi
|
|
1321
|
+
const rootTools = reachableTools(pi).filter((name) => !INTERNAL_WORKFLOW_TOOLS.includes(name));
|
|
1196
1322
|
const trustedProject = projectTrusted(ctx);
|
|
1197
1323
|
const launchCwd = typeof ctx.cwd === "string" ? ctx.cwd : process.cwd();
|
|
1198
1324
|
const launch = workflowLaunchSettings(launchCwd, trustedProject, settingsPath, params.concurrency);
|
|
@@ -1207,6 +1333,8 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1207
1333
|
if (trustedProject && launch.resolution.project.extensionSettings !== undefined) registry.validateExtensionSettings(launch.resolution.project.extensionSettings, { source: "project", cwd: ctx.cwd, projectTrusted: true, settingsPath: launch.resolution.projectSettingsPath });
|
|
1208
1334
|
const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, agentDir: extensionAgentDir, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools), modelAliases, knownModels, settingsPath, ...(settings.extensionSettings === undefined ? {} : { extensionSettings: settings.extensionSettings }) }, registry);
|
|
1209
1335
|
const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
|
|
1336
|
+
const toolContext = scriptToolContext(ctx);
|
|
1337
|
+
validateScriptToolReferences(script, toolContext);
|
|
1210
1338
|
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
1211
1339
|
const runId = randomUUID();
|
|
1212
1340
|
const args = params.args ?? null;
|
|
@@ -1263,7 +1391,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1263
1391
|
runs.set(runId, runRecord);
|
|
1264
1392
|
if (params.foreground && onUpdate) onUpdate(workflowToolUpdate((await store.load()).run));
|
|
1265
1393
|
scheduler.addRun(runId, settings.concurrency, () => runs.get(runId)?.budget.checkAgentLaunch(), settings.extensionSettings);
|
|
1266
|
-
const execution = runWorkflow(script, args, withWorkflowFunctions({ shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: workflowAgentHandler(store, checked.metadata, lifecycle, executor, ctx.cwd, runId, (role, model) => capturedRoles.capture(role, model)), worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, () => runs.get(runId)?.foreground ?? foregroundAttached, ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(store, lifecycle, checked.metadata.name) }, store, runContext, registry, settings.extensionSettings), runController.signal);
|
|
1394
|
+
const execution = runWorkflow(script, args, withWorkflowFunctions({ tool: (identifier, toolArgs, signal, identity) => toolForRun(store, lifecycle, toolContext, identifier, toolArgs, signal, identity), shell: (command, options, signal, identity) => shellForRun(store, checked.metadata, lifecycle, command, options, signal, identity), agent: workflowAgentHandler(store, checked.metadata, lifecycle, executor, ctx.cwd, runId, (role, model) => capturedRoles.capture(role, model)), worktree: async (owner) => resolveWorktree(store, checked.metadata, owner), checkpoint: checkpointBridge(runId, store, checked.metadata, () => runs.get(runId)?.foreground ?? foregroundAttached, ctx.hasUI ? ctx.ui : undefined, headless), phase: phaseBridge(store, checked.metadata, lifecycle), log: logBridge(store, lifecycle, checked.metadata.name) }, store, runContext, registry, settings.extensionSettings), runController.signal);
|
|
1267
1395
|
runRecord.execution = execution;
|
|
1268
1396
|
await eventPublisher.runStarted(store, checked.metadata);
|
|
1269
1397
|
const finish = execution.result.then(async (value) => {
|
|
@@ -1302,7 +1430,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1302
1430
|
}, async (error: unknown) => {
|
|
1303
1431
|
await deliveryController.deliverTerminal(store, deliverFailureContent(error), true);
|
|
1304
1432
|
});
|
|
1305
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
|
|
1433
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` }, structuredContent: { runId, state: "running" } };
|
|
1306
1434
|
}
|
|
1307
1435
|
void completion.then(async (result) => {
|
|
1308
1436
|
await deliveryController.deliverDetachedTerminal(toolCallId, completionContent("background", result));
|
|
@@ -1327,7 +1455,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1327
1455
|
}
|
|
1328
1456
|
deliveryController.foregroundDeliveries.delete(toolCallId);
|
|
1329
1457
|
const run = (await store.load()).run;
|
|
1330
|
-
return { content: [{ type: "text" as const, text: foregroundDelivery.content }, ...(foregroundDelivery.inlined ? [{ type: "text" as const, text: `Workflow run ID: ${runId}` }] : [])], details: { runId, value, run } };
|
|
1458
|
+
return { content: [{ type: "text" as const, text: foregroundDelivery.content }, ...(foregroundDelivery.inlined ? [{ type: "text" as const, text: `Workflow run ID: ${runId}` }] : [])], details: { runId, value, run }, structuredContent: { runId, state: "completed", value } };
|
|
1331
1459
|
} catch (error) {
|
|
1332
1460
|
if (params.foreground && foregroundStore && completionInstalled) {
|
|
1333
1461
|
const claim = await deliveryController.claimForegroundDelivery(foregroundStore, toolCallId);
|
|
@@ -1406,7 +1534,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1406
1534
|
return textBlock(isPartial ? "Workflow starting..." : runDetails?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
|
|
1407
1535
|
},
|
|
1408
1536
|
};
|
|
1409
|
-
pi.registerTool(workflowTool);
|
|
1537
|
+
pi.registerTool({ ...workflowTool, ...workflowToolExposure(workflowTool.name, codemodeTools) });
|
|
1410
1538
|
registerWorkflowNavigator({ pi, home, clipboard, extensionAgentDir, runs, terminalRunStates, hardTerminalRunStates: HARD_TERMINAL_RUN_STATES, ensureSessionLease, coordinateRunMutation, answerCheckpoint, recovery, stopWorkflowRun, moveForegroundToBackground: deliveryController.moveForegroundToBackground, isForegroundAttached: deliveryController.isForegroundAttached, liveAgents, registry, projectTrusted, resumeHostContext, resumeSelectedWorkflow, reportBlocked: reportWorkflowBlocked, trajectoryProvider, setNavigatorOpen: (open) => { if (open) backgroundWidgetController.suspend(); else backgroundWidgetController.resume(); } });
|
|
1411
1539
|
pi.on("session_shutdown", async () => {
|
|
1412
1540
|
try {
|
|
@@ -1436,6 +1564,7 @@ export default function workflowExtension(pi: WorkflowExtensionAPI, home?: strin
|
|
|
1436
1564
|
type LiveAgentState = { session?: WorkflowAgentSession; prepared?: Readonly<PreparedAgentSession>; handoff?: LiveSessionHandoff; activity?: AgentActivity; lastEventAt?: number };
|
|
1437
1565
|
class LiveAgentRegistry {
|
|
1438
1566
|
readonly #byRun = new Map<string, Map<string, LiveAgentState>>();
|
|
1567
|
+
readonly #tools = new Map<string, Set<{ name: string; startedAt: number }>>();
|
|
1439
1568
|
get(runId: string, agentId: string): Readonly<LiveAgentState> | undefined { return this.#byRun.get(runId)?.get(agentId); }
|
|
1440
1569
|
setSession(runId: string, agentId: string, session?: WorkflowAgentSession): void {
|
|
1441
1570
|
if (session) this.#state(runId, agentId).session = session;
|
|
@@ -1452,17 +1581,26 @@ class LiveAgentRegistry {
|
|
|
1452
1581
|
setEventTime(runId: string, agentId: string, timestamp?: number): void {
|
|
1453
1582
|
if (timestamp !== undefined) this.#state(runId, agentId).lastEventAt = timestamp;
|
|
1454
1583
|
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1584
|
+
/** Marks a script tool call in flight until the returned callback runs. */
|
|
1585
|
+
toolStarted(runId: string, name: string): () => void {
|
|
1586
|
+
const call = { name, startedAt: Date.now() };
|
|
1587
|
+
const calls = this.#tools.get(runId) ?? new Set<{ name: string; startedAt: number }>();
|
|
1588
|
+
this.#tools.set(runId, calls.add(call));
|
|
1589
|
+
return () => { calls.delete(call); if (!calls.size && this.#tools.get(runId) === calls) this.#tools.delete(runId); };
|
|
1590
|
+
}
|
|
1591
|
+
deleteRun(runId: string): void { this.#byRun.delete(runId); this.#tools.delete(runId); }
|
|
1592
|
+
/** Overlays live activity, freshness, and script tool calls onto a persisted run; returns the input when nothing is live. */
|
|
1457
1593
|
overlay(run: PersistedRun): PersistedRun {
|
|
1594
|
+
const tools = this.#tools.get(run.id);
|
|
1595
|
+
const withTools = tools?.size ? { ...run, activeTools: [...tools].map((call) => ({ ...call })) } : run;
|
|
1458
1596
|
const agents = this.#byRun.get(run.id);
|
|
1459
|
-
if (!agents?.size) return
|
|
1597
|
+
if (!agents?.size) return withTools;
|
|
1460
1598
|
const next = run.agents.map((agent) => {
|
|
1461
1599
|
const live = agents.get(agent.id);
|
|
1462
1600
|
if (!live || (live.activity === undefined && live.lastEventAt === undefined)) return agent;
|
|
1463
1601
|
return { ...agent, ...(live.activity === undefined ? {} : { activity: live.activity }), ...(live.lastEventAt === undefined ? {} : { lastEventAt: live.lastEventAt }) };
|
|
1464
1602
|
});
|
|
1465
|
-
return next.some((agent, index) => agent !== run.agents[index]) ? { ...
|
|
1603
|
+
return next.some((agent, index) => agent !== run.agents[index]) ? { ...withTools, agents: next } : withTools;
|
|
1466
1604
|
}
|
|
1467
1605
|
#state(runId: string, agentId: string): LiveAgentState {
|
|
1468
1606
|
const agents = this.#byRun.get(runId) ?? new Map<string, LiveAgentState>();
|
package/src/paths.ts
CHANGED
|
@@ -18,6 +18,9 @@ export function canonicalPath(path: string): string {
|
|
|
18
18
|
try { return realpathSync(absolute); } catch { return absolute; }
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Pi names built-in extensions `builtin:<name>` instead of by a path; other extensions are files. */
|
|
22
|
+
export function extensionIdentity(path: string): string { return path.startsWith("builtin:") ? path : canonicalPath(path); }
|
|
23
|
+
|
|
21
24
|
export function sameFilesystemPath(left: string, right: string): boolean { return canonicalPath(left) === canonicalPath(right); }
|
|
22
25
|
export function workflowProjectSettingsPath(cwd: string): string { return join(cwd, ".pi", "pi-extensible-workflows", "settings.json"); }
|
|
23
26
|
|
package/src/registry.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { validateSchema } from "./utils.js";
|
|
|
10
10
|
import { canonicalPath } from "./paths.js";
|
|
11
11
|
import { isCorePackageName } from "./package-identity.js";
|
|
12
12
|
|
|
13
|
-
const RESERVED_GLOBALS = new Set(["agent", "shell", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
|
|
13
|
+
const RESERVED_GLOBALS = new Set(["agent", "shell", "tools", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
|
|
14
14
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
15
15
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
16
16
|
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
package/src/roles.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { AgentDefinition, AgentResourceSelectorSources, AgentResourceSelect
|
|
|
5
5
|
import { registeredWorkflowRoleDirectoryRegistrations } from "./registry.js";
|
|
6
6
|
import { resolveWorkflowSettings, validateContextFileScopes, validateSelectorList, validateWorkflowExtensionSettings, workflowSettingsPath } from "./settings.js";
|
|
7
7
|
import { assertModelThinking, deepFreeze, object, errorText, fail, isNodeError, modelAliasName, modelCapability, resourcePatternHasMagic, resolveModelReference, selectResourcesByLayers, unmatchedResourcePatterns } from "./utils.js";
|
|
8
|
-
import { canonicalPath } from "./paths.js";
|
|
8
|
+
import { canonicalPath, extensionIdentity } from "./paths.js";
|
|
9
9
|
|
|
10
10
|
const ROLE_DIRECTORY = "pi-extensible-workflows";
|
|
11
11
|
// Preserve the role-file base when a discovered definition is resolved again through the public API.
|
|
@@ -237,7 +237,8 @@ export function loadAgentDefinitions(cwd: string, agentDir = getAgentDir(), proj
|
|
|
237
237
|
export function canonicalExtensionSelector(selector: string, base = process.cwd()): string {
|
|
238
238
|
const negated = selector.startsWith("!");
|
|
239
239
|
const body = negated ? selector.slice(1) : selector;
|
|
240
|
-
|
|
240
|
+
// Built-in extensions are named `builtin:<name>`, not by a path.
|
|
241
|
+
if (body === "*" || body === "**" || body.startsWith("**/") || body.startsWith("builtin:")) return selector;
|
|
241
242
|
const resolved = resolve(base, body);
|
|
242
243
|
if (resourcePatternHasMagic(body)) return `${negated ? "!" : ""}${resolved}`;
|
|
243
244
|
return `${negated ? "!" : ""}${canonicalPath(resolved)}`;
|
|
@@ -299,7 +300,7 @@ export function resolveRole(name: string | undefined, options: RoleResolutionOpt
|
|
|
299
300
|
if (outsideRootTool) fail("UNKNOWN_TOOL", `Tool is outside the launching session boundary: ${outsideRootTool}`);
|
|
300
301
|
}
|
|
301
302
|
const selectedSkills = resources.skills === undefined ? undefined : selectResourcesByLayers(selectorLayers.skills, resources.skills);
|
|
302
|
-
const extensionResources = resources.extensions?.map((extension) =>
|
|
303
|
+
const extensionResources = resources.extensions?.map((extension) => extensionIdentity(extension));
|
|
303
304
|
const selectedExtensions = extensionResources === undefined ? undefined : selectResourcesByLayers(selectorLayers.extensions, extensionResources);
|
|
304
305
|
const unmatchedSkills = resources.skills === undefined ? undefined : unmatchedResourcePatterns(selectorLayers.skills.flatMap((layer) => layer ?? []), resources.skills);
|
|
305
306
|
const unmatchedExtensions = extensionResources === undefined ? undefined : unmatchedResourcePatterns(selectorLayers.extensions.flatMap((layer) => layer ?? []), extensionResources);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionToolContext, ToolLoadout, ToolLoadoutChanges } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { WorkflowError, type JsonValue } from "./types.js";
|
|
3
|
+
import { errorText, jsonValue, object } from "./utils.js";
|
|
4
|
+
import { RPC_LIMIT_BYTES } from "./execution.js";
|
|
5
|
+
import { scriptToolReferences } from "./validation.js";
|
|
6
|
+
|
|
7
|
+
type ScriptTool = ExtensionToolContext["tools"][number];
|
|
8
|
+
type ScriptToolOutcome = Awaited<ReturnType<ExtensionToolContext["executeTool"]>>;
|
|
9
|
+
|
|
10
|
+
/** Workflow and subagent tools stay out of scripts, so a run cannot launch or control runs. Codemode is model-only and never callable. */
|
|
11
|
+
export function scriptToolExcluded(name: string): boolean {
|
|
12
|
+
return name === "workflow" || name.startsWith("workflow_") || name.startsWith("subagents_");
|
|
13
|
+
}
|
|
14
|
+
/** The identifier scripts use for a tool, the same as Pi codemode's, so its `tools.<id>(args)` lines apply to workflow scripts. */
|
|
15
|
+
export function scriptToolIdentifier(name: string): string {
|
|
16
|
+
let identifier = "";
|
|
17
|
+
for (const char of name) identifier += (identifier === "" ? /^[A-Za-z_$]$/ : /^[A-Za-z0-9_$]$/).test(char) ? char : "_";
|
|
18
|
+
return identifier || "_";
|
|
19
|
+
}
|
|
20
|
+
/** The context of a Pi tool call, whose `executeTool()` runs session tools; absent for commands, session events, and headless launches. */
|
|
21
|
+
export function scriptToolContext(ctx: unknown): ExtensionToolContext | undefined {
|
|
22
|
+
return object(ctx) && typeof ctx.executeTool === "function" && Array.isArray(ctx.tools) ? ctx as unknown as ExtensionToolContext : undefined;
|
|
23
|
+
}
|
|
24
|
+
export function scriptToolIdentifiers(context: ExtensionToolContext | undefined): string[] {
|
|
25
|
+
return (context?.tools ?? []).filter(({ name }) => !scriptToolExcluded(name)).map(({ name }) => scriptToolIdentifier(name));
|
|
26
|
+
}
|
|
27
|
+
// NOTE: a launch rejects identifiers that several tools share; a later tool set may still collide, and the first match wins.
|
|
28
|
+
export function scriptTool(context: ExtensionToolContext, identifier: string): ScriptTool | undefined {
|
|
29
|
+
return context.tools.find(({ name }) => !scriptToolExcluded(name) && scriptToolIdentifier(name) === identifier);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What a script call resolves to, as in codemode: the structured content of a tool with an output
|
|
33
|
+
* schema, also for error results that carry one, otherwise its text; other failures throw. The value
|
|
34
|
+
* is normalized through JSON and size-checked before it is journaled, so a replay returns the same value.
|
|
35
|
+
*/
|
|
36
|
+
export function scriptToolValue(tool: ScriptTool, outcome: ScriptToolOutcome): JsonValue {
|
|
37
|
+
const structured = tool.outputSchema === undefined ? undefined : outcome.result.structuredContent;
|
|
38
|
+
const text = outcome.result.content.flatMap((item) => item.type === "text" ? [item.text] : []).join("\n");
|
|
39
|
+
if (structured === undefined && outcome.isError) throw new WorkflowError("TOOL_FAILED", `${tool.name}: ${text || "failed"}`);
|
|
40
|
+
let value: unknown;
|
|
41
|
+
try { value = JSON.parse(JSON.stringify(structured ?? text)) as unknown; } catch (error) { throw new WorkflowError("TOOL_FAILED", `${tool.name} returned a result that is not JSON: ${errorText(error)}`); }
|
|
42
|
+
if (!jsonValue(value)) throw new WorkflowError("TOOL_FAILED", `${tool.name} returned a result that is not JSON`);
|
|
43
|
+
if (Buffer.byteLength(JSON.stringify(value)) > RPC_LIMIT_BYTES) throw new WorkflowError("TOOL_FAILED", `${tool.name} returned more than the 10 MB workflow RPC limit`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
/** Fails a launch whose script names a tool this session cannot call, before any agent runs. */
|
|
47
|
+
export function validateScriptToolReferences(script: string, context: ExtensionToolContext | undefined): void {
|
|
48
|
+
const references = scriptToolReferences(script);
|
|
49
|
+
if (!references?.length) return;
|
|
50
|
+
if (!context) throw new WorkflowError("UNKNOWN_TOOL", `Workflow scripts can call session tools only when launched by the workflow tool; this launch has none for ${references.map((name) => `tools.${name}`).join(", ")}`);
|
|
51
|
+
const identifiers = scriptToolIdentifiers(context);
|
|
52
|
+
const unknown = references.filter((name) => !identifiers.includes(name));
|
|
53
|
+
if (unknown.length) throw new WorkflowError("UNKNOWN_TOOL", `Workflow script calls tools this session cannot call: ${unknown.map((name) => `tools.${name}`).join(", ")}`);
|
|
54
|
+
const ambiguous = references.filter((name) => identifiers.indexOf(name) !== identifiers.lastIndexOf(name));
|
|
55
|
+
if (ambiguous.length) throw new WorkflowError("UNKNOWN_TOOL", `Workflow script calls tools whose names collide after normalization: ${ambiguous.map((name) => `tools.${name}`).join(", ")}`);
|
|
56
|
+
}
|
|
57
|
+
function describeScriptOutput(schema: unknown): string | undefined {
|
|
58
|
+
if (!object(schema) || schema.type !== "object" || !object(schema.properties)) return schema === undefined ? undefined : "a JSON value";
|
|
59
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
60
|
+
return `\`{ ${Object.keys(schema.properties).map((name) => required.has(name) ? name : `${name}?`).join(", ")} }\``;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Tells the model what `tools.<id>(args)` resolves to for declared tools with an output schema; the
|
|
64
|
+
* workflow description says that the others resolve to their text. With codemode active its own
|
|
65
|
+
* identical `Codemode:` lines say this, and pi keeps only one description per tool, so this defers.
|
|
66
|
+
*/
|
|
67
|
+
export function prepareScriptToolLoadout(loadout: ToolLoadout): ToolLoadoutChanges | undefined {
|
|
68
|
+
if (loadout.declared.some(({ name }) => name === "codemode")) return undefined;
|
|
69
|
+
const callable = new Set(loadout.callable.map(({ name }) => name));
|
|
70
|
+
const descriptions: Record<string, string> = {};
|
|
71
|
+
for (const tool of loadout.declared) {
|
|
72
|
+
const output = callable.has(tool.name) && !scriptToolExcluded(tool.name) ? describeScriptOutput(tool.outputSchema) : undefined;
|
|
73
|
+
if (output) descriptions[tool.name] = `${tool.description.trim()}\n\nWorkflow scripts: \`tools.${scriptToolIdentifier(tool.name)}(args)\` resolves to ${output}.`;
|
|
74
|
+
}
|
|
75
|
+
return { descriptions };
|
|
76
|
+
}
|