pi-extensible-workflows 3.1.0 → 3.3.0

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 (47) hide show
  1. package/README.md +11 -47
  2. package/dist/src/agent-execution.d.ts +4 -0
  3. package/dist/src/agent-execution.js +146 -60
  4. package/dist/src/bundles.d.ts +53 -0
  5. package/dist/src/bundles.js +457 -0
  6. package/dist/src/cli.d.ts +3 -1
  7. package/dist/src/cli.js +146 -21
  8. package/dist/src/doctor-cleanup.js +4 -2
  9. package/dist/src/eval-capture-extension.js +16 -1
  10. package/dist/src/execution.js +13 -4
  11. package/dist/src/host.d.ts +51 -8
  12. package/dist/src/host.js +1181 -487
  13. package/dist/src/index.d.ts +4 -2
  14. package/dist/src/index.js +3 -1
  15. package/dist/src/persistence.d.ts +40 -1
  16. package/dist/src/persistence.js +51 -0
  17. package/dist/src/session-inspector.d.ts +12 -2
  18. package/dist/src/session-inspector.js +36 -2
  19. package/dist/src/types.d.ts +19 -0
  20. package/dist/src/types.js +1 -0
  21. package/dist/src/validation.js +42 -2
  22. package/dist/src/workflow-artifacts.d.ts +13 -0
  23. package/dist/src/workflow-artifacts.js +39 -0
  24. package/dist/src/workflow-evals.d.ts +7 -1
  25. package/dist/src/workflow-evals.js +23 -3
  26. package/evals/cases/recovery-completed-worktree.yaml +17 -0
  27. package/evals/cases/recovery-failed-run.yaml +14 -0
  28. package/examples/workflow-extension-template/README.md +37 -0
  29. package/examples/workflow-extension-template/extension.test.mjs +59 -0
  30. package/examples/workflow-extension-template/index.js +51 -0
  31. package/examples/workflow-extension-template/roles/reviewer.md +4 -0
  32. package/package.json +3 -2
  33. package/skills/pi-extensible-workflows/SKILL.md +21 -28
  34. package/src/agent-execution.ts +102 -30
  35. package/src/bundles.ts +471 -0
  36. package/src/cli.ts +118 -21
  37. package/src/doctor-cleanup.ts +3 -2
  38. package/src/eval-capture-extension.ts +15 -1
  39. package/src/execution.ts +13 -4
  40. package/src/host.ts +992 -447
  41. package/src/index.ts +4 -2
  42. package/src/persistence.ts +53 -1
  43. package/src/session-inspector.ts +36 -4
  44. package/src/types.ts +12 -5
  45. package/src/validation.ts +33 -2
  46. package/src/workflow-artifacts.ts +34 -0
  47. package/src/workflow-evals.ts +24 -3
@@ -0,0 +1,17 @@
1
+ id: recovery-completed-worktree
2
+ prompt: >-
3
+ The completed workflow run ID is "completed-run-42". You need a new workflow to do named worktree work,
4
+ so launch the workflow with exactly parentRunId "completed-run-42". Use exactly the workflow arguments {"name":"borrow-worktree","script":"return true;","parentRunId":"completed-run-42"}.
5
+ Do not call workflow_retry because the source is completed and parentRunId only borrows the named worktree; it does not replay or resume.
6
+ maxCost: 0.1
7
+ expectations:
8
+ firstTool: workflow
9
+ firstBatchToolSequence:
10
+ startsWith: [workflow]
11
+ parentToolSequence:
12
+ startsWith: [workflow]
13
+ workflowCallCount: 1
14
+ expectedWorkflowCalls: 1
15
+ semanticCriteria:
16
+ - id: parent-run-id
17
+ description: The captured workflow call has exactly name borrow-worktree, script return true;, and parentRunId completed-run-42; it does not call workflow_retry.
@@ -0,0 +1,14 @@
1
+ id: recovery-failed-run
2
+ prompt: >-
3
+ A workflow failure diagnostic identifies the persisted failed run ID "failed-run-42" and says the exact next action is
4
+ workflow_retry({ runId: "failed-run-42" }). Select that recovery tool now with exactly {"runId":"failed-run-42"}.
5
+ Do not launch a new workflow, use parentRunId, or answer with prose.
6
+ maxCost: 0.1
7
+ expectedWorkflowCalls: 0
8
+ expectations:
9
+ firstTool: workflow_retry
10
+ firstBatchToolSequence:
11
+ equals: [workflow_retry]
12
+ parentToolSequence:
13
+ equals: [workflow_retry]
14
+ workflowCallCount: 0
@@ -0,0 +1,37 @@
1
+ # Workflow extension template
2
+
3
+ This is a small, copyable extension rather than a generator. It shows the usual
4
+ registration shape with one function and one packaged role. Copy the directory
5
+ into a project, rename the metadata and function, then edit the role body.
6
+
7
+
8
+ ## Run it
9
+
10
+ From the repository root after installing dependencies and building the package:
11
+
12
+ ```sh
13
+ node --test examples/workflow-extension-template/extension.test.mjs
14
+ ```
15
+
16
+ For a published package, run the same test from this directory after installing
17
+ `pi-extensible-workflows` in the surrounding project. Copy the directory into a
18
+ trusted Pi extension location; Pi auto-discovers its `index.js` entry point.
19
+
20
+
21
+ ## Files
22
+
23
+ - `index.js` registers `greet` and resolves `roles/` from `import.meta.url`.
24
+ Relative role-directory strings are not accepted by the extension API.
25
+ - `roles/reviewer.md` is a portable packaged role with no provider or tool
26
+ assumptions.
27
+ - `extension.test.mjs` checks registration, function behavior, role packaging,
28
+ and the advanced examples.
29
+
30
+
31
+ ## Optional advanced pieces
32
+
33
+ The dynamic `template-model` alias and `templateAdvisor` setup hook are
34
+ optional examples. The hook only changes an agent after the call includes
35
+ `{ templateAdvisor: true }`; remove either section if the extension does not
36
+ need it. Both features are trusted host code and should be kept under explicit
37
+ project policy.
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import { cp, mkdir, mkdtemp, rm, symlink } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
9
+ import { beginWorkflowExtensionLoading, loadingRegistry, registeredWorkflowFunctions, registeredWorkflowRoleDirectoryRegistrations, resetWorkflowRegistry, workflowCatalog } from "pi-extensible-workflows";
10
+
11
+ test("discovers the copied directory as a trusted Pi extension", async () => {
12
+ const root = await mkdtemp(join(tmpdir(), "workflow-extension-template-"));
13
+ try {
14
+ const destination = join(root, ".pi", "extensions", "workflow-extension-template");
15
+ await mkdir(join(root, "node_modules"), { recursive: true });
16
+ const packageEntry = fileURLToPath(import.meta.resolve("pi-extensible-workflows"));
17
+ const packageRoot = join(dirname(packageEntry), "..", "..");
18
+ await symlink(packageRoot, join(root, "node_modules", "pi-extensible-workflows"), "dir");
19
+ await cp(dirname(fileURLToPath(import.meta.url)), destination, { recursive: true });
20
+ const result = await discoverAndLoadExtensions([], root, join(root, ".pi", "agent"));
21
+ assert.equal(result.errors.length, 0);
22
+ assert.equal(result.extensions.length, 1);
23
+ } finally {
24
+ await rm(root, { recursive: true, force: true });
25
+ }
26
+
27
+ resetWorkflowRegistry();
28
+ const { default: extension } = await import("./index.js");
29
+ beginWorkflowExtensionLoading();
30
+ extension();
31
+
32
+ const catalog = workflowCatalog();
33
+ assert.deepEqual(catalog.functions.map(({ name }) => name), ["greet"]);
34
+ assert.deepEqual(catalog.modelAliasEntries?.filter(({ name }) => name === "template-model").map(({ name, kind }) => ({ name, kind })), [{ name: "template-model", kind: "dynamic" }]);
35
+ assert.equal(await registeredWorkflowFunctions().greet.run({ name: "Ada" }, {}), "Hello, Ada!");
36
+
37
+ const registration = registeredWorkflowRoleDirectoryRegistrations()[0];
38
+ assert.ok(registration);
39
+ assert.match(readFileSync(join(registration.path, "reviewer.md"), "utf8"), /Packaged reviewer role/);
40
+
41
+ const resolved = await loadingRegistry().resolveModelAliases({
42
+ cwd: process.cwd(),
43
+ projectTrusted: true,
44
+ rootModel: { provider: "example", model: "root" },
45
+ knownModels: new Set(["example/root", "example/available"]),
46
+ availableModels: new Set(["example/available"]),
47
+ signal: new AbortController().signal,
48
+ });
49
+ assert.deepEqual(resolved, { "template-model": "example/available" });
50
+
51
+ const hook = loadingRegistry().agentSetupHooks().find(({ name }) => name === "templateAdvisor");
52
+ assert.ok(hook);
53
+ const agent = { prompt: "Review this", options: {}, sessionInput: {} };
54
+ await hook.setup(agent, { signal: new AbortController().signal });
55
+ assert.equal(agent.sessionInput.systemPromptAppend, undefined);
56
+ agent.options.templateAdvisor = true;
57
+ await hook.setup(agent, { signal: new AbortController().signal });
58
+ assert.match(agent.sessionInput.systemPromptAppend, /one concrete risk/);
59
+ });
@@ -0,0 +1,51 @@
1
+ import { registerWorkflowExtension } from "pi-extensible-workflows";
2
+
3
+ const templateExtension = {
4
+ version: "1.0.0",
5
+ headline: "Workflow extension template",
6
+ description: "A copyable registered function with a packaged role.",
7
+ functions: {
8
+ greet: {
9
+ description: "Return a greeting for one person.",
10
+ input: {
11
+ type: "object",
12
+ properties: { name: { type: "string" } },
13
+ required: ["name"],
14
+ additionalProperties: false,
15
+ },
16
+ output: { type: "string" },
17
+ run(input) {
18
+ return `Hello, ${String(input.name)}!`;
19
+ },
20
+ },
21
+ },
22
+ // Packaged resources must be absolute paths or file URLs. This URL stays
23
+ // correct when the extension is copied or installed elsewhere.
24
+ roleDirectories: [new URL("./roles/", import.meta.url)],
25
+ // Optional advanced example: select an available model without naming a
26
+ // provider-specific model in the extension.
27
+ modelAliases: {
28
+ "template-model": {
29
+ resolve({ availableModels, rootModel }) {
30
+ const root = `${rootModel.provider}/${rootModel.model}`;
31
+ return availableModels.has(root) ? root : availableModels.values().next().value ?? root;
32
+ },
33
+ },
34
+ },
35
+ // Optional advanced example: mutate setup only for explicitly opted-in
36
+ // agents. Setup hooks are trusted code and should stay narrowly scoped.
37
+ agentSetupHooks: {
38
+ templateAdvisor: {
39
+ setup(agent, context) {
40
+ if (context.signal.aborted || agent.options.templateAdvisor !== true) return;
41
+ const suffix = "\n\nAdvisor: call out one concrete risk and one next check.";
42
+ const existing = agent.sessionInput.systemPromptAppend;
43
+ agent.sessionInput.systemPromptAppend = existing ? existing + suffix : suffix;
44
+ },
45
+ },
46
+ },
47
+ };
48
+
49
+ export default function extension() {
50
+ registerWorkflowExtension(templateExtension);
51
+ }
@@ -0,0 +1,4 @@
1
+ ---
2
+ description: Packaged reviewer role
3
+ ---
4
+ Review the requested change carefully. Return concrete findings with file names and a next check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensible-workflows",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Deterministic multi-agent workflow orchestration for Pi",
5
5
  "homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
6
6
  "repository": {
@@ -25,6 +25,7 @@
25
25
  "src",
26
26
  "skills",
27
27
  "evals",
28
+ "examples",
28
29
  "test/fixtures"
29
30
  ],
30
31
  "scripts": {
@@ -32,7 +33,7 @@
32
33
  "inspect": "node dist/src/cli.js inspect",
33
34
  "lint": "eslint .",
34
35
  "test": "npm run build && TEST_FILES='dist/test/*.test.js' npm run test:run",
35
- "test:run": "tmp=$(mktemp -d); trap 'rm -rf \"$tmp\"' EXIT; TMPDIR=\"$tmp\" node --test $TEST_FILES",
36
+ "test:run": "tmp=$(mktemp -d); trap 'rm -rf \"$tmp\"' EXIT; TMPDIR=\"$tmp\" node --test --test-concurrency=1 $TEST_FILES",
36
37
  "acceptance": "npm run build && TEST_FILES='dist/test/runtime-acceptance.test.js' npm run test:run",
37
38
  "docs:check": "node scripts/check-docs.mjs",
38
39
  "check": "npm run lint && npm test && npm run docs:check",
@@ -5,47 +5,36 @@ description: Use when the task is complex enough to require multiple subagents o
5
5
 
6
6
  # pi-extensible-workflows
7
7
 
8
- Use `workflow` only for genuinely multi-agent orchestration; one agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
8
+ ## Default path
9
9
 
10
- ## Example
10
+ Use `workflow` only for genuinely multi-agent orchestration; a single agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
11
11
 
12
- ```js
13
- const reportSchema = {
14
- type: "object",
15
- properties: {
16
- summary: { type: "string" },
17
- findings: { type: "array", items: { type: "string" } },
18
- },
19
- required: ["summary", "findings"],
20
- additionalProperties: false,
21
- };
12
+ For most multi-agent tasks, start with a named inline workflow: provide a non-empty `name` and a `script` that fans out independent work with `parallel(...)`, awaits the keyed results, passes them into one summarizing `agent(...)`, and returns.
22
13
 
14
+ ```js
23
15
  const reports = await parallel("research", {
24
- first: () =>
25
- agent("Research the first target.", {
26
- role: "scout",
27
- outputSchema: reportSchema,
28
- }),
29
- second: () =>
30
- agent("Research the second target.", {
31
- role: "scout",
32
- outputSchema: reportSchema,
33
- }),
16
+ first: () => agent("Research the first target."),
17
+ second: () => agent("Research the second target."),
34
18
  });
35
19
 
36
- return agent(prompt("Review these reports:\n\n{reports}", { reports }), {
37
- role: "reviewer",
38
- outputSchema: reportSchema,
39
- });
20
+ return await agent(
21
+ prompt("Summarize these reports:\n\n{reports}", { reports }),
22
+ );
40
23
  ```
41
24
 
25
+ Await `parallel(...)` or `pipeline(...)` results before interpolation. Runs are backgrounded by default; set the tool-call `foreground: true` when the caller must wait for the final value.
26
+
27
+ ## Runtime and safety rules
28
+
29
+
42
30
  Inline launches require a non-empty `name`. Registered function launches must omit `name`; they use `workflow` as the run name:
43
31
 
44
32
  ```json
45
33
  { "workflow": "workflowName", "args": { "issue": 42 } }
46
34
  ```
47
-
48
- Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes but does not replay journal results. For an explicitly failed run, use the exact `runId` from failure diagnostics with `workflow_retry({ runId })`: diagnostics list replayable and incomplete paths, artifacts, and valid named worktrees; the tool creates a linked child, replays completed agent, shell, function, and checkpoint operations, and executes incomplete work. Retry versus per-agent `retries` and `workflow_resume` is always explicit; external side effects before failure are not guaranteed exactly once.
35
+ Recovery map: `agent(..., { retries })` reruns one agent call in the same run for transient failures; `workflow_retry({ runId, foreground? })` replays a failed run into a child; `workflow_resume({ runId, budget?, foreground? })` continues a `budget_exhausted` run; recovery inherits the source snapshot's foreground/background launch mode, while legacy snapshots without `launchMode` recover in background; set `foreground: true` or `false` to override it; `parentRunId` on a new launch only borrows named worktrees and never replays or resumes. Use each only for its stated case.
36
+ For an explicitly failed run, use the exact `runId` from failure diagnostics with `workflow_retry({ runId })`: diagnostics list replayable and incomplete paths, artifacts, and valid named worktrees; the tool creates a linked child, replays completed agent, shell, function, and checkpoint operations, and executes incomplete work. External side effects before failure are not guaranteed exactly once.
37
+ Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground launches and foreground recovery retain their terminal value and completed `runId`, while background launches and recovery return `runId` immediately and deliver completion or failure as a follow-up. Retry versus per-agent `retries` and `workflow_resume` is always explicit.
49
38
  Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
50
39
 
51
40
  Workflow JavaScript has no imports, filesystem, network, process, or timers. Delegate that work to agents. `shell(command, options)` is the trusted host RPC for deterministic gates: it inherits the workflow or active-worktree cwd, merges string `env` overrides, and returns `{ exitCode, stdout, stderr }`; nonzero exits are results, but launch failures and timeouts fail with `SHELL_FAILED`.
@@ -63,6 +52,10 @@ if (testRes.exitCode === 0) {
63
52
 
64
53
  Most of the times using `shell()` to perform mutations is an antipattern. Use it mainly for verifications or idempotent actions.
65
54
 
55
+ ## Advanced capabilities
56
+
57
+ Registered functions, `outputSchema`, budgets, checkpoints, worktrees, retry/resume, CLI export, and `pipeline(...)` remain available for workflows that need them. Treat these as advanced controls rather than requirements for the default inline path.
58
+
66
59
  ## `agent()` options
67
60
 
68
61
  ```typescript
@@ -1,4 +1,5 @@
1
- import { realpathSync } from "node:fs";
1
+ import { existsSync, realpathSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
4
  import { Type } from "@earendil-works/pi-ai";
4
5
  import { Value } from "typebox/value";
@@ -16,7 +17,7 @@ export interface AgentBudgetHooks {
16
17
  afterTurn(accounting: AgentAccounting, final: boolean): void;
17
18
  instruction(): string | undefined;
18
19
  }
19
- export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
20
+ export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: ThinkingLevel; tools?: readonly string[]; overrideSystemPrompt?: boolean; disabledAgentResources?: AgentResourceExclusions }
20
21
  export interface AgentProviderFailure { label: string; provider: string; model: string; error: string }
21
22
  export type AgentProviderRecovery = "retry" | "abort" | { model: string };
22
23
  export interface AgentExecutionOptions {
@@ -49,6 +50,7 @@ export interface AgentExecutionRoot {
49
50
  tools: ReadonlySet<string>;
50
51
  agentDefinitions?: Readonly<Record<string, AgentDefinition>>;
51
52
  agentDir?: string;
53
+ additionalSkillPaths?: readonly string[];
52
54
  availableModels?: ReadonlySet<string>;
53
55
  knownModels?: ReadonlySet<string>;
54
56
  modelAliases?: Readonly<Record<string, string>>;
@@ -64,7 +66,7 @@ export interface AgentExecutionRoot {
64
66
  export interface AgentAccounting { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
65
67
  export interface AgentToolCallProgress { id: string; name: string; state: "running" | "completed" | "failed" }
66
68
  export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: string }
67
- export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
69
+ export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; lastEventAt?: number; persist: boolean }
68
70
  export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup?: AgentSetupSummary }
69
71
  export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
70
72
 
@@ -106,14 +108,41 @@ function terminalProviderError(error: WorkflowError): TerminalProviderError | un
106
108
  const candidate = value as Partial<TerminalProviderError>;
107
109
  return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
108
110
  }
111
+ type ProviderRecoveryMarker = { providerRecoveryHandled?: boolean; providerRecovery?: AgentProviderRecovery; providerRecoveryFailed?: boolean };
112
+ const providerContinuationPrompt = "The provider error was transient. Continue the task from your current state.";
113
+ async function recoverTerminalProviderError(session: NativeSession, fallbackModel: ModelSpec, label: string, recovery: AgentExecutionOptions["providerErrorRecovery"], continuePrompt: () => Promise<void>): Promise<boolean> {
114
+ let continued = false;
115
+ for (;;) {
116
+ try { throwIfTerminalAssistantError(session, fallbackModel); return continued; }
117
+ catch (error) {
118
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("AGENT_FAILED", error instanceof Error ? error.message : String(error));
119
+ const terminal = terminalProviderError(typed);
120
+ if (!terminal || !recovery) throw error;
121
+ let action: AgentProviderRecovery;
122
+ try { action = await recovery({ label, ...terminal }); } catch { Object.assign(typed, { providerRecoveryHandled: true, providerRecoveryFailed: true }); throw typed; }
123
+ if (action === "retry") { continued = true; await continuePrompt(); continue; }
124
+ Object.assign(typed, { providerRecoveryHandled: true, providerRecovery: action });
125
+ throw typed;
126
+ }
127
+ }
128
+ }
109
129
 
110
130
  function accounting(stats: ReturnType<NativeSession["getSessionStats"]>): AgentAccounting {
111
131
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
112
132
  }
113
133
  function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
134
+ const WORKFLOW_DIRECTORY = "pi-extensible-workflows";
135
+ function workflowSystemPromptPath(cwd: string, agentDir: string, projectTrusted: boolean): string | undefined {
136
+ const projectPath = join(cwd, ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md");
137
+ if (projectTrusted && existsSync(projectPath)) return projectPath;
138
+ const globalPaths = [join(homedir(), ".pi", WORKFLOW_DIRECTORY, "SYSTEM.md"), join(agentDir, WORKFLOW_DIRECTORY, "SYSTEM.md")];
139
+ return globalPaths.find((path) => existsSync(path));
140
+ }
114
141
 
115
142
  export async function createNativeAgentSession(input: SessionInput): Promise<NativeSession> {
116
143
  const agentDir = input.agentDir ?? getAgentDir();
144
+ const systemPromptSource = workflowSystemPromptPath(input.cwd, agentDir, input.resourcePolicy?.projectTrusted ?? true);
145
+ const systemPromptOptions = input.systemPrompt !== undefined ? { systemPromptOverride: () => input.systemPrompt } : systemPromptSource !== undefined ? { systemPrompt: systemPromptSource } : {};
117
146
  const manager = input.agentDir ? SessionManager.create(input.cwd, join(agentDir, "sessions")) : SessionManager.create(input.cwd);
118
147
  manager.appendSessionInfo(input.sessionLabel);
119
148
  const modelRuntime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
@@ -147,17 +176,18 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
147
176
  noExtensions: true,
148
177
  additionalExtensionPaths: extensionPaths,
149
178
  noSkills: true,
150
- additionalSkillPaths: skillPaths,
179
+ additionalSkillPaths: [...new Set([...skillPaths, ...(input.additionalSkillPaths ?? [])])],
151
180
  ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
152
181
  skillsOverride: (base) => {
153
182
  const disabledSkills = updateSkillMatches(base.skills);
154
183
  return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
155
184
  },
156
185
  ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
186
+ ...systemPromptOptions,
157
187
  });
158
188
  await resourceLoader.reload();
159
- } else if (input.systemPromptAppend || input.extensionFactories?.length) {
160
- resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
189
+ } else if (input.systemPrompt !== undefined || systemPromptSource !== undefined || input.systemPromptAppend || input.extensionFactories?.length || input.additionalSkillPaths?.length) {
190
+ resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.additionalSkillPaths?.length ? { additionalSkillPaths: [...input.additionalSkillPaths] } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...systemPromptOptions, ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
161
191
  await resourceLoader.reload();
162
192
  }
163
193
  const { session } = await createAgentSession({ ...(input.options ?? {}), cwd: input.cwd, agentDir, modelRuntime, model, ...(settingsManager ? { settingsManager } : {}), ...(input.model.thinking ? { thinkingLevel: input.model.thinking } : {}), tools, ...(customTools.length ? { customTools } : {}), ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(resourceLoader ? { resourceLoader } : {}), sessionManager: manager });
@@ -199,13 +229,13 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
199
229
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
200
230
  return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
201
231
  }
202
- async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
232
+ async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
203
233
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
204
234
  const baselineOptions = structuredClone(options.agentOptions ?? {});
205
235
  const baseResourcePolicy = await root.agentResourcePolicy?.();
206
236
  const roleExclusions = options.role ? root.agentDefinitions?.[options.role]?.disabledAgentResources : undefined;
207
237
  const resourcePolicy = baseResourcePolicy && roleExclusions ? { ...baseResourcePolicy, effective: mergeAgentResourceExclusions(baseResourcePolicy.effective, roleExclusions) } : baseResourcePolicy;
208
- const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
238
+ const sessionInput: SessionInput = { cwd, model: { ...resolved.model }, tools: [...resolved.tools], sessionLabel: `${options.workflowName}:${options.label}:attempt-${String(attempt)}`, ...(root.agentDir ? { agentDir: root.agentDir } : {}), ...(root.additionalSkillPaths?.length ? { additionalSkillPaths: [...root.additionalSkillPaths] } : {}), ...(customTools.length ? { customTools: [...customTools] } : {}), ...(resultTool ? { resultTool } : {}), ...(resolved.systemPrompt !== undefined ? { systemPrompt: resolved.systemPrompt } : {}), systemPromptAppend: resolved.systemPromptAppend, ...(resourcePolicy ? { resourcePolicy } : {}), options: structuredClone(baselineOptions) };
209
239
  const setup: AgentSetup = { prompt: task, options: sessionInput.options ?? {}, sessionInput, createSession };
210
240
  const base = fallbackSetupContext(root, options, setupSignal);
211
241
  const context = Object.freeze({ run: base.run, identity: base.identity, attempt, signal: setupSignal });
@@ -230,7 +260,7 @@ export class WorkflowAgentExecutor {
230
260
  constructor(private readonly root: AgentExecutionRoot, private readonly createSession: SessionFactory = createNativeAgentSession) {}
231
261
  setRunContext(runContext: Readonly<WorkflowRunContext>): void { this.root.runContext = runContext; }
232
262
 
233
- resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPromptAppend: string } {
263
+ resolve(options: AgentExecutionOptions, inheritedTools?: readonly string[]): { model: ModelSpec; requestedModel?: string; tools: readonly string[]; systemPrompt?: string; systemPromptAppend: string } {
234
264
  const role = options.role;
235
265
  const definition = role ? this.root.agentDefinitions?.[role] : undefined;
236
266
  if (role && !definition) throw new WorkflowError("UNKNOWN_AGENT_TYPE", `Unknown agent role: ${role}`);
@@ -246,7 +276,8 @@ export class WorkflowAgentExecutor {
246
276
  const model = options.modelOverride ?? parseModel(requestedModel, this.root.model, options.thinking ?? (aliasThinking === undefined ? definition?.thinking : undefined), this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
247
277
  const availableModels = this.root.knownModels ?? this.root.availableModels ?? new Set([modelCapability(this.root.model)]);
248
278
  if (!availableModels.has(modelCapability(model))) throw new WorkflowError("UNKNOWN_MODEL", `Unknown model${requestedModel ? ` ${requestedModel} resolved to ${modelCapability(model)}` : ""}${this.root.settingsPath ? ` (settings: ${this.root.settingsPath})` : ""}`);
249
- return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], systemPromptAppend: definition?.prompt ?? "" };
279
+ const overrideSystemPrompt = definition?.overrideSystemPrompt === true;
280
+ return { model, ...(alias && requestedModel ? { requestedModel } : {}), tools: [...requested], ...(overrideSystemPrompt ? { systemPrompt: definition.prompt ?? "" } : {}), systemPromptAppend: overrideSystemPrompt ? "" : definition?.prompt ?? "" };
250
281
  }
251
282
 
252
283
  async execute(task: string, options: AgentExecutionOptions, signal?: AbortSignal, customTools: readonly ToolDefinition[] = [], setSteer?: (handler: (message: string) => void | Promise<void>) => void, beforeRetry?: () => void): Promise<AgentExecutionResult> {
@@ -299,6 +330,8 @@ export class WorkflowAgentExecutor {
299
330
  } as ToolDefinition : undefined;
300
331
  const toolCalls = new Map<string, AgentToolCallProgress>();
301
332
  let activity: AgentActivity | undefined;
333
+ let lastEventAt: number | undefined;
334
+ let lastReportedEventAt: number | undefined;
302
335
  let progress = Promise.resolve();
303
336
  let unsubscribe: (() => void) | undefined;
304
337
  let systemPromptTurn = 0;
@@ -310,9 +343,15 @@ export class WorkflowAgentExecutor {
310
343
  };
311
344
  const report = (persist: boolean) => {
312
345
  if (!session || !options.onProgress) return;
313
- const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), persist };
346
+ const update = { accounting: accounting(session.getSessionStats()), toolCalls: [...toolCalls.values()], ...(activity ? { activity } : {}), ...(lastEventAt === undefined ? {} : { lastEventAt }), persist };
347
+ if (lastEventAt !== undefined) lastReportedEventAt = lastEventAt;
314
348
  progress = progress.then(() => options.onProgress?.(update)).then(() => undefined);
315
349
  };
350
+ const reportTimestamp = () => {
351
+ if (lastEventAt !== undefined && lastReportedEventAt !== undefined && lastEventAt - lastReportedEventAt < 1000) return;
352
+ report(false);
353
+ };
354
+ const activityChanged = (previous: AgentActivity | undefined) => previous?.kind !== activity?.kind || previous?.text !== activity?.text;
316
355
  try {
317
356
  setupFailed = true;
318
357
  const prepared = await prepareAgentSetup(this.root, this.createSession, task, options, resolved, cwd, attempt, executionSignal, customTools, resultTool);
@@ -326,7 +365,20 @@ export class WorkflowAgentExecutor {
326
365
  const includeAttemptSetup = Boolean(this.root.agentSetupHooks?.length || setup.sessionInput.resourcePolicy);
327
366
  await options.onAttempt?.({ attempt, sessionId: session.sessionId, sessionFile: requiredFile(session.sessionFile), ...(includeAttemptSetup ? { setup: setupSummary } : {}) });
328
367
  const activeSession = session;
368
+ const sessionModel = setup.sessionInput.model;
369
+ const recoverTerminal = () => recoverTerminalProviderError(activeSession, sessionModel, options.label, options.providerErrorRecovery, async () => { try { await promptWithProviderPause(activeSession, providerContinuationPrompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; } });
370
+ const promptAndRecover = async (prompt: string): Promise<void> => {
371
+ let promptFailed = false;
372
+ let promptError: unknown;
373
+ try { await promptWithProviderPause(activeSession, prompt, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { promptFailed = true; promptError = error; }
374
+ const recovered = await recoverTerminal();
375
+ if (promptFailed && !hasSchemaResult() && !recovered) throw promptError;
376
+ };
329
377
  unsubscribe = activeSession.subscribe?.((event) => {
378
+ lastEventAt = Date.now();
379
+ let persist = false;
380
+ let shouldReport = false;
381
+ let removeToolCallId: string | undefined;
330
382
  if (event.type === "agent_start" && session?.systemPrompt !== undefined) {
331
383
  if (this.root.runStore) {
332
384
  systemPromptTurn += 1;
@@ -336,20 +388,32 @@ export class WorkflowAgentExecutor {
336
388
  }
337
389
  if (event.type === "message_start" && event.message.role === "assistant") {
338
390
  if (!turnStarted) { try { options.budget?.beforeTurn(); turnStarted = true; } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
339
- activity = { kind: "text", text: "responding" }; report(false);
391
+ activity = { kind: "text", text: "responding" };
392
+ shouldReport = true;
393
+ }
394
+ if (event.type === "message_update") {
395
+ const previousActivity = activity;
396
+ if (["thinking_start", "thinking_delta", "thinking_end"].includes(event.assistantMessageEvent.type)) activity = { kind: "reasoning", text: "reasoning" };
397
+ else if (["text_start", "text_delta", "text_end", "toolcall_start", "toolcall_delta", "toolcall_end"].includes(event.assistantMessageEvent.type)) activity = { kind: "text", text: "responding" };
398
+ shouldReport = activityChanged(previousActivity);
340
399
  }
341
400
  if (event.type === "message_end") {
401
+ const previousActivity = activity;
342
402
  activity = undefined;
403
+ shouldReport = activityChanged(previousActivity);
343
404
  if (event.message.role === "assistant") {
344
405
  const needsMoreWork = hasToolCall(event.message);
345
406
  const final = !needsMoreWork || (options.schema !== undefined && hasSchemaResult());
346
407
  if (!budgetError) { try { options.budget?.afterTurn(accounting(activeSession.getSessionStats()), final); if (!final) { const instruction = options.budget?.instruction(); if (instruction) void session?.steer?.(instruction); } } catch (error) { budgetError ??= error instanceof WorkflowError ? error : new WorkflowError("BUDGET_EXHAUSTED", error instanceof Error ? error.message : String(error)); void session?.abort?.(); } }
347
408
  turnStarted = false;
348
- report(true);
409
+ persist = true;
349
410
  }
350
411
  }
351
- if (event.type === "tool_execution_start") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; report(false); }
352
- if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; report(false); toolCalls.delete(event.toolCallId); }
412
+ if (event.type === "tool_execution_start") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: "running" }); activity = { kind: "tool", text: event.toolName }; shouldReport = true; }
413
+ if (event.type === "tool_execution_update") { const previousActivity = activity; activity = { kind: "tool", text: event.toolName }; shouldReport = activityChanged(previousActivity); }
414
+ if (event.type === "tool_execution_end") { toolCalls.set(event.toolCallId, { id: event.toolCallId, name: event.toolName, state: event.isError ? "failed" : "completed" }); if (activity?.kind === "tool" && activity.text === event.toolName) activity = undefined; shouldReport = true; removeToolCallId = event.toolCallId; }
415
+ if (shouldReport || persist) report(persist); else reportTimestamp();
416
+ if (removeToolCallId) toolCalls.delete(removeToolCallId);
353
417
  });
354
418
  report(false);
355
419
  if (setSteer) {
@@ -361,18 +425,21 @@ export class WorkflowAgentExecutor {
361
425
  const promptText = `${context}\n\nTask:\n${setup.prompt}${instruction ? `\n\n${instruction}` : ""}`;
362
426
  options.budget?.beforeTurn();
363
427
  turnStarted = true;
364
- try { await promptWithProviderPause(session, promptText, remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); } catch (error) { if (!hasSchemaResult()) throw error; }
365
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
428
+ await promptAndRecover(promptText);
366
429
  { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, options.schema !== undefined ? hasSchemaResult() : !latestAssistantHasToolCall(session.messages)); turnStarted = false; }
367
430
  if (budgetError) throw budgetError;
368
431
  if (options.schema) {
369
432
  if (!hasSchemaResult()) {
370
- try { options.budget?.beforeTurn(); turnStarted = true; await promptWithProviderPause(session, "Submit the final result now by calling workflow_result exactly once. Do not return prose.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
433
+ options.budget?.beforeTurn();
434
+ turnStarted = true;
435
+ await promptAndRecover("Submit the final result now by calling workflow_result exactly once. Do not return prose.");
436
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; }
371
437
  }
372
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
373
438
  if (!hasSchemaResult()) {
374
- try { options.budget?.beforeTurn(); turnStarted = true; await promptWithProviderPause(session, "Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.", remaining(options.timeoutMs, started), executionSignal, this.root.providerPause); { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; } } catch (error) { if (!hasSchemaResult()) throw error; }
375
- throwIfTerminalAssistantError(session, setup.sessionInput.model);
439
+ options.budget?.beforeTurn();
440
+ turnStarted = true;
441
+ await promptAndRecover("Your result was missing or invalid. Repair it by calling workflow_result exactly once with a schema-valid value.");
442
+ { const completedAccounting = accounting(session.getSessionStats()); options.budget?.afterTurn(completedAccounting, true); turnStarted = false; }
376
443
  }
377
444
  if (schemaResult === undefined) throw new WorkflowError("RESULT_INVALID", "Agent did not submit a valid workflow_result after one repair");
378
445
  }
@@ -402,21 +469,26 @@ export class WorkflowAgentExecutor {
402
469
  }
403
470
  if (options.worktreeOwner && typed.code !== "WORKTREE_FAILED") await this.root.runStore?.snapshotWorktree(options.worktreeOwner).catch(() => undefined);
404
471
  const terminal = terminalProviderError(typed);
405
- if (terminal && options.providerErrorRecovery) {
406
- let recovery: AgentProviderRecovery;
472
+ const recoveryState = typed as WorkflowError & ProviderRecoveryMarker;
473
+ let recovery = recoveryState.providerRecovery;
474
+ if (terminal && options.providerErrorRecovery && !recoveryState.providerRecoveryHandled) {
407
475
  try { recovery = await options.providerErrorRecovery({ label: options.label, ...terminal }); } catch { throw Object.assign(typed, { attempts }); }
408
- if (recovery === "retry" || typeof recovery === "object" && typeof recovery.model === "string") {
409
- if (typeof recovery === "object") {
410
- try {
411
- const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
412
- recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
413
- } catch { throw Object.assign(typed, { attempts }); }
414
- }
476
+ if (recovery === "retry") {
415
477
  maxAttempts += 1;
416
478
  beforeRetry?.();
417
479
  continue;
418
480
  }
419
481
  }
482
+ if (recoveryState.providerRecoveryFailed || recovery === "abort") throw Object.assign(typed, { attempts });
483
+ if (typeof recovery === "object" && typeof recovery.model === "string") {
484
+ try {
485
+ const selected = resolveModelReference(recovery.model, this.root.modelAliases, this.root.knownModels ?? this.root.availableModels, this.root.settingsPath);
486
+ recoveryModel = selected.thinking === undefined && resolved.model.thinking ? { ...selected, thinking: resolved.model.thinking } : selected;
487
+ } catch { throw Object.assign(typed, { attempts }); }
488
+ maxAttempts += 1;
489
+ beforeRetry?.();
490
+ continue;
491
+ }
420
492
  if (attempt === maxAttempts || setupFailed || typed.code === "CANCELLED" || typed.code === "WORKTREE_FAILED" || typed.code === "RESUME_INCOMPATIBLE") throw Object.assign(typed, { attempts });
421
493
  beforeRetry?.();
422
494
  }