pi-extensible-workflows 1.0.0 → 1.0.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.
@@ -120,6 +120,43 @@ export async function createNativeAgentSession(input) {
120
120
  }
121
121
  function changedOption(options, baseline, key) { return JSON.stringify(options[key]) !== JSON.stringify(baseline[key]); }
122
122
  function validThinking(value) { return typeof value === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value); }
123
+ function jsonValue(value, seen = new Set()) {
124
+ if (value === null || typeof value === "boolean" || typeof value === "string")
125
+ return true;
126
+ if (typeof value === "number")
127
+ return Number.isFinite(value);
128
+ if (typeof value !== "object" || seen.has(value))
129
+ return false;
130
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
131
+ return false;
132
+ const keys = Reflect.ownKeys(value);
133
+ if (keys.some((key) => typeof key !== "string"))
134
+ return false;
135
+ seen.add(value);
136
+ const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => value[key])).every((item) => jsonValue(item, seen));
137
+ seen.delete(value);
138
+ return valid;
139
+ }
140
+ function jsonObject(value) { return jsonValue(value) && typeof value === "object" && value !== null && !Array.isArray(value); }
141
+ function isChildAgentToolParams(value) {
142
+ if (!jsonObject(value) || typeof value.prompt !== "string" || typeof value.label !== "string")
143
+ return false;
144
+ if (value.tools !== undefined && (!Array.isArray(value.tools) || value.tools.some((tool) => typeof tool !== "string")))
145
+ return false;
146
+ if (value.model !== undefined && typeof value.model !== "string")
147
+ return false;
148
+ if (value.thinking !== undefined && !validThinking(value.thinking))
149
+ return false;
150
+ if (value.role !== undefined && typeof value.role !== "string")
151
+ return false;
152
+ if (value.outputSchema !== undefined && !jsonObject(value.outputSchema))
153
+ return false;
154
+ if (value.retries !== undefined && (typeof value.retries !== "number" || !Number.isInteger(value.retries) || value.retries < 0))
155
+ return false;
156
+ if (value.timeoutMs !== undefined && (value.timeoutMs !== null && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1)))
157
+ return false;
158
+ return true;
159
+ }
123
160
  function fallbackSetupContext(root, options, signal) {
124
161
  const identity = options.agentIdentity ?? { structuralPath: [], callSite: options.label, occurrence: 1 };
125
162
  const run = root.runContext ?? Object.freeze({ cwd: root.cwd, sessionId: "", runId: "", workflow: Object.freeze({ name: options.workflowName }), args: null, signal });
@@ -686,7 +723,6 @@ export class FairAgentScheduler {
686
723
  if (nodes.every(({ restored }) => restored))
687
724
  run.logical = 0;
688
725
  }
689
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
690
726
  toolsFor(parentId, resolveTools) {
691
727
  const parent = this.#node(parentId);
692
728
  if (!parent.options.tools.includes("agent"))
@@ -694,11 +730,15 @@ export class FairAgentScheduler {
694
730
  const agentTool = {
695
731
  name: "agent", label: "Child Agent", description: "Start a direct child agent",
696
732
  parameters: Type.Object({ prompt: Type.String(), label: Type.String(), tools: Type.Optional(Type.Array(Type.String())), model: Type.Optional(Type.String()), thinking: Type.Optional(Type.String()), role: Type.Optional(Type.String()), outputSchema: Type.Optional(Type.Unsafe({})), retries: Type.Optional(Type.Integer({ minimum: 0 })), timeoutMs: Type.Optional(Type.Union([Type.Integer({ minimum: 1 }), Type.Null()])) }, { additionalProperties: true }),
697
- execute: async (_id, params) => {
733
+ execute: async (_id, rawParams) => {
734
+ if (!isChildAgentToolParams(rawParams))
735
+ throw new WorkflowError("INVALID_METADATA", "Invalid child agent parameters");
736
+ const params = rawParams;
698
737
  if (params.role !== undefined && (params.model !== undefined || params.thinking !== undefined || params.tools !== undefined))
699
738
  throw new WorkflowError("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
700
739
  const tools = (params.tools !== undefined || params.role !== undefined ? resolveTools?.(params.role, params.tools, params.model, parent.options.tools, params.thinking) : undefined) ?? params.tools ?? parent.options.tools;
701
- const agentOptions = Object.fromEntries(Object.entries(params).filter(([key]) => key !== "prompt"));
740
+ const agentOptions = { ...params };
741
+ Reflect.deleteProperty(agentOptions, "prompt");
702
742
  const options = { label: params.label, requestedLabel: params.label, cwd: parent.options.cwd, tools, agentOptions, ...(params.model ? { model: params.model } : {}), ...(params.thinking ? { thinking: params.thinking } : {}), ...(params.role ? { role: params.role } : {}), ...(params.outputSchema ? { schema: params.outputSchema } : {}), ...(params.retries === undefined ? {} : { retries: params.retries }), ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }) };
703
743
  const child = this.spawn(parent.runId, params.prompt, options, parentId);
704
744
  return { content: [{ type: "text", text: JSON.stringify({ id: child.id }) }], details: { id: child.id } };
@@ -745,17 +785,17 @@ export class FairAgentScheduler {
745
785
  #inherit(parent, options) {
746
786
  if (!options.label.trim() || !options.cwd || !Array.isArray(options.tools))
747
787
  throw new WorkflowError("INVALID_METADATA", "Agents require label, cwd, and tools");
788
+ const inheritedTools = options.tools;
748
789
  if (!parent)
749
- return Object.freeze({ ...options, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
790
+ return Object.freeze({ ...options, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(options.agentIdentity ? { agentIdentity: Object.freeze({ ...options.agentIdentity, structuralPath: Object.freeze([...options.agentIdentity.structuralPath]) }) } : {}) });
750
791
  if (options.cwd !== parent.options.cwd)
751
792
  throw new WorkflowError("UNKNOWN_TOOL", "Child cwd cannot differ from its parent");
752
- const forbidden = options.tools.find((tool) => !parent.options.tools.includes(tool));
793
+ const forbidden = inheritedTools.find((tool) => !parent.options.tools.includes(tool));
753
794
  if (forbidden)
754
795
  throw new WorkflowError("UNKNOWN_TOOL", `Child tool escalates parent boundary: ${forbidden}`);
755
796
  const identity = options.agentIdentity ?? parent.options.agentIdentity;
756
- return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...options.tools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
797
+ return Object.freeze({ ...options, cwd: parent.options.cwd, tools: Object.freeze([...inheritedTools]), ...(options.agentOptions ? { agentOptions: structuredClone(options.agentOptions) } : {}), ...(parent.options.parentBreadcrumb && !options.parentBreadcrumb ? { parentBreadcrumb: parent.options.parentBreadcrumb } : {}), ...(identity ? { agentIdentity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) } : {}), ...(parent.options.worktreeOwner ? { worktreeOwner: parent.options.worktreeOwner } : {}) });
757
798
  }
758
- /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/restrict-template-expressions */
759
799
  #enqueue(runId, node, start) { this.#runs.get(runId)?.queue.push({ ...(node ? { node } : {}), start }); this.#dispatch(); }
760
800
  #dispatch() {
761
801
  while (this.#active < this.sessionLimit && this.#runOrder.length) {
@@ -1,12 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { join, relative } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { CAPTURE_IDENTITY } from "./eval-capture-extension.js";
9
- import { extractCapturedWorkflows, extractParentOracleFile } from "./workflow-evals.js";
9
+ import { isObject } from "./index.js";
10
+ import { extractCapturedWorkflows, extractParentOracleFile, findSessionFile } from "./workflow-evals.js";
10
11
  export const AMBIENT_OPT_IN = "PI_WORKFLOW_EVAL_AMBIENT";
11
12
  export const AMBIENT_CAPTURE_NOTE = "Ambient Tier D uses the explicit capture extension. Pi 0.80.6 orders CLI extensions before discovered extensions and the first tool registration wins, so ambient tools and skills remain available while workflow execution stays capture-only.";
12
13
  export const AMBIENT_INVOCATION_MODE = "ambient-capture-only";
@@ -234,34 +235,12 @@ function emptyAccounting() {
234
235
  function ambientAgentDir(environment) {
235
236
  return environment.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
236
237
  }
237
- function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
238
- function findSessionFile(directory, sessionId) {
239
- if (!existsSync(directory))
240
- return undefined;
241
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
242
- const path = join(directory, entry.name);
243
- if (entry.isDirectory()) {
244
- const found = findSessionFile(path, sessionId);
245
- if (found)
246
- return found;
247
- }
248
- else if (entry.name.endsWith(".jsonl")) {
249
- try {
250
- const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
251
- if (isObject(header) && header.id === sessionId)
252
- return path;
253
- }
254
- catch { /* Ignore incomplete sessions. */ }
255
- }
256
- }
257
- return undefined;
258
- }
259
238
  function emptyResult(candidate, repository, worktree, environment, error) {
260
239
  const accounting = emptyAccounting();
261
240
  return {
262
241
  id: candidate.id, status: "failed", workflows: [], accounting, accountingTrustworthy: false, diagnostics: [], errors: [error],
263
242
  manifest: {
264
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
243
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
265
244
  cleanup: { processExited: false, processGroupTerminated: false, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: 0 },
266
245
  },
267
246
  };
@@ -271,12 +250,13 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
271
250
  const sessionId = randomUUID();
272
251
  const sessionDir = join(repository.root, "sessions", candidate.id);
273
252
  let result;
274
- let gitStatusAfter = worktree.gitStatusBefore;
253
+ let failure;
254
+ let gitStatusAfter;
275
255
  try {
276
256
  const pi = await runAmbientPiProcess({ worktree: worktree.path, sessionDir, sessionId, prompt: candidate.prompt, provider, model, ...(thinking ? { thinking } : {}), ...(piCommand ? { piCommand } : {}), timeoutMs: candidate.timeoutMs, maxCost: candidate.maxCost, environment });
277
257
  const sessionFile = findSessionFile(sessionDir, sessionId);
278
258
  if (!sessionFile)
279
- result = emptyResult(candidate, repository, worktree, environment, "Ambient parent session was not written.");
259
+ failure = "Ambient parent session was not written.";
280
260
  else {
281
261
  const oracle = extractParentOracleFile(sessionFile);
282
262
  const workflows = extractCapturedWorkflows(oracle);
@@ -286,14 +266,14 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
286
266
  result = {
287
267
  id: candidate.id, status, workflows, accounting: oracle.usage, accountingTrustworthy: !pi.timedOut && !pi.budgetExceeded && pi.exitCode === 0, diagnostics: [pi.stderr].filter(Boolean), errors,
288
268
  manifest: {
289
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
269
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
290
270
  cleanup: { processExited: pi.exitCode !== null, processGroupTerminated: pi.processGroupTerminated, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified, realWorkflowAgentsLaunched: 0 },
291
271
  },
292
272
  };
293
273
  }
294
274
  }
295
275
  catch (error) {
296
- result = emptyResult(candidate, repository, worktree, environment, error instanceof Error ? error.message : String(error));
276
+ failure = error instanceof Error ? error.message : String(error);
297
277
  }
298
278
  finally {
299
279
  try {
@@ -304,12 +284,13 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
304
284
  }
305
285
  const worktreeRemoved = removeAmbientCaseWorktree(repository, worktree);
306
286
  if (!result)
307
- result = emptyResult(candidate, repository, worktree, environment, "Ambient case produced no result.");
287
+ result = emptyResult(candidate, repository, worktree, environment, failure ?? "Ambient case produced no result.");
308
288
  const cleanup = { ...result.manifest.cleanup, worktreeRemoved };
309
- result = { ...result, manifest: { ...result.manifest, gitStatusAfter, cleanup } };
310
- writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
289
+ result = { ...result, manifest: { ...result.manifest, cleanup } };
311
290
  }
312
- return result;
291
+ const finalized = { ...result, manifest: { ...result.manifest, gitStatusAfter } };
292
+ writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(finalized, null, 2)}\n`, { mode: 0o600 });
293
+ return finalized;
313
294
  }
314
295
  export function assertAmbientOptIn(environment = process.env) {
315
296
  if (environment[AMBIENT_OPT_IN] !== "1")
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { basename, dirname, extname, join, resolve } from "node:path";
3
3
  import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
4
4
  import { ModelRuntime, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
- import { DEFAULT_SETTINGS, loadSettings, parseModelReference, resolveAgentResourcePolicy, resolveModelReference, parseRoleMarkdown, preflight, registeredWorkflowDefinitions, workflowRoleDirectories, workflowProjectSettingsPath, workflowSettingsPath, WorkflowError, } from "./index.js";
5
+ import { DEFAULT_SETTINGS, loadSettings, resolveAgentResourcePolicy, resolveModelReference, parseRoleMarkdown, preflight, registeredWorkflowDefinitions, workflowRoleDirectories, workflowProjectSettingsPath, workflowSettingsPath, WorkflowError, } from "./index.js";
6
6
  const THINKING_HINT = "Use off, minimal, low, medium, high, xhigh, or max.";
7
7
  function canonical(path) {
8
8
  const absolute = resolve(path);
@@ -153,9 +153,6 @@ function inspectRole(path, activeTools, knownModels, availableModels, diagnostic
153
153
  diagnostics.push(diagnostic("error", "ROLE_TOOL_INACTIVE", `Tool is unknown or inactive: ${tool}`, path, "Use a tool listed under Active tools or enable its Pi extension."));
154
154
  return definition;
155
155
  }
156
- class DoctorModelSet extends Set {
157
- has(value) { parseModelReference(value); return true; }
158
- }
159
156
  function matchResourcePolicy(policy, pi) {
160
157
  const extensions = new Set((pi.extensions ?? []).map(canonical));
161
158
  const skills = new Set(pi.skills ?? []);
@@ -245,12 +242,13 @@ export async function doctor(options = {}) {
245
242
  let valid = true;
246
243
  try {
247
244
  const checked = preflight(workflow.script, {
248
- models: new DoctorModelSet(pi.knownModels),
245
+ models: new Set(pi.knownModels),
249
246
  tools: activeTools,
250
247
  agentTypes: new Set(definitions.keys()),
251
248
  modelAliases: aliases,
252
249
  knownModels,
253
250
  settingsPath,
251
+ skipModelAvailability: true,
254
252
  }, [], { name, description: workflow.description });
255
253
  for (const model of checked.referenced.models)
256
254
  if (!knownModels.has(model) || !availableModels.has(model))
@@ -5,8 +5,8 @@ import { RunStore } from "./persistence.js";
5
5
  import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
6
6
  export declare const RUN_STATES: readonly ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"];
7
7
  export declare const AGENT_STATES: readonly ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"];
8
- export declare const WORKFLOW_ASYNC_STARTED_EVENT = "workflow:async-started";
9
- export declare const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
8
+ export declare const WORKFLOW_CALL_KINDS: readonly ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"];
9
+ export type WorkflowCallKind = (typeof WORKFLOW_CALL_KINDS)[number];
10
10
  export declare const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
11
11
  export declare const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
12
12
  export declare const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
@@ -215,7 +215,7 @@ export interface AgentRecord {
215
215
  activity?: AgentActivity | undefined;
216
216
  }
217
217
  export interface WorkflowRunEvent {
218
- type: "warning";
218
+ type: string;
219
219
  message: string;
220
220
  }
221
221
  export interface RunRecord {
@@ -290,6 +290,7 @@ export interface WorkflowRunContext {
290
290
  }
291
291
  export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
292
292
  run: Readonly<WorkflowRunContext>;
293
+ invoke: (name: string, input: Readonly<Record<string, JsonValue>>) => Promise<JsonValue>;
293
294
  }
294
295
  export interface WorkflowFunction {
295
296
  description: string;
@@ -339,6 +340,8 @@ export declare class RunLifecycle {
339
340
  terminal(state: "completed" | "failed" | "stopped" | "interrupted" | "budget_exhausted", reason?: string): Promise<void>;
340
341
  }
341
342
  export declare const DEFAULT_SETTINGS: Readonly<WorkflowSettings>;
343
+ declare function object(value: unknown): value is Record<string, unknown>;
344
+ export { object as isObject };
342
345
  export declare function validateBudget(value: unknown): WorkflowBudget | undefined;
343
346
  export declare function validateBudgetPatch(value: unknown): WorkflowBudgetPatch;
344
347
  export declare class WorkflowBudgetRuntime {
@@ -395,7 +398,7 @@ export interface StaticWorkflowScope {
395
398
  key: string | null;
396
399
  }
397
400
  export interface StaticWorkflowCall {
398
- kind: "agent" | "conversation" | "parallel" | "pipeline" | "checkpoint" | "phase" | "withWorktree";
401
+ kind: WorkflowCallKind;
399
402
  start: number;
400
403
  end: number;
401
404
  name: string | null;