one-code-extension 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 +32 -0
- package/README.md +202 -0
- package/agents/explore.md +24 -0
- package/agents/general-purpose.md +21 -0
- package/agents/plan.md +22 -0
- package/docs/decisions/auto-mode.md +526 -0
- package/docs/decisions/branding.md +127 -0
- package/docs/decisions/code-review-remediation.md +106 -0
- package/docs/decisions/compaction.md +140 -0
- package/docs/decisions/distribution.md +133 -0
- package/docs/decisions/hooks.md +64 -0
- package/docs/decisions/lsp.md +58 -0
- package/docs/decisions/mcp.md +104 -0
- package/docs/decisions/memory-state.md +170 -0
- package/docs/decisions/model-policy.md +126 -0
- package/docs/decisions/modes.md +178 -0
- package/docs/decisions/skills-plugins.md +56 -0
- package/docs/decisions/subagents-workflows.md +225 -0
- package/docs/decisions/system-prompt.md +98 -0
- package/docs/decisions/tools.md +407 -0
- package/docs/decisions/tui.md +202 -0
- package/docs/decisions.md +148 -0
- package/extensions/ask-user/index.ts +124 -0
- package/extensions/ask-user/questions.ts +60 -0
- package/extensions/auto-mode/actions.ts +78 -0
- package/extensions/auto-mode/classifier.ts +388 -0
- package/extensions/auto-mode/config.ts +237 -0
- package/extensions/auto-mode/decision-log.ts +60 -0
- package/extensions/auto-mode/defaults.ts +65 -0
- package/extensions/auto-mode/instructions.ts +74 -0
- package/extensions/auto-mode/model-picker.ts +225 -0
- package/extensions/auto-mode/model-select.ts +178 -0
- package/extensions/auto-mode/paths.ts +109 -0
- package/extensions/auto-mode/pause.ts +96 -0
- package/extensions/auto-mode/prompt.ts +307 -0
- package/extensions/auto-mode/rules.ts +96 -0
- package/extensions/auto-mode/safety-floor.ts +100 -0
- package/extensions/auto-mode/sensitive.ts +137 -0
- package/extensions/auto-mode/shell-analysis.ts +702 -0
- package/extensions/background/index.ts +405 -0
- package/extensions/background/registry.ts +81 -0
- package/extensions/background/wakeup.ts +41 -0
- package/extensions/bash/background.ts +136 -0
- package/extensions/bash/index.ts +149 -0
- package/extensions/branding/assistant-marker.ts +53 -0
- package/extensions/branding/index.ts +366 -0
- package/extensions/branding/prompt-editor.ts +42 -0
- package/extensions/branding/prompt-marker.ts +29 -0
- package/extensions/branding/startup.ts +152 -0
- package/extensions/claude-compat/index.ts +50 -0
- package/extensions/claude-context/index.ts +79 -0
- package/extensions/compaction/index.ts +231 -0
- package/extensions/compaction/prompt.ts +191 -0
- package/extensions/context-management/index.ts +147 -0
- package/extensions/effort/index.ts +216 -0
- package/extensions/effort/slider.ts +253 -0
- package/extensions/exit/index.ts +28 -0
- package/extensions/file-tracker/index.ts +103 -0
- package/extensions/file-tracker/tracker.ts +145 -0
- package/extensions/hooks/debug.ts +53 -0
- package/extensions/hooks/executor.ts +132 -0
- package/extensions/hooks/index.ts +310 -0
- package/extensions/hooks/matcher.ts +72 -0
- package/extensions/hooks/plugin-hooks.ts +79 -0
- package/extensions/hooks/protocol.ts +151 -0
- package/extensions/hooks/settings.ts +163 -0
- package/extensions/hooks/trust.ts +151 -0
- package/extensions/lib/branch-restore.ts +35 -0
- package/extensions/lib/claude-context.ts +148 -0
- package/extensions/lib/deferred.ts +119 -0
- package/extensions/lib/git.ts +14 -0
- package/extensions/lib/memory.ts +219 -0
- package/extensions/lib/model-policy.ts +436 -0
- package/extensions/lib/model-tier.ts +99 -0
- package/extensions/lib/notifications.ts +18 -0
- package/extensions/lib/paths.ts +26 -0
- package/extensions/lib/per-cwd.ts +21 -0
- package/extensions/lib/persisted-output.ts +70 -0
- package/extensions/lib/pi-version.ts +54 -0
- package/extensions/lib/plugins.ts +227 -0
- package/extensions/lib/reminders.ts +212 -0
- package/extensions/lib/scratchpad.ts +68 -0
- package/extensions/lib/tui-render.ts +522 -0
- package/extensions/lsp/client.ts +278 -0
- package/extensions/lsp/format.ts +46 -0
- package/extensions/lsp/index.ts +193 -0
- package/extensions/lsp/protocol.ts +64 -0
- package/extensions/lsp/servers.ts +79 -0
- package/extensions/mcp/client.ts +205 -0
- package/extensions/mcp/config.ts +199 -0
- package/extensions/mcp/index.ts +373 -0
- package/extensions/mcp/schema.ts +161 -0
- package/extensions/memory/index.ts +90 -0
- package/extensions/notebook/index.ts +77 -0
- package/extensions/notebook/notebook.ts +108 -0
- package/extensions/permissions/index.ts +876 -0
- package/extensions/permissions/matcher.ts +455 -0
- package/extensions/permissions/modes.ts +107 -0
- package/extensions/permissions/protected-paths.ts +125 -0
- package/extensions/permissions/settings.ts +98 -0
- package/extensions/plan-mode/index.ts +209 -0
- package/extensions/plan-mode/reminder.ts +33 -0
- package/extensions/plan-mode/slug.ts +28 -0
- package/extensions/plan-mode/viewer.ts +124 -0
- package/extensions/plugins/index.ts +115 -0
- package/extensions/plugins/template.ts +42 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +24 -0
- package/extensions/skill/index.ts +170 -0
- package/extensions/subagents/agents.ts +126 -0
- package/extensions/subagents/child.ts +414 -0
- package/extensions/subagents/default-model.ts +122 -0
- package/extensions/subagents/index.ts +1119 -0
- package/extensions/subagents/model-select.ts +346 -0
- package/extensions/subagents/rpc-turns.ts +123 -0
- package/extensions/subagents/runs.ts +93 -0
- package/extensions/subagents/usage.ts +53 -0
- package/extensions/subagents/worktree.ts +69 -0
- package/extensions/system-prompt/environment.ts +35 -0
- package/extensions/system-prompt/index.ts +54 -0
- package/extensions/system-prompt/template.ts +103 -0
- package/extensions/system-prompt/tiers/common.ts +63 -0
- package/extensions/system-prompt/tiers/frontier.ts +24 -0
- package/extensions/system-prompt/tiers/low.ts +71 -0
- package/extensions/system-prompt/tiers/mid.ts +56 -0
- package/extensions/system-reminder/index.ts +37 -0
- package/extensions/tasks/index.ts +140 -0
- package/extensions/tasks/store.ts +174 -0
- package/extensions/todo/index.ts +133 -0
- package/extensions/tool-search/index.ts +168 -0
- package/extensions/tool-style/index.ts +84 -0
- package/extensions/web/index.ts +43 -0
- package/extensions/web-fetch/extract.ts +139 -0
- package/extensions/web-fetch/index.ts +291 -0
- package/extensions/web-fetch/summarize.ts +78 -0
- package/extensions/workflow/agent-session.ts +368 -0
- package/extensions/workflow/globals.ts +251 -0
- package/extensions/workflow/index.ts +364 -0
- package/extensions/workflow/journal.ts +75 -0
- package/extensions/workflow/permission-gate.ts +71 -0
- package/extensions/workflow/run-manager.ts +306 -0
- package/extensions/workflow/saved-workflows.ts +45 -0
- package/extensions/workflow/script-source.ts +201 -0
- package/extensions/workflow/types.ts +99 -0
- package/extensions/workflow/vm-runtime.ts +31 -0
- package/extensions/workflow/widget.ts +56 -0
- package/extensions/worktree/index.ts +266 -0
- package/extensions/worktree/rewrite.ts +59 -0
- package/package.json +101 -0
- package/themes/one-code-light.json +76 -0
- package/themes/one-code.json +76 -0
- package/types/app.d.ts +5 -0
- package/types/community-packages.d.ts +12 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process subagent execution — the one place the workflow extension
|
|
3
|
+
* touches pi's SDK. Each agent() call becomes a real `createAgentSession()`
|
|
4
|
+
* with an in-memory session; the run shares a single ModelRuntime and
|
|
5
|
+
* DefaultResourceLoader across all its agents (building either per agent is
|
|
6
|
+
* expensive and, for the loader, re-runs every extension factory).
|
|
7
|
+
*
|
|
8
|
+
* The shared loader uses `noExtensions: true`, which structurally blocks
|
|
9
|
+
* recursive orchestration (no workflow tool inside subagents) — and would
|
|
10
|
+
* also drop One Code's permission gate, so `permissionGateFactory` is passed
|
|
11
|
+
* via `extensionFactories`, which DefaultResourceLoader always loads.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
createAgentSession,
|
|
18
|
+
DefaultResourceLoader,
|
|
19
|
+
getAgentDir,
|
|
20
|
+
ModelRuntime,
|
|
21
|
+
SessionManager,
|
|
22
|
+
type ToolDefinition,
|
|
23
|
+
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
25
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
26
|
+
import { Type } from "typebox";
|
|
27
|
+
import { agentDirs, type AgentDefinition, discoverAgents } from "../subagents/agents.ts";
|
|
28
|
+
import { expensiveModelGate, resolveSubagentModel, subagentModelMenu } from "../subagents/model-select.ts";
|
|
29
|
+
import { cleanupWorktree, createWorktree, isGitRepo, type Worktree } from "../subagents/worktree.ts";
|
|
30
|
+
import { permissionGateFactory } from "./permission-gate.ts";
|
|
31
|
+
import type { AgentCallOptions, AgentCallResult, AgentEffort } from "./types.ts";
|
|
32
|
+
import { WorkflowScriptError } from "./types.ts";
|
|
33
|
+
|
|
34
|
+
const MAX_SCHEMA_RETRIES = 2;
|
|
35
|
+
const OUTPUT_CAP = 50_000;
|
|
36
|
+
|
|
37
|
+
export interface AgentRunnerOptions {
|
|
38
|
+
cwd: string;
|
|
39
|
+
/** Session model, retained as the final fallback. */
|
|
40
|
+
defaultModel: unknown;
|
|
41
|
+
/** `/subagent` user/managed default; automatic role selection follows it. */
|
|
42
|
+
configuredDefaultModel?: string;
|
|
43
|
+
defaultEffort?: AgentEffort | string;
|
|
44
|
+
/** Surface model-resolution notices (fallbacks, provider crossings) in the run log. */
|
|
45
|
+
onNotice?: (message: string) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Shared per-run spawn state. Create once per workflow run, `dispose()` when
|
|
50
|
+
* the run ends.
|
|
51
|
+
*/
|
|
52
|
+
const THINKING_SUFFIX = /^(off|minimal|low|medium|high|xhigh|max)$/i;
|
|
53
|
+
|
|
54
|
+
export interface WorkflowModelInput {
|
|
55
|
+
opts: AgentCallOptions;
|
|
56
|
+
agentModel?: string;
|
|
57
|
+
configuredDefaultModel?: string;
|
|
58
|
+
sessionModel?: Model<Api>;
|
|
59
|
+
available: Model<Api>[];
|
|
60
|
+
defaultEffort?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Pure workflow-facing wrapper around the shared subagent resolver. */
|
|
64
|
+
export function resolveWorkflowAgentModel(input: WorkflowModelInput): {
|
|
65
|
+
model?: Model<Api>;
|
|
66
|
+
thinkingLevel: string | undefined;
|
|
67
|
+
notices: string[];
|
|
68
|
+
} {
|
|
69
|
+
let requested = input.opts.model;
|
|
70
|
+
let suffixLevel: string | undefined;
|
|
71
|
+
if (requested) {
|
|
72
|
+
const colon = requested.lastIndexOf(":");
|
|
73
|
+
if (colon > 0 && THINKING_SUFFIX.test(requested.slice(colon + 1))) {
|
|
74
|
+
suffixLevel = requested.slice(colon + 1).toLowerCase();
|
|
75
|
+
requested = requested.slice(0, colon);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const resolution = resolveSubagentModel({
|
|
80
|
+
requested,
|
|
81
|
+
agentModel: input.agentModel,
|
|
82
|
+
configuredDefault: input.configuredDefaultModel,
|
|
83
|
+
sessionModel: input.sessionModel,
|
|
84
|
+
available: input.available,
|
|
85
|
+
});
|
|
86
|
+
if (resolution.unresolved) {
|
|
87
|
+
const fallback = resolveSubagentModel({
|
|
88
|
+
configuredDefault: input.configuredDefaultModel,
|
|
89
|
+
sessionModel: input.sessionModel,
|
|
90
|
+
available: input.available,
|
|
91
|
+
});
|
|
92
|
+
const menu = subagentModelMenu({
|
|
93
|
+
available: input.available,
|
|
94
|
+
sessionModel: input.sessionModel,
|
|
95
|
+
defaultModel: fallback.model,
|
|
96
|
+
defaultSource: fallback.source,
|
|
97
|
+
});
|
|
98
|
+
throw new WorkflowScriptError(
|
|
99
|
+
`agent() model "${resolution.unresolved}" is not available.\n${menu.join("\n")}\nAny exact provider/model-id also resolves.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
const gate = expensiveModelGate(resolution, input.sessionModel, input.opts.allowExpensive);
|
|
103
|
+
if (gate) {
|
|
104
|
+
throw new WorkflowScriptError(
|
|
105
|
+
`${gate}\nIf the user explicitly asked for this model, pass allowExpensive: true; otherwise pick a cheaper model or omit opts.model.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
model: resolution.model,
|
|
110
|
+
thinkingLevel: input.opts.effort ?? suffixLevel ?? input.defaultEffort,
|
|
111
|
+
notices: resolution.notices,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export class AgentRunner {
|
|
116
|
+
private readonly options: AgentRunnerOptions;
|
|
117
|
+
private readonly modelRuntime: ModelRuntime;
|
|
118
|
+
private readonly loader: DefaultResourceLoader;
|
|
119
|
+
private readonly agentCatalog: AgentDefinition[];
|
|
120
|
+
private readonly availableModels: Model<Api>[];
|
|
121
|
+
/** Per-agentType loader cache — building one re-runs every extension factory, so each
|
|
122
|
+
* agentType's loader is built once (lazily, on first use) and reused for the rest of the run. */
|
|
123
|
+
private readonly loadersByAgentType = new Map<string, Promise<DefaultResourceLoader>>();
|
|
124
|
+
|
|
125
|
+
private constructor(
|
|
126
|
+
options: AgentRunnerOptions,
|
|
127
|
+
modelRuntime: ModelRuntime,
|
|
128
|
+
loader: DefaultResourceLoader,
|
|
129
|
+
agentCatalog: AgentDefinition[],
|
|
130
|
+
availableModels: Model<Api>[],
|
|
131
|
+
) {
|
|
132
|
+
this.options = options;
|
|
133
|
+
this.modelRuntime = modelRuntime;
|
|
134
|
+
this.loader = loader;
|
|
135
|
+
this.agentCatalog = agentCatalog;
|
|
136
|
+
this.availableModels = availableModels;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
static async create(options: AgentRunnerOptions): Promise<AgentRunner> {
|
|
140
|
+
const agentDir = getAgentDir();
|
|
141
|
+
const modelRuntime = await ModelRuntime.create({
|
|
142
|
+
authPath: join(agentDir, "auth.json"),
|
|
143
|
+
modelsPath: join(agentDir, "models.json"),
|
|
144
|
+
});
|
|
145
|
+
const loader = new DefaultResourceLoader({
|
|
146
|
+
cwd: options.cwd,
|
|
147
|
+
agentDir,
|
|
148
|
+
noExtensions: true,
|
|
149
|
+
extensionFactories: [permissionGateFactory(options.cwd, os.homedir())],
|
|
150
|
+
});
|
|
151
|
+
await loader.reload();
|
|
152
|
+
const agentCatalog = discoverAgents(agentDirs(options.cwd, os.homedir()));
|
|
153
|
+
const availableModels = [...(await modelRuntime.getAvailable())];
|
|
154
|
+
return new AgentRunner(options, modelRuntime, loader, agentCatalog, availableModels);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Same resolution the subagent tool uses (see subagents/model-select.ts):
|
|
159
|
+
* aliases stay within the session's provider/vendor, exact references
|
|
160
|
+
* resolve anywhere but a provider crossing is logged, and pi's cross-provider
|
|
161
|
+
* fuzzy matcher is never consulted. A trailing ":level" effort suffix
|
|
162
|
+
* (pi's `--model sonnet:high` convention) is honoured before resolving.
|
|
163
|
+
*/
|
|
164
|
+
private resolveModel(opts: AgentCallOptions, agentModel?: string): { model: unknown; thinkingLevel: string | undefined } {
|
|
165
|
+
const resolution = resolveWorkflowAgentModel({
|
|
166
|
+
opts,
|
|
167
|
+
agentModel,
|
|
168
|
+
configuredDefaultModel: this.options.configuredDefaultModel,
|
|
169
|
+
sessionModel: this.options.defaultModel as Model<Api> | undefined,
|
|
170
|
+
available: this.availableModels,
|
|
171
|
+
defaultEffort: this.options.defaultEffort as string | undefined,
|
|
172
|
+
});
|
|
173
|
+
for (const notice of resolution.notices) this.options.onNotice?.(notice);
|
|
174
|
+
return { model: resolution.model ?? this.options.defaultModel, thinkingLevel: resolution.thinkingLevel };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async run(prompt: string, opts: AgentCallOptions, signal: AbortSignal): Promise<AgentCallResult> {
|
|
178
|
+
let agentDef: AgentDefinition | undefined;
|
|
179
|
+
if (opts.agentType) {
|
|
180
|
+
agentDef = this.agentCatalog.find((a) => a.name === opts.agentType);
|
|
181
|
+
if (!agentDef) {
|
|
182
|
+
const known = this.agentCatalog.map((a) => a.name).join(", ") || "(none)";
|
|
183
|
+
throw new WorkflowScriptError(`agent() agentType "${opts.agentType}" is not defined. Known agents: ${known}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const modelSpec = this.resolveModel(opts, agentDef?.model);
|
|
188
|
+
|
|
189
|
+
let worktree: Worktree | undefined;
|
|
190
|
+
let cwd = this.options.cwd;
|
|
191
|
+
if (opts.isolation === "worktree") {
|
|
192
|
+
if (!(await isGitRepo(this.options.cwd))) {
|
|
193
|
+
throw new WorkflowScriptError('isolation: "worktree" needs a git repository');
|
|
194
|
+
}
|
|
195
|
+
worktree = await createWorktree(this.options.cwd, opts.label ?? "wf-agent");
|
|
196
|
+
cwd = worktree.path;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const capture: { called: boolean; value: unknown } = { called: false, value: undefined };
|
|
200
|
+
const customTools: ToolDefinition[] = opts.schema ? [buildStructuredOutputTool(opts.schema, capture)] : [];
|
|
201
|
+
|
|
202
|
+
const loader = agentDef ? await this.loaderForAgentType(agentDef) : this.loader;
|
|
203
|
+
|
|
204
|
+
const { session } = await createAgentSession({
|
|
205
|
+
cwd,
|
|
206
|
+
agentDir: getAgentDir(),
|
|
207
|
+
modelRuntime: this.modelRuntime,
|
|
208
|
+
model: modelSpec.model as never,
|
|
209
|
+
thinkingLevel: modelSpec.thinkingLevel as never,
|
|
210
|
+
tools: agentDef?.tools,
|
|
211
|
+
customTools,
|
|
212
|
+
resourceLoader: loader,
|
|
213
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const onAbort = () => void session.abort();
|
|
217
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
218
|
+
try {
|
|
219
|
+
await session.prompt(this.buildPrompt(prompt, Boolean(opts.schema)));
|
|
220
|
+
if (signal.aborted) throw new WorkflowScriptError("aborted");
|
|
221
|
+
|
|
222
|
+
let value: unknown;
|
|
223
|
+
if (opts.schema) {
|
|
224
|
+
value = await this.resolveStructuredOutput(session, capture, signal);
|
|
225
|
+
} else {
|
|
226
|
+
value = finalAssistantText(session.messages);
|
|
227
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
228
|
+
throw new Error("subagent produced no output");
|
|
229
|
+
}
|
|
230
|
+
value = (value as string).slice(0, OUTPUT_CAP);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const stats = session.getSessionStats();
|
|
234
|
+
// cleanupWorktree keeps trees holding uncommitted changes; report those.
|
|
235
|
+
let worktreePath: string | undefined;
|
|
236
|
+
if (worktree) {
|
|
237
|
+
const removed = await cleanupWorktree(this.options.cwd, worktree);
|
|
238
|
+
if (!removed) worktreePath = worktree.path;
|
|
239
|
+
worktree = undefined;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
value,
|
|
243
|
+
tokens: { input: stats.tokens.input, output: stats.tokens.output, total: stats.tokens.total },
|
|
244
|
+
cost: stats.cost,
|
|
245
|
+
worktreePath,
|
|
246
|
+
};
|
|
247
|
+
} finally {
|
|
248
|
+
signal.removeEventListener("abort", onAbort);
|
|
249
|
+
session.dispose();
|
|
250
|
+
if (worktree) await cleanupWorktree(this.options.cwd, worktree);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private buildPrompt(prompt: string, structured: boolean): string {
|
|
255
|
+
if (!structured) {
|
|
256
|
+
return `${prompt}\n\nYour final message is returned verbatim to an orchestration script, not shown to a human — reply with the requested data/report only.`;
|
|
257
|
+
}
|
|
258
|
+
return `${prompt}\n\nWhen you are done, you MUST call the \`structured_output\` tool exactly once with your final result. Its arguments are the only output the caller receives.`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async resolveStructuredOutput(
|
|
262
|
+
session: { prompt(text: string): Promise<void>; messages: AgentMessage[] },
|
|
263
|
+
capture: { called: boolean; value: unknown },
|
|
264
|
+
signal: AbortSignal,
|
|
265
|
+
): Promise<unknown> {
|
|
266
|
+
for (let attempt = 0; attempt < MAX_SCHEMA_RETRIES && !capture.called; attempt++) {
|
|
267
|
+
if (signal.aborted) throw new WorkflowScriptError("aborted");
|
|
268
|
+
await session.prompt("You have not produced your result yet. Call the `structured_output` tool now with your final answer.");
|
|
269
|
+
}
|
|
270
|
+
if (capture.called) return capture.value;
|
|
271
|
+
// Last resort: extract a JSON object from the final assistant text.
|
|
272
|
+
const text = finalAssistantText(session.messages);
|
|
273
|
+
const extracted = extractJsonObject(text);
|
|
274
|
+
if (extracted !== undefined) return extracted;
|
|
275
|
+
throw new Error("subagent never produced structured output");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Builds (once) and reuses the loader for this agentType, keyed by agent name. */
|
|
279
|
+
private loaderForAgentType(agentDef: AgentDefinition): Promise<DefaultResourceLoader> {
|
|
280
|
+
let pending = this.loadersByAgentType.get(agentDef.name);
|
|
281
|
+
if (!pending) {
|
|
282
|
+
pending = this.buildLoaderWithSystemPrompt(agentDef.systemPrompt);
|
|
283
|
+
// A failed build must not poison the cache — let the next call retry.
|
|
284
|
+
pending.catch(() => this.loadersByAgentType.delete(agentDef.name));
|
|
285
|
+
this.loadersByAgentType.set(agentDef.name, pending);
|
|
286
|
+
}
|
|
287
|
+
return pending;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private async buildLoaderWithSystemPrompt(systemPrompt: string): Promise<DefaultResourceLoader> {
|
|
291
|
+
const loader = new DefaultResourceLoader({
|
|
292
|
+
cwd: this.options.cwd,
|
|
293
|
+
agentDir: getAgentDir(),
|
|
294
|
+
noExtensions: true,
|
|
295
|
+
extensionFactories: [permissionGateFactory(this.options.cwd, os.homedir())],
|
|
296
|
+
systemPrompt,
|
|
297
|
+
});
|
|
298
|
+
await loader.reload();
|
|
299
|
+
return loader;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
dispose(): void {
|
|
303
|
+
// ModelRuntime/loader(s) hold no OS resources that need explicit teardown today;
|
|
304
|
+
// this hook exists so run-manager can stay correct if that changes. The
|
|
305
|
+
// per-agentType cache is dropped alongside the base loader.
|
|
306
|
+
this.loadersByAgentType.clear();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Terminating tool capturing schema-validated output (pi validates params pre-execute). */
|
|
311
|
+
function buildStructuredOutputTool(schema: Record<string, unknown>, capture: { called: boolean; value: unknown }): ToolDefinition {
|
|
312
|
+
if (schema.type !== "object" || typeof schema.properties !== "object") {
|
|
313
|
+
throw new WorkflowScriptError("agent() schema must be a JSON Schema with top-level type \"object\" and properties");
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
name: "structured_output",
|
|
317
|
+
label: "Structured Output",
|
|
318
|
+
description: "Return the final machine-readable result for this task. Call exactly once, as your last action.",
|
|
319
|
+
parameters: Type.Unsafe(schema as never) as never,
|
|
320
|
+
async execute(_toolCallId: string, params: unknown) {
|
|
321
|
+
capture.called = true;
|
|
322
|
+
capture.value = params;
|
|
323
|
+
return {
|
|
324
|
+
content: [{ type: "text" as const, text: "Structured output received." }],
|
|
325
|
+
details: params,
|
|
326
|
+
terminate: true,
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
} as ToolDefinition;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function finalAssistantText(messages: AgentMessage[]): string {
|
|
333
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
334
|
+
const message = messages[i];
|
|
335
|
+
if (message.role !== "assistant") continue;
|
|
336
|
+
const content = (message as { content: unknown }).content;
|
|
337
|
+
if (typeof content === "string") return content;
|
|
338
|
+
if (Array.isArray(content)) {
|
|
339
|
+
const text = content
|
|
340
|
+
.filter((b): b is { type: "text"; text: string } => (b as { type?: string }).type === "text")
|
|
341
|
+
.map((b) => b.text)
|
|
342
|
+
.join("");
|
|
343
|
+
if (text.trim()) return text;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return "";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Pull the first parseable JSON object/array out of free text (```json fences first). */
|
|
350
|
+
export function extractJsonObject(text: string): unknown {
|
|
351
|
+
const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
|
352
|
+
const braceIndex = text.indexOf("{");
|
|
353
|
+
const bracketIndex = text.indexOf("[");
|
|
354
|
+
const candidates = [
|
|
355
|
+
fenced?.[1],
|
|
356
|
+
braceIndex >= 0 ? text.slice(braceIndex) : undefined,
|
|
357
|
+
bracketIndex >= 0 ? text.slice(bracketIndex) : undefined,
|
|
358
|
+
];
|
|
359
|
+
for (const candidate of candidates) {
|
|
360
|
+
if (!candidate) continue;
|
|
361
|
+
try {
|
|
362
|
+
return JSON.parse(candidate.trim());
|
|
363
|
+
} catch {
|
|
364
|
+
// Try the next candidate form.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Script-visible globals (pure, dependency-injected). Everything the vm
|
|
3
|
+
* script can touch — agent/parallel/pipeline/phase/log/console/args/budget —
|
|
4
|
+
* is built here around one injected AgentCallFn, so the whole orchestration
|
|
5
|
+
* semantics (concurrency, caps, null-on-failure, replay) is unit-testable
|
|
6
|
+
* with a fake agent runner.
|
|
7
|
+
*
|
|
8
|
+
* Claude Code semantics implemented here:
|
|
9
|
+
* - agent() resolves null when the underlying agent fails; caps/budget/abort
|
|
10
|
+
* violations throw (they must unwind the script).
|
|
11
|
+
* - parallel() takes thunks, is a barrier, and maps a throwing thunk to null.
|
|
12
|
+
* - pipeline() has no barrier between stages; stage callbacks receive
|
|
13
|
+
* (prev, originalItem, index); a throwing stage drops that item to null.
|
|
14
|
+
* - callIndex is assigned synchronously at invocation time, which is what
|
|
15
|
+
* makes journal replay deterministic under parallel().
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { hashAgentCall } from "./journal.ts";
|
|
19
|
+
import type { AgentCallFn, AgentCallOptions, BudgetSnapshot, JournalEntry, RunProgressEvent } from "./types.ts";
|
|
20
|
+
import { WorkflowScriptError } from "./types.ts";
|
|
21
|
+
import type { ReplayCursor } from "./journal.ts";
|
|
22
|
+
|
|
23
|
+
export const MAX_AGENTS_PER_RUN = 1000;
|
|
24
|
+
export const MAX_ITEMS_PER_CALL = 4096;
|
|
25
|
+
export const MAX_CONCURRENCY = 16;
|
|
26
|
+
|
|
27
|
+
/** Promise-queue limiter: at most `limit` callbacks in flight, FIFO overflow. */
|
|
28
|
+
export function createLimiter(limit: number): <T>(fn: () => Promise<T>) => Promise<T> {
|
|
29
|
+
let active = 0;
|
|
30
|
+
const queue: Array<() => void> = [];
|
|
31
|
+
const release = () => {
|
|
32
|
+
active--;
|
|
33
|
+
queue.shift()?.();
|
|
34
|
+
};
|
|
35
|
+
return async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
36
|
+
if (active >= limit) await new Promise<void>((resolve) => queue.push(resolve));
|
|
37
|
+
active++;
|
|
38
|
+
try {
|
|
39
|
+
return await fn();
|
|
40
|
+
} finally {
|
|
41
|
+
release();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ScriptGlobalsOptions {
|
|
47
|
+
agentCall: AgentCallFn;
|
|
48
|
+
args: unknown;
|
|
49
|
+
/** null = no budget ceiling. */
|
|
50
|
+
budgetTotal: number | null;
|
|
51
|
+
concurrency: number;
|
|
52
|
+
maxAgents?: number;
|
|
53
|
+
signal: AbortSignal;
|
|
54
|
+
onEvent: (event: RunProgressEvent) => void;
|
|
55
|
+
onJournal?: (entry: JournalEntry) => void;
|
|
56
|
+
replay?: ReplayCursor;
|
|
57
|
+
/** Wired for top-level runs only; nested workflow() calls get none (one level of nesting). */
|
|
58
|
+
runWorkflow?: (nameOrRef: unknown, childArgs: unknown) => Promise<unknown>;
|
|
59
|
+
/** Injected clock for journal timestamps (the vm blocks Date.now, the host doesn't). */
|
|
60
|
+
now?: () => number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ScriptGlobals {
|
|
64
|
+
agent: (prompt: string, opts?: AgentCallOptions) => Promise<unknown>;
|
|
65
|
+
parallel: (thunks: Array<() => Promise<unknown>>) => Promise<unknown[]>;
|
|
66
|
+
pipeline: (items: unknown[], ...stages: Array<(prev: unknown, item: unknown, index: number) => unknown>) => Promise<unknown[]>;
|
|
67
|
+
workflow: (nameOrRef: unknown, childArgs?: unknown) => Promise<unknown>;
|
|
68
|
+
phase: (title: string) => void;
|
|
69
|
+
log: (message: unknown) => void;
|
|
70
|
+
console: { log: (m: unknown) => void; info: (m: unknown) => void; warn: (m: unknown) => void; error: (m: unknown) => void };
|
|
71
|
+
args: unknown;
|
|
72
|
+
budget: BudgetSnapshot;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ScriptRunState {
|
|
76
|
+
agentCount: () => number;
|
|
77
|
+
outputTokens: () => number;
|
|
78
|
+
cost: () => number;
|
|
79
|
+
currentPhase: () => string | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createScriptGlobals(options: ScriptGlobalsOptions): { globals: ScriptGlobals; state: ScriptRunState } {
|
|
83
|
+
const maxAgents = options.maxAgents ?? MAX_AGENTS_PER_RUN;
|
|
84
|
+
const limiter = createLimiter(Math.max(1, Math.min(options.concurrency, MAX_CONCURRENCY)));
|
|
85
|
+
const now = options.now ?? (() => Date.now());
|
|
86
|
+
|
|
87
|
+
let callSeq = 0;
|
|
88
|
+
let agentCount = 0;
|
|
89
|
+
let outputTokens = 0;
|
|
90
|
+
let cost = 0;
|
|
91
|
+
let currentPhase: string | undefined;
|
|
92
|
+
|
|
93
|
+
const budget: BudgetSnapshot = Object.freeze({
|
|
94
|
+
total: options.budgetTotal,
|
|
95
|
+
spent: () => outputTokens,
|
|
96
|
+
remaining: () => (options.budgetTotal === null ? Number.POSITIVE_INFINITY : Math.max(0, options.budgetTotal - outputTokens)),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const throwIfAborted = () => {
|
|
100
|
+
if (options.signal.aborted) throw new WorkflowScriptError("Workflow run was aborted");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const agent = async (prompt: string, opts: AgentCallOptions = {}): Promise<unknown> => {
|
|
104
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
105
|
+
throw new WorkflowScriptError("agent() needs a non-empty prompt string");
|
|
106
|
+
}
|
|
107
|
+
if (opts.schema !== undefined && (typeof opts.schema !== "object" || opts.schema === null)) {
|
|
108
|
+
throw new WorkflowScriptError("agent() schema must be a JSON Schema object");
|
|
109
|
+
}
|
|
110
|
+
throwIfAborted();
|
|
111
|
+
if (agentCount >= maxAgents) {
|
|
112
|
+
throw new WorkflowScriptError(`Workflow agent limit reached (${maxAgents} agents per run)`);
|
|
113
|
+
}
|
|
114
|
+
if (budget.total !== null && budget.remaining() <= 0) {
|
|
115
|
+
throw new WorkflowScriptError(`Workflow token budget exhausted (${budget.total} output tokens)`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Everything up to here — and the index/hash/phase capture — is
|
|
119
|
+
// synchronous, so invocation order fully determines replay identity.
|
|
120
|
+
const callIndex = callSeq++;
|
|
121
|
+
agentCount++;
|
|
122
|
+
const hash = hashAgentCall(prompt, opts);
|
|
123
|
+
const phase = opts.phase ?? currentPhase;
|
|
124
|
+
const label = opts.label ?? `agent ${callIndex + 1}`;
|
|
125
|
+
|
|
126
|
+
const replayed = options.replay?.match(callIndex, hash);
|
|
127
|
+
if (replayed !== undefined) {
|
|
128
|
+
outputTokens += replayed.tokens?.output ?? 0;
|
|
129
|
+
cost += replayed.cost ?? 0;
|
|
130
|
+
options.onEvent({ type: "agentEnd", callIndex, label, phase, tokens: replayed.tokens, replayed: true });
|
|
131
|
+
return replayed.value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return limiter(async () => {
|
|
135
|
+
throwIfAborted();
|
|
136
|
+
options.onEvent({ type: "agentStart", callIndex, label, phase });
|
|
137
|
+
try {
|
|
138
|
+
const result = await options.agentCall(prompt, opts);
|
|
139
|
+
outputTokens += result.tokens.output;
|
|
140
|
+
cost += result.cost;
|
|
141
|
+
options.onJournal?.({ callIndex, hash, result, timestamp: now() });
|
|
142
|
+
options.onEvent({ type: "agentEnd", callIndex, label, phase, tokens: result.tokens, cost: result.cost });
|
|
143
|
+
return result.value;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throwIfAborted();
|
|
146
|
+
// A script-authoring mistake (bad agentType/model, worktree without a
|
|
147
|
+
// git repo, malformed schema) must unwind the script, not vanish into
|
|
148
|
+
// a null the way a genuine agent failure does — same rule parallel()
|
|
149
|
+
// and pipeline() already apply. Otherwise a completed run reports a
|
|
150
|
+
// null hole with the real cause nowhere in the final report.
|
|
151
|
+
if (error instanceof WorkflowScriptError) throw error;
|
|
152
|
+
// A failed agent resolves to null (Claude Code semantics); the
|
|
153
|
+
// failure is surfaced as a progress event, not an exception.
|
|
154
|
+
options.onEvent({ type: "agentEnd", callIndex, label, phase, text: `failed: ${(error as Error).message}` });
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const parallel = async (thunks: Array<() => Promise<unknown>>): Promise<unknown[]> => {
|
|
161
|
+
if (!Array.isArray(thunks)) throw new WorkflowScriptError("parallel() takes an array of functions");
|
|
162
|
+
if (thunks.length > MAX_ITEMS_PER_CALL) {
|
|
163
|
+
throw new WorkflowScriptError(`parallel() accepts at most ${MAX_ITEMS_PER_CALL} items (got ${thunks.length})`);
|
|
164
|
+
}
|
|
165
|
+
// Invoke every thunk synchronously first so agent() callIndexes are
|
|
166
|
+
// assigned in array order regardless of completion timing.
|
|
167
|
+
const promises = thunks.map((thunk) => {
|
|
168
|
+
if (typeof thunk !== "function") {
|
|
169
|
+
return Promise.reject(new WorkflowScriptError("parallel() items must be functions returning promises"));
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return Promise.resolve(thunk());
|
|
173
|
+
} catch (error) {
|
|
174
|
+
return Promise.reject(error);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
const settled = await Promise.allSettled(promises);
|
|
178
|
+
return settled.map((s) => {
|
|
179
|
+
if (s.status === "fulfilled") return s.value;
|
|
180
|
+
if (s.reason instanceof WorkflowScriptError) throw s.reason;
|
|
181
|
+
return null;
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const pipeline = async (
|
|
186
|
+
items: unknown[],
|
|
187
|
+
...stages: Array<(prev: unknown, item: unknown, index: number) => unknown>
|
|
188
|
+
): Promise<unknown[]> => {
|
|
189
|
+
if (!Array.isArray(items)) throw new WorkflowScriptError("pipeline() takes an array of items");
|
|
190
|
+
if (items.length > MAX_ITEMS_PER_CALL) {
|
|
191
|
+
throw new WorkflowScriptError(`pipeline() accepts at most ${MAX_ITEMS_PER_CALL} items (got ${items.length})`);
|
|
192
|
+
}
|
|
193
|
+
if (stages.some((s) => typeof s !== "function")) {
|
|
194
|
+
throw new WorkflowScriptError("pipeline() stages must be functions");
|
|
195
|
+
}
|
|
196
|
+
return Promise.all(
|
|
197
|
+
items.map(async (item, index) => {
|
|
198
|
+
let prev: unknown = item;
|
|
199
|
+
for (const stage of stages) {
|
|
200
|
+
try {
|
|
201
|
+
prev = await stage(prev, item, index);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof WorkflowScriptError) throw error;
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return prev;
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const workflowFn = async (nameOrRef: unknown, childArgs?: unknown): Promise<unknown> => {
|
|
213
|
+
if (!options.runWorkflow) {
|
|
214
|
+
throw new WorkflowScriptError("workflow() nesting is one level only — this run cannot start another child workflow");
|
|
215
|
+
}
|
|
216
|
+
return options.runWorkflow(nameOrRef, childArgs);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const log = (message: unknown) => {
|
|
220
|
+
options.onEvent({ type: "log", text: String(message) });
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const globals: ScriptGlobals = {
|
|
224
|
+
agent,
|
|
225
|
+
parallel,
|
|
226
|
+
pipeline,
|
|
227
|
+
workflow: workflowFn,
|
|
228
|
+
phase: (title: string) => {
|
|
229
|
+
currentPhase = String(title);
|
|
230
|
+
options.onEvent({ type: "phase", phase: currentPhase });
|
|
231
|
+
},
|
|
232
|
+
log,
|
|
233
|
+
console: {
|
|
234
|
+
log,
|
|
235
|
+
info: log,
|
|
236
|
+
warn: (m) => log(`[warn] ${String(m)}`),
|
|
237
|
+
error: (m) => log(`[error] ${String(m)}`),
|
|
238
|
+
},
|
|
239
|
+
args: options.args,
|
|
240
|
+
budget,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const state: ScriptRunState = {
|
|
244
|
+
agentCount: () => agentCount,
|
|
245
|
+
outputTokens: () => outputTokens,
|
|
246
|
+
cost: () => cost,
|
|
247
|
+
currentPhase: () => currentPhase,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return { globals, state };
|
|
251
|
+
}
|