pi-famulus 0.1.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/LICENSE +21 -0
- package/README.md +36 -0
- package/bin/pi-famulus.js +29 -0
- package/package.json +49 -0
- package/src/agents/builtins.ts +41 -0
- package/src/agents/definition.ts +221 -0
- package/src/agents/loader.ts +119 -0
- package/src/bash-override.ts +372 -0
- package/src/behavior-guidelines.ts +42 -0
- package/src/clock.ts +169 -0
- package/src/comms/comms.ts +199 -0
- package/src/comms/mailbox.ts +156 -0
- package/src/comms/registry-host.ts +73 -0
- package/src/comms/reply-command.ts +52 -0
- package/src/comms/routing.ts +23 -0
- package/src/comms/tools.ts +339 -0
- package/src/comms/types.ts +68 -0
- package/src/config.ts +266 -0
- package/src/events.ts +61 -0
- package/src/exit-notify-gate.ts +69 -0
- package/src/exit-watchdog.ts +60 -0
- package/src/file-tail.ts +48 -0
- package/src/format.ts +276 -0
- package/src/index.ts +700 -0
- package/src/manager-client.ts +839 -0
- package/src/monitor-batching.ts +191 -0
- package/src/monitor.ts +517 -0
- package/src/native-manager.d.ts +9 -0
- package/src/native-manager.js +39 -0
- package/src/notify.ts +231 -0
- package/src/shell-exec.ts +145 -0
- package/src/subagent/agent-records.ts +168 -0
- package/src/subagent/child-bash.ts +183 -0
- package/src/subagent/conversation.ts +76 -0
- package/src/subagent/fleet-widget.ts +176 -0
- package/src/subagent/model-spec.ts +108 -0
- package/src/subagent/pi-runtime.ts +295 -0
- package/src/subagent/pool.ts +190 -0
- package/src/subagent/registry.ts +575 -0
- package/src/subagent/runner.ts +709 -0
- package/src/subagent/tool.ts +741 -0
- package/src/subagent/transcript.ts +71 -0
- package/src/subagent/types.ts +125 -0
- package/src/task-exit-notify.ts +21 -0
- package/src/task-tools.ts +330 -0
- package/src/tui/message-renderers.ts +183 -0
- package/src/tui/pi-tui-load.ts +186 -0
- package/src/tui/scroll-detail-view.ts +190 -0
- package/src/tui/task-output-paths.ts +46 -0
- package/src/tui/tasks-command.ts +515 -0
- package/src/tui/tool-component.ts +29 -0
- package/src/wake.ts +219 -0
- package/src/work-index.ts +182 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi runtime bridge (design doc §4.6 "pi runtime isolation rules").
|
|
3
|
+
*
|
|
4
|
+
* This is the ONLY module that loads `@earendil-works/pi-coding-agent` at
|
|
5
|
+
* runtime, and only via dynamic `await import(...)`. Everything else in
|
|
6
|
+
* src/subagent/ is pi-free and testable with fakes.
|
|
7
|
+
*
|
|
8
|
+
* `createPiSessionFn(deps)` returns the CreateSessionFn injected into
|
|
9
|
+
* InProcessRunner: it resolves the model spec against the parent's model
|
|
10
|
+
* registry, creates an in-memory child AgentSession with the agent's tool
|
|
11
|
+
* allowlist plus injected custom tools (child bash, M4 comms), and wraps it
|
|
12
|
+
* into a ChildSessionAdapter.
|
|
13
|
+
*
|
|
14
|
+
* Child sessions receive an isolated DefaultResourceLoader with extensions
|
|
15
|
+
* disabled; createAgentSession binds whatever the loader returns. Thus the
|
|
16
|
+
* subagent tool is never present in a child session — the depth-1 cap holds
|
|
17
|
+
* by construction.
|
|
18
|
+
*/
|
|
19
|
+
import type {
|
|
20
|
+
ExtensionContext,
|
|
21
|
+
ModelRegistry,
|
|
22
|
+
ToolDefinition,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { CHILD_BEHAVIOR_GUIDELINES } from "../behavior-guidelines";
|
|
25
|
+
import {
|
|
26
|
+
modelResolutionError,
|
|
27
|
+
resolveModelSpec,
|
|
28
|
+
type ModelCandidate,
|
|
29
|
+
} from "./model-spec";
|
|
30
|
+
import { turnsFromMessages } from "./conversation";
|
|
31
|
+
import type { ChildRunRequest, ChildSessionAdapter, CreateSessionFn } from "./types";
|
|
32
|
+
|
|
33
|
+
/** Structural subset of the pi module namespace we rely on. */
|
|
34
|
+
type PiModule = typeof import("@earendil-works/pi-coding-agent");
|
|
35
|
+
|
|
36
|
+
type PiAgentSession = Awaited<ReturnType<PiModule["createAgentSession"]>>["session"];
|
|
37
|
+
|
|
38
|
+
type Model = NonNullable<ExtensionContext["model"]>;
|
|
39
|
+
|
|
40
|
+
export interface PiRuntimeDeps {
|
|
41
|
+
/** Parent session model registry (ctx.modelRegistry). */
|
|
42
|
+
getModelRegistry: () => ModelRegistry | null;
|
|
43
|
+
/**
|
|
44
|
+
* Parent ModelRuntime, including providers registered by other extensions.
|
|
45
|
+
* createAgentSession without this builds a fresh runtime and fails those
|
|
46
|
+
* providers with "No API key found".
|
|
47
|
+
*/
|
|
48
|
+
getModelRuntime?: () => unknown;
|
|
49
|
+
/** Parent session current model (ctx.model) — default for children. */
|
|
50
|
+
getParentModel: () => Model | undefined;
|
|
51
|
+
/** Parent session thinking level (ctx.thinkingLevel). */
|
|
52
|
+
getParentThinkingLevel: () => ExtensionContext["thinkingLevel"];
|
|
53
|
+
/**
|
|
54
|
+
* Parent session scoped models (ctx.scopedModels) — the user's whitelist
|
|
55
|
+
* (settings enabledModels / --models). When non-empty, children may only
|
|
56
|
+
* select from these.
|
|
57
|
+
*/
|
|
58
|
+
getScopedModels?: () => readonly { model: Model; thinkingLevel?: string }[];
|
|
59
|
+
/** Working directory for child sessions. */
|
|
60
|
+
getCwd: () => string;
|
|
61
|
+
/**
|
|
62
|
+
* Custom tools injected into every child session (e.g. the no-background
|
|
63
|
+
* bash variant, M4 contact_supervisor). Called per child request.
|
|
64
|
+
*/
|
|
65
|
+
customTools?: (req: ChildRunRequest) => Array<ToolDefinition<any, any, any>>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Candidate set for model selection (§4.6): the scoped whitelist when the
|
|
70
|
+
* user configured one, otherwise every available model. `scoped` marks the
|
|
71
|
+
* source so UIs/errors can say where the list came from.
|
|
72
|
+
*/
|
|
73
|
+
export function modelCandidates(
|
|
74
|
+
deps: Pick<PiRuntimeDeps, "getModelRegistry" | "getScopedModels">,
|
|
75
|
+
): (ModelCandidate & { scoped: boolean })[] {
|
|
76
|
+
const scoped = deps.getScopedModels?.() ?? [];
|
|
77
|
+
if (scoped.length > 0) {
|
|
78
|
+
return scoped.map((s) => ({
|
|
79
|
+
provider: s.model.provider,
|
|
80
|
+
id: s.model.id,
|
|
81
|
+
name: (s.model as { name?: string }).name,
|
|
82
|
+
scoped: true,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
const registry = deps.getModelRegistry();
|
|
86
|
+
if (!registry) return [];
|
|
87
|
+
return registry.getAvailable().map((m) => ({
|
|
88
|
+
provider: m.provider,
|
|
89
|
+
id: m.id,
|
|
90
|
+
name: (m as { name?: string }).name,
|
|
91
|
+
scoped: false,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let piModulePromise: Promise<PiModule> | null = null;
|
|
96
|
+
|
|
97
|
+
/** Dynamically import the pi package, with a clear error when unavailable. */
|
|
98
|
+
async function importPi(): Promise<PiModule> {
|
|
99
|
+
if (!piModulePromise) {
|
|
100
|
+
piModulePromise = import("@earendil-works/pi-coding-agent").catch((err: unknown) => {
|
|
101
|
+
piModulePromise = null; // allow retry on next call
|
|
102
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Failed to load @earendil-works/pi-coding-agent for a subagent session: ${message}. ` +
|
|
105
|
+
"The subagent tool requires the pi package to be resolvable at runtime.",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return piModulePromise;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface ResolvedModel {
|
|
113
|
+
model: Model | undefined;
|
|
114
|
+
/** Thinking level from a ":<level>" spec suffix (overrides agent thinking). */
|
|
115
|
+
thinkingOverride?: string;
|
|
116
|
+
/** Non-fatal caveat to attach to the child result. */
|
|
117
|
+
warning?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Model priority: subagent() param override > agent definition > parent model.
|
|
122
|
+
* Failure semantics differ by source (§4.6): a bad tool-parameter spec is a
|
|
123
|
+
* hard error (the LLM retries with the candidate list); a bad agent-file spec
|
|
124
|
+
* falls back to the parent model with a warning (user-authored files must not
|
|
125
|
+
* hard-fail across machines).
|
|
126
|
+
*/
|
|
127
|
+
function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel {
|
|
128
|
+
const candidates = modelCandidates(deps);
|
|
129
|
+
const findModel = (provider: string, id: string): Model | undefined =>
|
|
130
|
+
deps.getModelRegistry()?.find(provider, id) ??
|
|
131
|
+
(deps.getScopedModels?.() ?? []).find(
|
|
132
|
+
(s) => s.model.provider === provider && s.model.id === id,
|
|
133
|
+
)?.model;
|
|
134
|
+
|
|
135
|
+
if (req.model !== undefined) {
|
|
136
|
+
const res = resolveModelSpec(req.model, candidates);
|
|
137
|
+
if (!res.ok) throw new Error(modelResolutionError(req.model, res));
|
|
138
|
+
const model = findModel(res.provider, res.id);
|
|
139
|
+
if (!model) throw new Error(`model ${res.provider}/${res.id} is not usable in this session`);
|
|
140
|
+
return { model, thinkingOverride: res.thinking };
|
|
141
|
+
}
|
|
142
|
+
if (req.agent.model !== undefined) {
|
|
143
|
+
const res = resolveModelSpec(req.agent.model, candidates);
|
|
144
|
+
if (res.ok) {
|
|
145
|
+
const model = findModel(res.provider, res.id);
|
|
146
|
+
if (model) return { model, thinkingOverride: res.thinking };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
model: deps.getParentModel(),
|
|
150
|
+
warning: `agent "${req.agent.name}" model "${req.agent.model}" unavailable; fell back to the parent model`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { model: deps.getParentModel() };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Wrap a real AgentSession into the pi-free ChildSessionAdapter. */
|
|
157
|
+
function wrapSession(
|
|
158
|
+
session: PiAgentSession,
|
|
159
|
+
extras: { warning?: string; resolvedModel?: string } = {},
|
|
160
|
+
): ChildSessionAdapter {
|
|
161
|
+
return {
|
|
162
|
+
...(extras.warning !== undefined ? { warning: extras.warning } : {}),
|
|
163
|
+
...(extras.resolvedModel !== undefined ? { resolvedModel: extras.resolvedModel } : {}),
|
|
164
|
+
prompt: (text) => session.prompt(text),
|
|
165
|
+
steer: (text) => session.steer(text),
|
|
166
|
+
followUp: (text) => session.followUp(text),
|
|
167
|
+
abort: () => session.abort(),
|
|
168
|
+
waitForIdle: () => session.waitForIdle(),
|
|
169
|
+
getLastAssistantText: () => session.getLastAssistantText(),
|
|
170
|
+
getLastAssistantFailure: () => {
|
|
171
|
+
const lastAssistant = [...session.messages].reverse().find((message) => message.role === "assistant") as
|
|
172
|
+
| { stopReason?: string; errorMessage?: string }
|
|
173
|
+
| undefined;
|
|
174
|
+
if (lastAssistant?.stopReason !== "error" && lastAssistant?.stopReason !== "aborted") return undefined;
|
|
175
|
+
return {
|
|
176
|
+
stopReason: lastAssistant.stopReason,
|
|
177
|
+
...(lastAssistant.errorMessage ? { errorMessage: lastAssistant.errorMessage } : {}),
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
getConversation: () => turnsFromMessages(session.messages),
|
|
181
|
+
getActiveToolNames: () => session.getActiveToolNames(),
|
|
182
|
+
getSystemPrompt: () => session.systemPrompt,
|
|
183
|
+
isStreaming: () => session.isStreaming,
|
|
184
|
+
subscribe: (listener) => session.subscribe((event) => listener({ type: event.type })),
|
|
185
|
+
dispose: () => session.dispose(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Options passed to createAgentSession. Tested without loading pi. */
|
|
190
|
+
export function childResourceLoaderOptions(input: { cwd: string; agentDir: string }): {
|
|
191
|
+
cwd: string;
|
|
192
|
+
agentDir: string;
|
|
193
|
+
noExtensions: true;
|
|
194
|
+
noSkills: true;
|
|
195
|
+
noPromptTemplates: true;
|
|
196
|
+
noThemes: true;
|
|
197
|
+
noContextFiles: true;
|
|
198
|
+
} {
|
|
199
|
+
return {
|
|
200
|
+
cwd: input.cwd,
|
|
201
|
+
agentDir: input.agentDir,
|
|
202
|
+
noExtensions: true,
|
|
203
|
+
noSkills: true,
|
|
204
|
+
noPromptTemplates: true,
|
|
205
|
+
noThemes: true,
|
|
206
|
+
noContextFiles: true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function childSessionCreateOptions(input: {
|
|
211
|
+
cwd: string;
|
|
212
|
+
model: unknown;
|
|
213
|
+
thinkingLevel: unknown;
|
|
214
|
+
tools?: string[];
|
|
215
|
+
customTools?: unknown[];
|
|
216
|
+
modelRuntime?: unknown;
|
|
217
|
+
resourceLoader?: unknown;
|
|
218
|
+
}): {
|
|
219
|
+
cwd: string;
|
|
220
|
+
model: unknown;
|
|
221
|
+
thinkingLevel: unknown;
|
|
222
|
+
tools?: string[];
|
|
223
|
+
customTools?: unknown[];
|
|
224
|
+
modelRuntime?: unknown;
|
|
225
|
+
resourceLoader?: unknown;
|
|
226
|
+
} {
|
|
227
|
+
const tools = input.tools && input.tools.length > 0 ? [...input.tools] : undefined;
|
|
228
|
+
const customTools = input.customTools?.map((customTool) => {
|
|
229
|
+
const tool = customTool as { name?: unknown; promptGuidelines?: string[] };
|
|
230
|
+
if (tool.name === "contact_supervisor") {
|
|
231
|
+
return {
|
|
232
|
+
...tool,
|
|
233
|
+
promptGuidelines: [...(tool.promptGuidelines ?? []), CHILD_BEHAVIOR_GUIDELINES],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return customTool;
|
|
237
|
+
});
|
|
238
|
+
if (tools && customTools) {
|
|
239
|
+
for (const customTool of customTools) {
|
|
240
|
+
const name = (customTool as { name?: unknown }).name;
|
|
241
|
+
if (typeof name === "string" && !tools.includes(name)) tools.push(name);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
cwd: input.cwd,
|
|
246
|
+
model: input.model,
|
|
247
|
+
thinkingLevel: input.thinkingLevel,
|
|
248
|
+
...(tools ? { tools } : {}),
|
|
249
|
+
...(customTools && customTools.length > 0 ? { customTools } : {}),
|
|
250
|
+
...(input.modelRuntime ? { modelRuntime: input.modelRuntime } : {}),
|
|
251
|
+
...(input.resourceLoader ? { resourceLoader: input.resourceLoader } : {}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Build the CreateSessionFn for InProcessRunner. All deps are getters so
|
|
257
|
+
* model/cwd changes in the parent session are picked up per child.
|
|
258
|
+
*/
|
|
259
|
+
export function createPiSessionFn(deps: PiRuntimeDeps): CreateSessionFn {
|
|
260
|
+
return async (req: ChildRunRequest): Promise<ChildSessionAdapter> => {
|
|
261
|
+
const pi = await importPi();
|
|
262
|
+
const resolved = resolveModel(deps, req);
|
|
263
|
+
const thinkingLevel =
|
|
264
|
+
(resolved.thinkingOverride as ExtensionContext["thinkingLevel"]) ??
|
|
265
|
+
req.agent.thinking ??
|
|
266
|
+
deps.getParentThinkingLevel();
|
|
267
|
+
const cwd = deps.getCwd();
|
|
268
|
+
const resourceLoader = new pi.DefaultResourceLoader(
|
|
269
|
+
childResourceLoaderOptions({ cwd, agentDir: pi.getAgentDir() }),
|
|
270
|
+
);
|
|
271
|
+
await resourceLoader.reload();
|
|
272
|
+
const customTools = deps.customTools?.(req) ?? [];
|
|
273
|
+
const modelRuntime = deps.getModelRuntime?.();
|
|
274
|
+
const options = childSessionCreateOptions({
|
|
275
|
+
cwd,
|
|
276
|
+
model: resolved.model,
|
|
277
|
+
thinkingLevel,
|
|
278
|
+
tools: req.agent.tools.length > 0 ? [...req.agent.tools] : undefined,
|
|
279
|
+
customTools: customTools.length > 0 ? customTools : undefined,
|
|
280
|
+
modelRuntime,
|
|
281
|
+
resourceLoader,
|
|
282
|
+
});
|
|
283
|
+
const { session } = await pi.createAgentSession({
|
|
284
|
+
...options,
|
|
285
|
+
sessionManager: pi.SessionManager.inMemory(cwd),
|
|
286
|
+
} as never);
|
|
287
|
+
const resolvedModel = resolved.model
|
|
288
|
+
? `${resolved.model.provider}/${resolved.model.id}`
|
|
289
|
+
: undefined;
|
|
290
|
+
return wrapSession(session, {
|
|
291
|
+
...(resolved.warning !== undefined ? { warning: resolved.warning } : {}),
|
|
292
|
+
...(resolvedModel !== undefined ? { resolvedModel } : {}),
|
|
293
|
+
});
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution engines for the subagent tool (design doc §4.6):
|
|
3
|
+
*
|
|
4
|
+
* - `runTasks`: parallel worker pool — `concurrency` slots, results preserved
|
|
5
|
+
* in tasks-array ordinal order, `failFast` stops only not-yet-started tasks
|
|
6
|
+
* (already-started ones run to completion).
|
|
7
|
+
* - `runChain`: sequential execution with `{previous}` / `{outputs.<label>}`
|
|
8
|
+
* interpolation. Unknown label references are rejected up front (before any
|
|
9
|
+
* child starts). A failed step aborts the chain; remaining steps are marked
|
|
10
|
+
* interrupted.
|
|
11
|
+
*
|
|
12
|
+
* Both are pi-free and registry-free: the caller injects `startChild`.
|
|
13
|
+
*/
|
|
14
|
+
import type { ChildResult } from "./types";
|
|
15
|
+
|
|
16
|
+
export interface StartChildContext {
|
|
17
|
+
/**
|
|
18
|
+
* True once a fail_fast cancellation is in effect. Checked by the registry
|
|
19
|
+
* after global admission, so children queued for a concurrency slot are
|
|
20
|
+
* still cancelled before they spawn a session.
|
|
21
|
+
*/
|
|
22
|
+
cancelled: () => boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunTasksOptions<T> {
|
|
26
|
+
concurrency: number; // worker slots, >= 1
|
|
27
|
+
failFast: boolean;
|
|
28
|
+
startChild: (task: T, ordinal: number, ctx: StartChildContext) => Promise<ChildResult>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cancelledResult(): ChildResult {
|
|
32
|
+
return { status: "interrupted", text: "", error: "cancelled (fail_fast)", durationMs: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function failureResult(err: unknown): ChildResult {
|
|
36
|
+
return {
|
|
37
|
+
status: "failed",
|
|
38
|
+
text: "",
|
|
39
|
+
error: err instanceof Error ? err.message : String(err),
|
|
40
|
+
durationMs: 0,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Run all tasks through a worker pool; the result array follows task order. */
|
|
45
|
+
export async function runTasks<T>(
|
|
46
|
+
tasks: readonly T[],
|
|
47
|
+
opts: RunTasksOptions<T>,
|
|
48
|
+
): Promise<ChildResult[]> {
|
|
49
|
+
const results = new Array<ChildResult>(tasks.length);
|
|
50
|
+
const workerCount = Math.max(1, Math.min(Math.floor(opts.concurrency), tasks.length));
|
|
51
|
+
let next = 0;
|
|
52
|
+
let failed = false;
|
|
53
|
+
|
|
54
|
+
const worker = async (): Promise<void> => {
|
|
55
|
+
for (;;) {
|
|
56
|
+
const ordinal = next++;
|
|
57
|
+
if (ordinal >= tasks.length) return;
|
|
58
|
+
if (opts.failFast && failed) {
|
|
59
|
+
// Never submitted: the registry reconciles the pending record via
|
|
60
|
+
// finalizeRun; here we only fill the result slot.
|
|
61
|
+
results[ordinal] = cancelledResult();
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
results[ordinal] = await opts.startChild(tasks[ordinal], ordinal, {
|
|
66
|
+
cancelled: () => opts.failFast && failed,
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
results[ordinal] = failureResult(err);
|
|
70
|
+
}
|
|
71
|
+
if (results[ordinal].status !== "completed") failed = true;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
76
|
+
return results;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Chain
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export interface ChainStep {
|
|
84
|
+
prompt: string;
|
|
85
|
+
label?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface RunChainOptions<T extends ChainStep> {
|
|
89
|
+
startChild: (step: T, ordinal: number, interpolatedPrompt: string) => Promise<ChildResult>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const REF_PATTERN = /\{previous\}|\{outputs\.([A-Za-z0-9_-]+)\}/g;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate every step's references before anything starts (§4.6: unknown
|
|
96
|
+
* label reference -> immediate error, nothing launches).
|
|
97
|
+
*/
|
|
98
|
+
export function validateChainSteps(steps: readonly ChainStep[]): void {
|
|
99
|
+
const defined = new Set<string>();
|
|
100
|
+
for (let i = 0; i < steps.length; i++) {
|
|
101
|
+
const prompt = steps[i].prompt;
|
|
102
|
+
for (const match of prompt.matchAll(REF_PATTERN)) {
|
|
103
|
+
if (match[0] === "{previous}") {
|
|
104
|
+
if (i === 0) {
|
|
105
|
+
throw new Error(`chain step ${i + 1}: {previous} referenced but there is no previous step`);
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
const label = match[1];
|
|
109
|
+
if (!defined.has(label)) {
|
|
110
|
+
const known = [...defined].join(", ") || "(none)";
|
|
111
|
+
throw new Error(
|
|
112
|
+
`chain step ${i + 1}: unknown label reference {outputs.${label}} — labels defined by earlier steps: ${known}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const label = steps[i].label;
|
|
118
|
+
if (label !== undefined) {
|
|
119
|
+
if (defined.has(label)) {
|
|
120
|
+
throw new Error(`chain step ${i + 1}: duplicate label "${label}"`);
|
|
121
|
+
}
|
|
122
|
+
defined.add(label);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Substitute {previous} and {outputs.<label>} with prior step result texts. */
|
|
128
|
+
export function interpolateChainPrompt(
|
|
129
|
+
prompt: string,
|
|
130
|
+
previous: string | undefined,
|
|
131
|
+
outputs: ReadonlyMap<string, string>,
|
|
132
|
+
): string {
|
|
133
|
+
return prompt.replace(REF_PATTERN, (whole, label: string | undefined) => {
|
|
134
|
+
if (whole === "{previous}") return previous ?? "";
|
|
135
|
+
if (label === undefined) {
|
|
136
|
+
// Should be unreachable: the pattern only matches {previous} or
|
|
137
|
+
// {outputs.<label>}; fail loudly rather than silently.
|
|
138
|
+
throw new Error(`invalid chain reference ${whole}`);
|
|
139
|
+
}
|
|
140
|
+
const text = outputs.get(label);
|
|
141
|
+
if (text === undefined) {
|
|
142
|
+
// Should be unreachable after validateChainSteps; fail loudly anyway.
|
|
143
|
+
throw new Error(`unknown label reference {outputs.${label}}`);
|
|
144
|
+
}
|
|
145
|
+
return text;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Run steps sequentially. A non-completed step aborts the chain: remaining
|
|
151
|
+
* steps are marked interrupted without being started.
|
|
152
|
+
*/
|
|
153
|
+
export async function runChain<T extends ChainStep>(
|
|
154
|
+
steps: readonly T[],
|
|
155
|
+
opts: RunChainOptions<T>,
|
|
156
|
+
): Promise<ChildResult[]> {
|
|
157
|
+
validateChainSteps(steps);
|
|
158
|
+
const results = new Array<ChildResult>(steps.length);
|
|
159
|
+
const outputs = new Map<string, string>();
|
|
160
|
+
let previous: string | undefined;
|
|
161
|
+
let abortedAt = -1;
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < steps.length; i++) {
|
|
164
|
+
if (abortedAt >= 0) {
|
|
165
|
+
results[i] = {
|
|
166
|
+
status: "interrupted",
|
|
167
|
+
text: "",
|
|
168
|
+
error: `skipped: chain aborted after step ${abortedAt + 1} failed`,
|
|
169
|
+
durationMs: 0,
|
|
170
|
+
};
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const interpolated = interpolateChainPrompt(steps[i].prompt, previous, outputs);
|
|
174
|
+
let result: ChildResult;
|
|
175
|
+
try {
|
|
176
|
+
result = await opts.startChild(steps[i], i, interpolated);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
result = failureResult(err);
|
|
179
|
+
}
|
|
180
|
+
results[i] = result;
|
|
181
|
+
if (result.status !== "completed") {
|
|
182
|
+
abortedAt = i;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
previous = result.text;
|
|
186
|
+
const label = steps[i].label;
|
|
187
|
+
if (label !== undefined) outputs.set(label, result.text);
|
|
188
|
+
}
|
|
189
|
+
return results;
|
|
190
|
+
}
|