pi-workflow-engine 1.0.0 → 1.2.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.
- package/.pi/extensions/pi-workflow-engine/index.ts +236 -94
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +9 -6
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +71 -23
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +12 -3
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
- package/.pi/extensions/pi-workflow-engine/src/classify.ts +107 -0
- package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
- package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -40
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +78 -18
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
- package/.pi/extensions/pi-workflow-engine/src/host-tools.ts +76 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
- package/.pi/extensions/pi-workflow-engine/src/model-ref.ts +19 -0
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +2 -2
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
- package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +8 -6
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +15 -3
- package/.pi/extensions/pi-workflow-engine/src/types.ts +68 -9
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +20 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +123 -9
- package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
- package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
- package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
- package/README.md +12 -4
- package/USAGE.md +167 -23
- package/package.json +13 -4
- package/skills/workflow-authoring/SKILL.md +160 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
1
|
+
import type { Api, ClassifierApi, ClassifierModel, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import type {
|
|
3
3
|
AgentSession,
|
|
4
4
|
CreateAgentSessionOptions,
|
|
@@ -8,6 +8,7 @@ import type { WorkflowBudget } from "./budget.ts";
|
|
|
8
8
|
import type { Semaphore } from "./concurrency.ts";
|
|
9
9
|
import type { WorkflowAgentLimiter } from "./agent-limits.ts";
|
|
10
10
|
import type { AgentRetryScheduler } from "./agent-retry.ts";
|
|
11
|
+
import type { HostToolBridge } from "./host-tools.ts";
|
|
11
12
|
import type { ResolvedWorkflowModelProfiles } from "./model-profiles.ts";
|
|
12
13
|
import type { WorkflowJournal } from "./journal.ts";
|
|
13
14
|
import type { PerfSink } from "./perf.ts";
|
|
@@ -37,10 +38,12 @@ export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise
|
|
|
37
38
|
/** Every row update after `agentQueued` addresses that row by its id; labels are display text, not unique keys. */
|
|
38
39
|
export interface AgentProgress {
|
|
39
40
|
agentQueued(phase: string | undefined, label: string): number;
|
|
40
|
-
|
|
41
|
+
/** `skip` aborts the row's live agent; the tracker offers it to the inspector only while the row runs. */
|
|
42
|
+
agentStart(id: number, skip?: () => void): void;
|
|
41
43
|
agentTool(id: number, tool: string): void;
|
|
42
44
|
agentDone(id: number): void;
|
|
43
45
|
agentFailed(id: number, error: unknown): void;
|
|
46
|
+
agentSkipped(id: number): void;
|
|
44
47
|
event(event: WorkflowProgressEvent): void;
|
|
45
48
|
log(message: string): void;
|
|
46
49
|
}
|
|
@@ -63,6 +66,10 @@ interface RunContextBase {
|
|
|
63
66
|
budget: WorkflowBudget;
|
|
64
67
|
journal: WorkflowJournal;
|
|
65
68
|
worktrees: WorktreeRegistry;
|
|
69
|
+
/** Host MCP tools subagents may call; set only for a synchronous `workflow` tool run. */
|
|
70
|
+
hostTools?: HostToolBridge;
|
|
71
|
+
/** Classifier routes resolved this run, so repeated `api.classify()` calls skip the credential lookup. */
|
|
72
|
+
classifierRoutes?: Map<string, Promise<ClassifierModel<ClassifierApi>>>;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
/** Shared per-run context threaded into every agent() call. */
|
|
@@ -74,7 +81,7 @@ export type RunContext = RunContextBase & (
|
|
|
74
81
|
}
|
|
75
82
|
| {
|
|
76
83
|
/** Injected test sessions bypass Pi's resource loader and therefore do not resolve skills. */
|
|
77
|
-
modelRegistry: Pick<ModelRegistry, "find">;
|
|
84
|
+
modelRegistry: Pick<ModelRegistry, "find" | "classify" | "getAvailableOfType" | "getModelOfType">;
|
|
78
85
|
createSession: CreateAgentSession;
|
|
79
86
|
}
|
|
80
87
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assertWorkflowBudgetAvailable } from "./budget.ts";
|
|
2
2
|
import { WorkflowAgentTimeoutError } from "./agent-limits.ts";
|
|
3
|
-
import { abortReason, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
|
|
3
|
+
import { abortReason, linkAbortSignal, throwIfAborted, WorkflowAgentSkippedError } from "./cancellation.ts";
|
|
4
4
|
import { executeAgentAttempt } from "./agent-attempt.ts";
|
|
5
5
|
import {
|
|
6
6
|
createAgentReplayPlan,
|
|
@@ -66,7 +66,7 @@ export async function runAgent(
|
|
|
66
66
|
const agentRc: RunContext = { ...rc, signal: liveScope.signal };
|
|
67
67
|
throwIfAborted(agentRc.signal);
|
|
68
68
|
if (!isReplayEnabled(replay)) assertWorkflowBudgetAvailable(agentRc.budget);
|
|
69
|
-
rc.progress.agentStart(rowId);
|
|
69
|
+
rc.progress.agentStart(rowId, liveScope.skip);
|
|
70
70
|
if (replay.kind === "disabled") {
|
|
71
71
|
rc.progress.log(`${label}: resume disabled for this call (${replay.reason})`);
|
|
72
72
|
}
|
|
@@ -107,6 +107,8 @@ export async function runAgent(
|
|
|
107
107
|
await agentRc.retryScheduler.sleep(delayMs, agentRc.signal);
|
|
108
108
|
continue;
|
|
109
109
|
}
|
|
110
|
+
// A skip that lands as the attempt finishes still wins, so its result is neither returned nor journaled.
|
|
111
|
+
liveScope.throwIfSkipped();
|
|
110
112
|
const settlement = await settleAgentAttempt({ rc: agentRc, label, outcome });
|
|
111
113
|
if (settlement.kind === "retry-live") {
|
|
112
114
|
attemptPlan = { kind: "off" };
|
|
@@ -125,8 +127,13 @@ export async function runAgent(
|
|
|
125
127
|
let failure = error;
|
|
126
128
|
if (liveScope.signal.aborted) failure = abortReason(liveScope.signal);
|
|
127
129
|
if (rc.signal?.aborted) failure = abortReason(rc.signal);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
if (failure instanceof WorkflowAgentSkippedError) {
|
|
131
|
+
rc.progress.agentSkipped(rowId);
|
|
132
|
+
rc.progress.log(`${label} skipped`);
|
|
133
|
+
} else {
|
|
134
|
+
rc.progress.agentFailed(rowId, failure);
|
|
135
|
+
rc.progress.log(`${label} failed: ${unknownErrorMessage(failure)}`);
|
|
136
|
+
}
|
|
130
137
|
throw failure;
|
|
131
138
|
} finally {
|
|
132
139
|
liveScope.dispose();
|
|
@@ -151,6 +158,13 @@ function createAgentLiveScope(rc: RunContext, label: string) {
|
|
|
151
158
|
rc.agentTimeoutMs,
|
|
152
159
|
);
|
|
153
160
|
},
|
|
161
|
+
/** Aborts only this agent; the run and its siblings carry on. */
|
|
162
|
+
skip() {
|
|
163
|
+
controller.abort(new WorkflowAgentSkippedError(label));
|
|
164
|
+
},
|
|
165
|
+
throwIfSkipped() {
|
|
166
|
+
if (controller.signal.reason instanceof WorkflowAgentSkippedError) throw controller.signal.reason;
|
|
167
|
+
},
|
|
154
168
|
dispose() {
|
|
155
169
|
if (timer) clearTimeout(timer);
|
|
156
170
|
unlink();
|
|
@@ -2,14 +2,17 @@ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import {
|
|
3
3
|
createAgentSessionFromServices,
|
|
4
4
|
createAgentSessionServices,
|
|
5
|
+
createCodemodeExtension,
|
|
5
6
|
defineTool,
|
|
6
7
|
SessionManager,
|
|
7
8
|
type CreateAgentSessionOptions,
|
|
9
|
+
type CreateAgentSessionServicesOptions,
|
|
8
10
|
type ModelRegistry,
|
|
9
11
|
type Skill,
|
|
10
12
|
type ToolDefinition,
|
|
11
13
|
} from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import { prepareAgentSkillResources } from "./agent-skills.ts";
|
|
15
|
+
import { createCodemodeSlotExtension, runToolCallsSequentially } from "./codemode-slots.ts";
|
|
13
16
|
import type {
|
|
14
17
|
AgentExecutionOptions,
|
|
15
18
|
AgentProgress,
|
|
@@ -23,12 +26,26 @@ import {
|
|
|
23
26
|
} from "./structured-output.ts";
|
|
24
27
|
import { providerErrorFromMessages } from "./agent-retry.ts";
|
|
25
28
|
import { synchronizeWorkflowModelRuntime } from "./agent-session-providers.ts";
|
|
29
|
+
import { hostToolProxy, isMcpToolName, WorkflowHostToolUnavailableError, type HostToolBridge } from "./host-tools.ts";
|
|
26
30
|
import { matchesAgentToolHint, WorkflowToolHintUnavailableError } from "./tool-capabilities.ts";
|
|
31
|
+
import { parseAgentModelRef } from "./model-ref.ts";
|
|
27
32
|
import { truncateText } from "./text.ts";
|
|
28
33
|
import { WORKFLOW_TOOL_NAME, type AgentToolHint } from "./types.ts";
|
|
29
34
|
|
|
30
35
|
export const FINAL_TOOL = "final_answer";
|
|
36
|
+
/** pi's codemode tool; SDK sessions only have it when their resource loader registers the extension. */
|
|
37
|
+
export const CODEMODE_TOOL = "codemode";
|
|
31
38
|
|
|
39
|
+
/** pi's stock prompt frames a chat with a person; a subagent's last message is program data instead. */
|
|
40
|
+
const RETURN_VALUE_FRAMING =
|
|
41
|
+
"Your final message is returned verbatim to the workflow program that started you, as data. " +
|
|
42
|
+
"It is not shown to a person as a chat reply. Make it exactly the output your task asks for, in the format it asks for, " +
|
|
43
|
+
"with no greeting, preamble, narration of your process, or offer of further help.";
|
|
44
|
+
/** A schema agent returns its final_answer arguments, not its last message. */
|
|
45
|
+
const STRUCTURED_RETURN_VALUE_FRAMING =
|
|
46
|
+
`Your ${FINAL_TOOL} arguments are returned to the workflow program that started you, as data. ` +
|
|
47
|
+
"They are not shown to a person as a chat reply. Fill each field with exactly the content it asks for, " +
|
|
48
|
+
"with no greeting, preamble, narration of your process, or offer of further help.";
|
|
32
49
|
const SCHEMA_REPROMPT =
|
|
33
50
|
`You ended your turn without calling the ${FINAL_TOOL} tool, so no result was recorded. ` +
|
|
34
51
|
`Call ${FINAL_TOOL} now with your final answer as its arguments. Do not reply with plain text.`;
|
|
@@ -76,6 +93,8 @@ export async function openAgentSession(input: {
|
|
|
76
93
|
description:
|
|
77
94
|
"Return your final structured answer. This MUST be your last action — do not write a normal reply after calling it.",
|
|
78
95
|
parameters: opts.schema,
|
|
96
|
+
// The agent's own terminal action: a codemode script must not answer on its behalf.
|
|
97
|
+
exposure: "model-only",
|
|
79
98
|
async execute(_toolCallId, params) {
|
|
80
99
|
captured = true;
|
|
81
100
|
structuredResult = params;
|
|
@@ -84,6 +103,7 @@ export async function openAgentSession(input: {
|
|
|
84
103
|
}),
|
|
85
104
|
]
|
|
86
105
|
: [];
|
|
106
|
+
customTools.push(...requestedHostTools(opts, rc.hostTools));
|
|
87
107
|
let session: AgentRunnerSession | undefined;
|
|
88
108
|
try {
|
|
89
109
|
throwIfAborted(rc.signal);
|
|
@@ -105,7 +125,7 @@ export async function openAgentSession(input: {
|
|
|
105
125
|
if (!opts.tools?.includes(WORKFLOW_TOOL_NAME)) withholdWorkflowTool(session);
|
|
106
126
|
if (opts.requireToolHints) {
|
|
107
127
|
const missing = toolSelection.toolHints.filter((hint) => !matchedToolHints.has(hint));
|
|
108
|
-
if (missing.length > 0) throw new WorkflowToolHintUnavailableError(missing);
|
|
128
|
+
if (missing.length > 0) throw new WorkflowToolHintUnavailableError(missing, { hostToolsReachable: rc.hostTools !== undefined });
|
|
109
129
|
}
|
|
110
130
|
throwIfAborted(rc.signal);
|
|
111
131
|
return {
|
|
@@ -200,24 +220,6 @@ export async function promptAgentSession(input: {
|
|
|
200
220
|
}
|
|
201
221
|
}
|
|
202
222
|
|
|
203
|
-
function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } {
|
|
204
|
-
const normalized = modelRef.trim();
|
|
205
|
-
if (normalized.length === 0) {
|
|
206
|
-
throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".');
|
|
207
|
-
}
|
|
208
|
-
if (normalized !== modelRef) {
|
|
209
|
-
throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const slash = modelRef.indexOf("/");
|
|
213
|
-
if (slash === -1) return { provider: "anthropic", id: modelRef };
|
|
214
|
-
const provider = modelRef.slice(0, slash);
|
|
215
|
-
const id = modelRef.slice(slash + 1);
|
|
216
|
-
if (provider.length === 0 || id.length === 0 || id.startsWith("/")) {
|
|
217
|
-
throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`);
|
|
218
|
-
}
|
|
219
|
-
return { provider, id };
|
|
220
|
-
}
|
|
221
223
|
|
|
222
224
|
interface AgentSessionResources {
|
|
223
225
|
readonly selectedSkills: readonly Skill[];
|
|
@@ -243,6 +245,7 @@ async function prepareAgentSessionResources(input: {
|
|
|
243
245
|
sessionManager: SessionManager.inMemory(cwd),
|
|
244
246
|
});
|
|
245
247
|
const createSession = rc.createSession;
|
|
248
|
+
// Injected test sessions build no resource loader, so they get neither skills nor the return-value framing.
|
|
246
249
|
if (createSession) {
|
|
247
250
|
return {
|
|
248
251
|
selectedSkills: [],
|
|
@@ -260,9 +263,15 @@ async function prepareAgentSessionResources(input: {
|
|
|
260
263
|
log: (message: string) => rc.progress.log(`${label}: ${message}`),
|
|
261
264
|
};
|
|
262
265
|
const preparedSkills = prepareAgentSkillResources(skillOptions);
|
|
266
|
+
const codemode = opts.tools?.includes(CODEMODE_TOOL) ?? false;
|
|
263
267
|
const services = await createAgentSessionServices({
|
|
264
268
|
cwd,
|
|
265
|
-
resourceLoaderOptions:
|
|
269
|
+
resourceLoaderOptions: {
|
|
270
|
+
...preparedSkills.resourceLoaderOptions,
|
|
271
|
+
...(codemode ? codemodeExtensions() : {}),
|
|
272
|
+
// An override, not `appendSystemPrompt`: passing that replaces pi's discovery of the user's APPEND_SYSTEM.md.
|
|
273
|
+
appendSystemPromptOverride: (base) => [...base, opts.schema ? STRUCTURED_RETURN_VALUE_FRAMING : RETURN_VALUE_FRAMING],
|
|
274
|
+
},
|
|
266
275
|
});
|
|
267
276
|
await synchronizeWorkflowModelRuntime({
|
|
268
277
|
host: rc.modelRegistry,
|
|
@@ -281,14 +290,53 @@ async function prepareAgentSessionResources(input: {
|
|
|
281
290
|
const selectedSkills = preparedSkills.resolve(services.resourceLoader);
|
|
282
291
|
return {
|
|
283
292
|
selectedSkills,
|
|
284
|
-
createSession: (sessionOptions) =>
|
|
285
|
-
createAgentSessionFromServices({
|
|
293
|
+
createSession: async (sessionOptions) => {
|
|
294
|
+
const created = await createAgentSessionFromServices({
|
|
286
295
|
services,
|
|
287
296
|
...commonSessionOptions(sessionOptions),
|
|
288
|
-
})
|
|
297
|
+
});
|
|
298
|
+
if (codemode) runToolCallsSequentially(created.session);
|
|
299
|
+
return created;
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* SDK sessions load none of pi's built-in extensions, so codemode is registered only for agents whose
|
|
306
|
+
* allowlist names it. Scripts can call just the agent's other allowed tools. Mode "on" keeps those
|
|
307
|
+
* tools declared whatever the user's `codemode.mode`, so the author's allowlist stays what the model sees.
|
|
308
|
+
* Its calls queue for the process-wide codemode slots instead of failing at pi's worker limit, so the
|
|
309
|
+
* session runs its tool calls one at a time.
|
|
310
|
+
*/
|
|
311
|
+
function codemodeExtensions(): Pick<SessionResourceLoaderOptions, "extensionFactories"> {
|
|
312
|
+
return {
|
|
313
|
+
extensionFactories: [
|
|
314
|
+
{ name: CODEMODE_TOOL, factory: createCodemodeExtension({ mode: "on" }), replaceable: true },
|
|
315
|
+
{ name: "workflow-codemode-slots", factory: createCodemodeSlotExtension(CODEMODE_TOOL), hidden: true },
|
|
316
|
+
],
|
|
289
317
|
};
|
|
290
318
|
}
|
|
291
319
|
|
|
320
|
+
type SessionResourceLoaderOptions = NonNullable<CreateAgentSessionServicesOptions["resourceLoaderOptions"]>;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Proxies for the host MCP tools this agent asked for: those named in its allowlist, and open-world
|
|
324
|
+
* research tools when it hints `external-search`. Local `search` hints never bridge, because host tools
|
|
325
|
+
* run in the host session's working directory, not the agent's (an isolated worktree, for example).
|
|
326
|
+
* A named tool the run cannot reach fails the agent, like `requireToolHints`, rather than letting it
|
|
327
|
+
* work without a tool its prompt may rely on.
|
|
328
|
+
*/
|
|
329
|
+
function requestedHostTools(opts: AgentExecutionOptions, bridge: HostToolBridge | undefined): ToolDefinition[] {
|
|
330
|
+
const named = new Set((opts.tools ?? []).filter(isMcpToolName));
|
|
331
|
+
const externalSearch = opts.toolHints?.includes("external-search") ?? false;
|
|
332
|
+
if (named.size === 0 && !externalSearch) return [];
|
|
333
|
+
const available = bridge?.tools() ?? [];
|
|
334
|
+
const selected = available.filter((tool) => named.has(tool.name) || (externalSearch && matchesAgentToolHint(tool, "external-search")));
|
|
335
|
+
const missing = [...named].filter((name) => !selected.some((tool) => tool.name === name));
|
|
336
|
+
if (missing.length > 0) throw new WorkflowHostToolUnavailableError(missing, bridge !== undefined);
|
|
337
|
+
return bridge ? selected.map((tool) => hostToolProxy(tool, bridge)) : [];
|
|
338
|
+
}
|
|
339
|
+
|
|
292
340
|
function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerSession): () => void {
|
|
293
341
|
if (!signal) return () => {};
|
|
294
342
|
const onAbort = () => {
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { BackgroundWorkflowCoordinator, backgroundOrigin } from "./background-workflows.ts";
|
|
3
|
-
import { createWorkflowRunId } from "./journal.ts";
|
|
4
|
+
import { createWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
4
5
|
import type { ResolvedWorkflowRunOptions } from "./options.ts";
|
|
5
6
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
7
|
+
import { workflowRunScriptFile } from "./workflow-run-record.ts";
|
|
6
8
|
|
|
7
9
|
export interface BackgroundWorkflowToolResult {
|
|
8
10
|
readonly content: Array<{ readonly type: "text"; readonly text: string }>;
|
|
9
11
|
readonly details: Readonly<Record<string, unknown>>;
|
|
12
|
+
readonly isError?: true;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
export interface BackgroundWorkflowToolStartInput {
|
|
@@ -14,6 +17,8 @@ export interface BackgroundWorkflowToolStartInput {
|
|
|
14
17
|
readonly ctx: ExtensionContext;
|
|
15
18
|
readonly name: string;
|
|
16
19
|
readonly options: ResolvedWorkflowRunOptions;
|
|
20
|
+
/** Deliver the completion with a new agent turn instead of silently. */
|
|
21
|
+
readonly wakeOnComplete?: boolean;
|
|
17
22
|
readonly execute: (ctx: ExtensionContext, options: ResolvedWorkflowRunOptions) => Promise<void>;
|
|
18
23
|
}
|
|
19
24
|
|
|
@@ -25,6 +30,7 @@ export function backgroundUnavailableResult(mode: ExtensionContext["mode"]): Bac
|
|
|
25
30
|
text: `Background workflows are unavailable in ${mode} mode because pi exits after the prompt. Run synchronously or use TUI/RPC mode.`,
|
|
26
31
|
}],
|
|
27
32
|
details: { error: "background_unavailable", mode },
|
|
33
|
+
isError: true,
|
|
28
34
|
};
|
|
29
35
|
}
|
|
30
36
|
|
|
@@ -37,8 +43,9 @@ export async function startBackgroundWorkflowTool(
|
|
|
37
43
|
inspect: false,
|
|
38
44
|
resultViewer: "skip",
|
|
39
45
|
runId,
|
|
40
|
-
background: backgroundOrigin(input.ctx),
|
|
46
|
+
background: backgroundOrigin(input.ctx, Date.now(), input.wakeOnComplete),
|
|
41
47
|
};
|
|
48
|
+
let scriptPersisted = false;
|
|
42
49
|
try {
|
|
43
50
|
await input.coordinator.start({
|
|
44
51
|
ctx: input.ctx,
|
|
@@ -51,6 +58,7 @@ export async function startBackgroundWorkflowTool(
|
|
|
51
58
|
...backgroundOptions,
|
|
52
59
|
signal,
|
|
53
60
|
onRunMetadata(metadata) {
|
|
61
|
+
scriptPersisted = metadata.scriptPath !== undefined;
|
|
54
62
|
onStarted();
|
|
55
63
|
return backgroundOptions.onRunMetadata?.(metadata);
|
|
56
64
|
},
|
|
@@ -63,12 +71,13 @@ export async function startBackgroundWorkflowTool(
|
|
|
63
71
|
return {
|
|
64
72
|
content: [{ type: "text", text: `Background workflow did not start: ${message}` }],
|
|
65
73
|
details: { error: "background_start_failed", message, runId },
|
|
74
|
+
isError: true,
|
|
66
75
|
};
|
|
67
76
|
}
|
|
68
77
|
return {
|
|
69
78
|
content: [{
|
|
70
79
|
type: "text",
|
|
71
|
-
text: `Background workflow "${input.name}" started.\nRun ID: ${runId}\nDurable record: .pi/.workflow-runs/${runId}.run.json`,
|
|
80
|
+
text: `Background workflow "${input.name}" started.\nRun ID: ${runId}\nDurable record: .pi/.workflow-runs/${runId}.run.json${scriptPersisted ? `\nScript: ${join(WORKFLOW_RUNS_DIR, workflowRunScriptFile(runId))}` : ""}`,
|
|
72
81
|
}],
|
|
73
82
|
details: { background: true, state: "running", name: input.name, runId },
|
|
74
83
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
1
2
|
import { SessionManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { WorkflowAbortError, WorkflowPauseError } from "./cancellation.ts";
|
|
3
4
|
import { isRecord } from "./guards.ts";
|
|
5
|
+
import { WORKFLOW_RUNS_DIR } from "./journal.ts";
|
|
4
6
|
import type { WorkflowBackgroundOrigin } from "./types.ts";
|
|
5
7
|
import {
|
|
6
8
|
stopWorkflowRunRecord,
|
|
@@ -9,9 +11,11 @@ import {
|
|
|
9
11
|
type WorkflowRunState,
|
|
10
12
|
} from "./workflow-run-record.ts";
|
|
11
13
|
import { updateWorkflowRunDelivery, type WorkflowRunDelivery } from "./workflow-run-background.ts";
|
|
12
|
-
import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
14
|
+
import { ProjectWorkflowRunStore, WORKFLOW_RUN_RECORD_SUFFIX, type WorkflowRunStore } from "./workflow-run-store.ts";
|
|
13
15
|
import { truncateText } from "./text.ts";
|
|
16
|
+
import { formatWorkflowUsageLine } from "./usage.ts";
|
|
14
17
|
import { workflowResultSummary } from "./workflow-execution.ts";
|
|
18
|
+
import { formatResultForContext, formatWorkflowRunFileLines } from "./workflow-result-context.ts";
|
|
15
19
|
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
16
20
|
|
|
17
21
|
const BACKGROUND_DELIVERY_CUSTOM_TYPE = "workflow-result";
|
|
@@ -294,11 +298,12 @@ export class BackgroundWorkflowCoordinator {
|
|
|
294
298
|
this.pi.sendMessage(
|
|
295
299
|
{
|
|
296
300
|
customType: BACKGROUND_DELIVERY_CUSTOM_TYPE,
|
|
297
|
-
content: formatBackgroundDelivery(details),
|
|
301
|
+
content: formatBackgroundDelivery(record, details),
|
|
298
302
|
display: true,
|
|
299
303
|
details,
|
|
300
304
|
},
|
|
301
|
-
|
|
305
|
+
// Delivery waits for an idle session, and pi steers a message sent while a run starts into that run.
|
|
306
|
+
{ triggerTurn: wakesAgent(record) },
|
|
302
307
|
);
|
|
303
308
|
}
|
|
304
309
|
await markDelivery(store, runId, { state: "delivered", deliveredAt: Date.now() });
|
|
@@ -306,8 +311,13 @@ export class BackgroundWorkflowCoordinator {
|
|
|
306
311
|
}
|
|
307
312
|
}
|
|
308
313
|
|
|
309
|
-
export function backgroundOrigin(
|
|
310
|
-
|
|
314
|
+
export function backgroundOrigin(
|
|
315
|
+
ctx: Pick<ExtensionContext, "sessionManager">,
|
|
316
|
+
requestedAt = Date.now(),
|
|
317
|
+
wakeOnComplete = false,
|
|
318
|
+
): WorkflowBackgroundOrigin {
|
|
319
|
+
const origin = { sessionId: ctx.sessionManager.getSessionId(), requestedAt };
|
|
320
|
+
return wakeOnComplete ? { ...origin, wakeOnComplete } : origin;
|
|
311
321
|
}
|
|
312
322
|
|
|
313
323
|
export function backgroundResultDetails(record: WorkflowRunRecord): BackgroundWorkflowResultDetails {
|
|
@@ -336,17 +346,41 @@ function backgroundSummary(record: WorkflowRunRecord): string {
|
|
|
336
346
|
return summary === undefined ? "Workflow completed. Open run history for the retained result." : truncateText(summary, SUMMARY_LIMIT);
|
|
337
347
|
}
|
|
338
348
|
|
|
339
|
-
|
|
349
|
+
/**
|
|
350
|
+
* The host model only sees this text, never `details`. A woken agent acts on it at once, so it gets the
|
|
351
|
+
* result as the synchronous tool path renders it; a silent delivery keeps the summary. Both name the run's
|
|
352
|
+
* files so a later turn can read the retained result.
|
|
353
|
+
*/
|
|
354
|
+
function formatBackgroundDelivery(record: WorkflowRunRecord, details: BackgroundWorkflowResultDetails): string {
|
|
355
|
+
const woken = record.background?.origin.wakeOnComplete === true;
|
|
356
|
+
const usage = woken && record.state === "completed" ? formatWorkflowUsageLine(record.usage) : undefined;
|
|
340
357
|
return [
|
|
341
358
|
`## Background workflow: ${details.name}`,
|
|
342
359
|
"",
|
|
343
|
-
|
|
360
|
+
// A resume, including an automatic one, has a new run id; name the run the agent started.
|
|
361
|
+
details.resumedFromRunId ? `Run ID: ${details.runId} (resumed from ${details.resumedFromRunId})` : `Run ID: ${details.runId}`,
|
|
344
362
|
`State: ${details.status}`,
|
|
345
363
|
"",
|
|
346
|
-
details.summary,
|
|
364
|
+
woken ? wokenDeliveryBody(record, details.summary) : details.summary,
|
|
365
|
+
"",
|
|
366
|
+
...backgroundRunFileLines(record),
|
|
367
|
+
...(usage === undefined ? [] : [usage]),
|
|
347
368
|
].join("\n");
|
|
348
369
|
}
|
|
349
370
|
|
|
371
|
+
function wokenDeliveryBody(record: WorkflowRunRecord, summary: string): string {
|
|
372
|
+
if (record.state !== "completed") return `Workflow ${record.state}: ${record.message}`;
|
|
373
|
+
return record.result.kind === "value" ? formatResultForContext(record.result.value) : summary;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The retained record, script and journal, relative to the workflow cwd like the synchronous result's lines. */
|
|
377
|
+
function backgroundRunFileLines(record: WorkflowRunRecord): string[] {
|
|
378
|
+
return [
|
|
379
|
+
`Record: ${join(WORKFLOW_RUNS_DIR, `${record.runId}${WORKFLOW_RUN_RECORD_SUFFIX}`)}`,
|
|
380
|
+
...formatWorkflowRunFileLines(record.runId, record.scriptFile !== undefined),
|
|
381
|
+
];
|
|
382
|
+
}
|
|
383
|
+
|
|
350
384
|
function sessionHasBackgroundDelivery(ctx: ExtensionContext, runId: string): boolean {
|
|
351
385
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
352
386
|
if (entry.type !== "message" || entry.message.role !== "custom") continue;
|
|
@@ -361,6 +395,15 @@ function isPendingBackgroundOutcome(record: WorkflowRunRecord): record is Workfl
|
|
|
361
395
|
return record.background?.delivery.state === "pending" && isDeliverableState(record.state);
|
|
362
396
|
}
|
|
363
397
|
|
|
398
|
+
/**
|
|
399
|
+
* A run started under Dynamax wakes the agent to act on its result; others arrive silently. So does a run
|
|
400
|
+
* the user stopped, or one paused with a resume already scheduled, whose successor wakes it instead.
|
|
401
|
+
*/
|
|
402
|
+
function wakesAgent(record: WorkflowRunRecord): boolean {
|
|
403
|
+
if (record.background?.origin.wakeOnComplete !== true || record.state === "stopped") return false;
|
|
404
|
+
return !(record.state === "paused" && record.pause?.autoResume === true);
|
|
405
|
+
}
|
|
406
|
+
|
|
364
407
|
function isDeliverableState(state: WorkflowRunState): state is "completed" | "failed" | "stopped" | "paused" {
|
|
365
408
|
return state === "completed" || state === "failed" || state === "stopped" || state === "paused";
|
|
366
409
|
}
|
|
@@ -13,6 +13,20 @@ export class WorkflowPauseError extends WorkflowAbortError {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Abort reason for one agent the user skipped from the inspector. It deliberately does not extend
|
|
18
|
+
* WorkflowAbortError: the run carries on, so `parallel()`/`pipeline()` turn the skipped branch into a
|
|
19
|
+
* `null` slot while a direct `await agent()` sees this error.
|
|
20
|
+
*/
|
|
21
|
+
export class WorkflowAgentSkippedError extends Error {
|
|
22
|
+
override readonly name = "WorkflowAgentSkippedError";
|
|
23
|
+
readonly code = "WORKFLOW_AGENT_SKIPPED";
|
|
24
|
+
|
|
25
|
+
constructor(readonly label: string) {
|
|
26
|
+
super(`Workflow agent "${label}" was skipped by the user.`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export function abortReason(signal: AbortSignal | undefined): Error {
|
|
17
31
|
const reason = signal?.reason;
|
|
18
32
|
if (safeInstanceOf(reason, Error)) return reason;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ClassifierAnswer, ClassifierApi, ClassifierContext, ClassifierModel } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { RunContext } from "./agent-runner-types.ts";
|
|
4
|
+
import { parseAgentModelRef } from "./model-ref.ts";
|
|
5
|
+
import { assertWorkflowBudgetAvailable } from "./budget.ts";
|
|
6
|
+
import { throwIfAborted } from "./cancellation.ts";
|
|
7
|
+
import type { ClassifyOptions } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
type ClassifierRegistry = Pick<ModelRegistry, "classify" | "getAvailableOfType" | "getModelOfType">;
|
|
10
|
+
|
|
11
|
+
/** No classifier model is configured, or the requested one is unknown. Recoverable inside `parallel`. */
|
|
12
|
+
export class WorkflowClassifierUnavailableError extends Error {
|
|
13
|
+
readonly code = "WORKFLOW_CLASSIFIER_UNAVAILABLE";
|
|
14
|
+
|
|
15
|
+
constructor(message: string) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "WorkflowClassifierUnavailableError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The classifier answered with an error or no answer. Recoverable inside `parallel`. */
|
|
22
|
+
export class WorkflowClassifierError extends Error {
|
|
23
|
+
readonly code = "WORKFLOW_CLASSIFIER_FAILED";
|
|
24
|
+
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "WorkflowClassifierError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `api.classify()`: answer typed questions about JSON state with a classifier model through the host's
|
|
33
|
+
* credentials, without a chat session. It shares the run's concurrency cap, budget, and per-agent timeout,
|
|
34
|
+
* records usage like an agent, and is not journaled, so a resumed run asks again.
|
|
35
|
+
*/
|
|
36
|
+
export async function runClassifier(
|
|
37
|
+
rc: RunContext,
|
|
38
|
+
context: ClassifierContext,
|
|
39
|
+
opts: ClassifyOptions = {},
|
|
40
|
+
): Promise<Record<string, ClassifierAnswer>> {
|
|
41
|
+
const registry = rc.modelRegistry;
|
|
42
|
+
return await rc.semaphore.run(async () => {
|
|
43
|
+
assertWorkflowBudgetAvailable(rc.budget);
|
|
44
|
+
const model = await cachedClassifierModel(rc, registry, opts.model);
|
|
45
|
+
const label = opts.label ?? `classify:${model.provider}/${model.id}`;
|
|
46
|
+
// A stalled classifier must not hold a concurrency slot past the limit agents get.
|
|
47
|
+
const timeout = AbortSignal.timeout(rc.agentTimeoutMs);
|
|
48
|
+
const result = await registry.classify(model, context, { signal: rc.signal ? AbortSignal.any([rc.signal, timeout]) : timeout });
|
|
49
|
+
// One classifier response is one model response: recording it like one keeps per-model usage and the budget
|
|
50
|
+
// whole. Record before the abort check, since the provider charged for a call that returned as the run stopped.
|
|
51
|
+
rc.usage.recordAgentSession({
|
|
52
|
+
label,
|
|
53
|
+
phase: opts.phase,
|
|
54
|
+
messages: [{ role: "assistant", provider: result.provider, model: result.model, usage: result.usage }],
|
|
55
|
+
});
|
|
56
|
+
throwIfAborted(rc.signal);
|
|
57
|
+
if (result.stopReason !== "stop" && timeout.aborted) {
|
|
58
|
+
throw new WorkflowClassifierError(`${label}: classifier timed out after ${rc.agentTimeoutMs}ms`);
|
|
59
|
+
}
|
|
60
|
+
if (result.stopReason !== "stop") {
|
|
61
|
+
throw new WorkflowClassifierError(`${label}: classifier ${result.stopReason}${result.errorMessage ? `: ${result.errorMessage}` : ""}`);
|
|
62
|
+
}
|
|
63
|
+
return result.answers;
|
|
64
|
+
}, { signal: rc.signal });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One credential lookup per route and run; a route that failed to resolve keeps failing for the run. */
|
|
68
|
+
function cachedClassifierModel(
|
|
69
|
+
rc: RunContext,
|
|
70
|
+
registry: ClassifierRegistry,
|
|
71
|
+
ref: string | readonly string[] | undefined,
|
|
72
|
+
): Promise<ClassifierModel<ClassifierApi>> {
|
|
73
|
+
const key = JSON.stringify(ref ?? null);
|
|
74
|
+
const cached = rc.classifierRoutes?.get(key);
|
|
75
|
+
if (cached) return cached;
|
|
76
|
+
const resolved = resolveClassifierModel(registry, ref);
|
|
77
|
+
rc.classifierRoutes?.set(key, resolved);
|
|
78
|
+
return resolved;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function resolveClassifierModel(
|
|
82
|
+
registry: ClassifierRegistry,
|
|
83
|
+
ref: string | readonly string[] | undefined,
|
|
84
|
+
): Promise<ClassifierModel<ClassifierApi>> {
|
|
85
|
+
if (ref !== undefined && typeof ref !== "string") {
|
|
86
|
+
const available = await registry.getAvailableOfType("classifier");
|
|
87
|
+
const preferred = ref.map((candidate) => parseAgentModelRef(candidate))
|
|
88
|
+
.map(({ provider, id }) => available.find((model) => model.provider === provider && model.id === id))
|
|
89
|
+
.find((model) => model !== undefined);
|
|
90
|
+
if (!preferred) throw new WorkflowClassifierUnavailableError(`None of the classifier models ${ref.join(", ")} has working credentials.`);
|
|
91
|
+
return preferred;
|
|
92
|
+
}
|
|
93
|
+
if (ref === undefined) {
|
|
94
|
+
const [available] = await registry.getAvailableOfType("classifier");
|
|
95
|
+
if (!available) {
|
|
96
|
+
throw new WorkflowClassifierUnavailableError(
|
|
97
|
+
"No classifier model has working credentials. Configure one (for example TypeSafe Jev via OpenRouter) or pass { model: \"provider/id\" }.",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return available;
|
|
101
|
+
}
|
|
102
|
+
// Same ref rules as agent({ model }): malformed refs throw, bare ids mean Anthropic.
|
|
103
|
+
const { provider, id } = parseAgentModelRef(ref);
|
|
104
|
+
const model = registry.getModelOfType("classifier", provider, id);
|
|
105
|
+
if (!model) throw new WorkflowClassifierUnavailableError(`Classifier model "${ref}" not found (resolved as ${provider}/${id}).`);
|
|
106
|
+
return model;
|
|
107
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AgentSession, ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Semaphore } from "./concurrency.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* pi runs at most this many codemode scripts per process, and rejects one more with "Codemode worker
|
|
6
|
+
* limit reached" instead of waiting (pi 1.3 `extensions/codemode/execute.js`). Every subagent shares
|
|
7
|
+
* that limit with the others and with the host, so a fan-out of codemode agents would fail calls.
|
|
8
|
+
*/
|
|
9
|
+
export const CODEMODE_WORKER_SLOTS = 2;
|
|
10
|
+
|
|
11
|
+
const processSlots = new Semaphore(CODEMODE_WORKER_SLOTS);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Queue a subagent's top-level calls to `toolName` for one of the process-wide codemode slots, so a
|
|
15
|
+
* call waits for a running script instead of failing. pi frees its own slot only once the worker has
|
|
16
|
+
* exited, which can lag the result on Bun, so a call can still meet pi's limit now and then. The host's
|
|
17
|
+
* own codemode calls are not queued. A session using it must run its tool calls one at a time
|
|
18
|
+
* ({@link runToolCallsSequentially}).
|
|
19
|
+
*/
|
|
20
|
+
export function createCodemodeSlotExtension(toolName: string, slots: Semaphore = processSlots): ExtensionFactory {
|
|
21
|
+
return (pi) => {
|
|
22
|
+
const held = new Map<string, () => void>();
|
|
23
|
+
const release = (toolCallId: string): void => {
|
|
24
|
+
held.get(toolCallId)?.();
|
|
25
|
+
held.delete(toolCallId);
|
|
26
|
+
};
|
|
27
|
+
// A call another handler blocks after this one took a slot never runs, so the turn's end frees it.
|
|
28
|
+
const releaseAll = (): void => {
|
|
29
|
+
for (const toolCallId of [...held.keys()]) release(toolCallId);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
33
|
+
// A script's nested calls run inside the slot its own call holds.
|
|
34
|
+
if (event.toolName !== toolName || event.parentToolCallId !== undefined) return undefined;
|
|
35
|
+
// Rejects while queued once the agent is aborted; pi then blocks the call.
|
|
36
|
+
await new Promise<void>((acquired, failed) => {
|
|
37
|
+
slots
|
|
38
|
+
.run(
|
|
39
|
+
() =>
|
|
40
|
+
new Promise<void>((done) => {
|
|
41
|
+
held.set(event.toolCallId, done);
|
|
42
|
+
acquired();
|
|
43
|
+
}),
|
|
44
|
+
{ signal: ctx.signal },
|
|
45
|
+
)
|
|
46
|
+
.catch(failed);
|
|
47
|
+
});
|
|
48
|
+
return undefined;
|
|
49
|
+
});
|
|
50
|
+
pi.on("tool_execution_end", (event) => release(event.toolCallId));
|
|
51
|
+
pi.on("tool_result", (event) => {
|
|
52
|
+
release(event.toolCallId);
|
|
53
|
+
return undefined;
|
|
54
|
+
});
|
|
55
|
+
pi.on("turn_end", () => {
|
|
56
|
+
releaseAll();
|
|
57
|
+
return undefined;
|
|
58
|
+
});
|
|
59
|
+
pi.on("session_shutdown", releaseAll);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run a codemode session's tool calls one at a time. pi's default "parallel" execution runs every call's
|
|
65
|
+
* `tool_call` hook before it executes any (pi-agent-core `executeToolCallsParallel`), so a message with
|
|
66
|
+
* more codemode calls than free slots would leave the calls holding slots waiting on the one queued
|
|
67
|
+
* behind them until the agent timed out, and starve every other codemode agent. In order, each call
|
|
68
|
+
* has freed its slot before the next call asks for one. pi then also runs the calls a script makes one
|
|
69
|
+
* at a time (`NestedToolCallRunner`).
|
|
70
|
+
*/
|
|
71
|
+
export function runToolCallsSequentially(session: Pick<AgentSession, "agent">): void {
|
|
72
|
+
session.agent.toolExecution = "sequential";
|
|
73
|
+
}
|