@qilin-agent/tool-workflow 0.0.0-stage → 3.1.3

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/lib/index.js ADDED
@@ -0,0 +1,445 @@
1
+ import z from "@qilin-agent/schemastery";
2
+ import { defineTool } from "@qilin-agent/tools";
3
+ //#region lib/types/record.js
4
+ /**
5
+ * Live-progress mirror for background workflow runs: streams the engine's
6
+ * `workflow/phase`, `workflow/log`, and member lifecycle events into the
7
+ * owning job's output ring as `log` chunks — observer-only narration the
8
+ * model's `job_output` never renders — and keeps the job's live progress
9
+ * line on the current phase. Appends against a settled job log and drop
10
+ * inside the registry, so a straggling event after settlement is harmless.
11
+ * @module @qilin-agent/tool-workflow/record
12
+ */
13
+ /**
14
+ * Create the run-to-ring mirror and subscribe the engine's live progress
15
+ * events for the runs it tracks.
16
+ * @param ctx - plugin context whose event bus carries the `workflow/*` events.
17
+ * @returns the mirror taps the tool wires around each background run.
18
+ */
19
+ function createWorkflowRecordMirror(ctx) {
20
+ const active = /* @__PURE__ */ new Map();
21
+ ctx.on("workflow/phase", (info, title) => {
22
+ const job = active.get(info.id);
23
+ if (job === void 0) return;
24
+ job.updateProgress(title);
25
+ job.append(`▸ ${title}\n`, { channel: "log" });
26
+ });
27
+ ctx.on("workflow/log", (info, message) => {
28
+ active.get(info.id)?.append(`${message}\n`, { channel: "log" });
29
+ });
30
+ ctx.on("workflow/agent-start", (info, agent) => {
31
+ active.get(info.id)?.append(`agent #${agent.seq} ${agent.label} started\n`, { channel: "log" });
32
+ });
33
+ ctx.on("workflow/agent-end", (info, agent) => {
34
+ active.get(info.id)?.append(`agent #${agent.seq} ${agent.outcome}\n`, { channel: "log" });
35
+ });
36
+ return {
37
+ start(runId, job) {
38
+ active.set(runId, job);
39
+ },
40
+ stop(runId) {
41
+ active.delete(runId);
42
+ }
43
+ };
44
+ }
45
+ //#endregion
46
+ //#region lib/types/index.js
47
+ /**
48
+ * The model-facing `workflow` tool: run a JavaScript orchestration script that fans out
49
+ * subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script
50
+ * parsing, execution, caps, and cancellation live behind `ctx.workflowEngine`
51
+ * (`@qilin-agent/workflow`), so a hardened engine swaps in without touching what the model
52
+ * sees. Foreground execution awaits `run.result` and always disposes the run; non-completed reasons
53
+ * become tool errors. `run_in_background: true` instead registers the run as an owned `ctx.jobs` job
54
+ * and returns its id immediately — the job's output ring streams live progress, and the run's value
55
+ * arrives with the job's completion notice. Presentation is an args-only generic card
56
+ * titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt
57
+ * section rather than deployment persona prose.
58
+ * @module @qilin-agent/tool-workflow
59
+ */
60
+ const name = "tool-workflow";
61
+ const inject = [
62
+ "tools",
63
+ "workflowEngine",
64
+ "systemPrompt"
65
+ ];
66
+ const Config = z.object({
67
+ toolName: z.string().default("workflow"),
68
+ maxResultChars: z.natural().min(1).default(5e4),
69
+ enableRunInBackground: z.boolean().default(true)
70
+ });
71
+ /** Render a contained recording failure without trusting the thrown value. */
72
+ function renderRecordingError(error) {
73
+ try {
74
+ return String(error);
75
+ } catch {
76
+ return "[unrenderable thrown value]";
77
+ }
78
+ }
79
+ /**
80
+ * Project active top-level workflow runs into their parent Sessions without
81
+ * letting recording failure affect tool execution.
82
+ */
83
+ function createWorkflowRecorder(ctx) {
84
+ const active = /* @__PURE__ */ new Map();
85
+ const append = (session, type, data) => {
86
+ const appendRecord = session.append.bind(session);
87
+ try {
88
+ appendRecord(type, data);
89
+ return true;
90
+ } catch (error) {
91
+ ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`);
92
+ return false;
93
+ }
94
+ };
95
+ ctx.on("workflow/agent-start", (info, agent) => {
96
+ const session = active.get(info.id);
97
+ if (session === void 0) return;
98
+ if (!append(session, "tool-workflow/agent-start", {
99
+ runId: info.id,
100
+ seq: agent.seq,
101
+ label: agent.label,
102
+ ...agent.phase === void 0 ? {} : { phase: agent.phase },
103
+ childId: agent.childId
104
+ })) active.delete(info.id);
105
+ });
106
+ ctx.on("workflow/agent-end", (info, agent) => {
107
+ const session = active.get(info.id);
108
+ if (session === void 0) return;
109
+ if (!append(session, "tool-workflow/agent-end", {
110
+ runId: info.id,
111
+ seq: agent.seq,
112
+ outcome: agent.outcome
113
+ })) active.delete(info.id);
114
+ });
115
+ return {
116
+ start(session, run) {
117
+ if (append(session, "tool-workflow/run-start", {
118
+ runId: run.id,
119
+ name: run.meta.name
120
+ })) active.set(run.id, session);
121
+ },
122
+ finish(runId, stopReason) {
123
+ const session = active.get(runId);
124
+ if (session !== void 0) append(session, "tool-workflow/run-end", {
125
+ runId,
126
+ stopReason
127
+ });
128
+ active.delete(runId);
129
+ },
130
+ abandon: (runId) => {
131
+ active.delete(runId);
132
+ }
133
+ };
134
+ }
135
+ /**
136
+ * The script-authoring contract, embedded in the tool description. This IS the
137
+ * model-facing spec: the meta block, the hooks and their exact semantics, and
138
+ * the supported schema subset.
139
+ */
140
+ const DESCRIPTION = `Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
141
+
142
+ The workflow's identity rides the \`meta\` parameter as JSON: required \`name\` (short kebab-case) and \`description\` strings, optional \`whenToUse\` string and \`phases\` array (\`{title, detail?, provider?, model?}\`). The \`script\` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO \`export const meta\` statement — meta is a parameter, not code), running with top-level await; end with \`return <value>\` — the value must be JSON-serializable and is this tool's result.
143
+
144
+ Script-body hooks:
145
+ - \`agent(prompt, opts?): Promise<any>\` — run one subagent to completion. Without \`opts.schema\` it resolves to the child's final text; with \`opts.schema\` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves \`null\` when the child fails (filter with \`.filter(Boolean)\`). Other opts: \`label\` (display), \`phase\` (progress group), and independent \`provider\`/\`model\` LLM target overrides (either may be provided alone). Anything else (\`effort\`/\`isolation\`/\`agentType\`) is rejected loudly.
146
+ - \`pipeline(items, ...stages): Promise<any[]>\` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives \`(prev, item, index)\`. An ordinary stage throw drops that ITEM to \`null\` and skips its remaining stages.
147
+ - \`parallel(thunks): Promise<any[]>\` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to \`null\`.
148
+ - \`phase(title)\` — start a progress phase; \`log(message)\` — narrate progress; \`args\` — the tool call's \`args\` input, verbatim.
149
+
150
+ Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item \`null\`.
151
+
152
+ Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them.`;
153
+ /** The pending-state card: a generic card titled by the workflow's meta name. */
154
+ function presentWorkflowCall(args) {
155
+ return {
156
+ card: "generic",
157
+ title: `workflow: ${args.meta.name}`,
158
+ rawInput: args.script
159
+ };
160
+ }
161
+ /** The completed-state card: keep the pending title; render the result content as-is. */
162
+ function presentWorkflowResult(args, result) {
163
+ return { card: "generic" };
164
+ }
165
+ /** A non-`completed` stop reason means the script did not finish cleanly. */
166
+ function stopReasonError(result) {
167
+ switch (result.stopReason) {
168
+ case "completed": return;
169
+ case "cancelled": return `workflow run was cancelled${result.error !== void 0 ? ` (${result.error})` : ""}`;
170
+ case "error": return `workflow run failed: ${result.error ?? "unknown error"}`;
171
+ /* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */
172
+ default: return `workflow run ended abnormally (${String(result.stopReason)})`;
173
+ }
174
+ }
175
+ /** Render the run's outcome text: the meta name, agent count, and the JSON value (capped). */
176
+ function renderResult(name, agentsStarted, value, maxChars) {
177
+ const rendered = JSON.stringify(value, null, 2);
178
+ const clipped = rendered.length > maxChars ? `${rendered.slice(0, maxChars)}\n… [truncated: ${rendered.length - maxChars} more characters]` : rendered;
179
+ return `workflow "${name}" completed (${agentsStarted} agent${agentsStarted === 1 ? "" : "s"}).\nReturn value:\n${clipped}`;
180
+ }
181
+ /**
182
+ * Map a settled background run onto the job outcome vocabulary. A completed
183
+ * run carries the rendered return value as the job's result; a cancelled run
184
+ * leaves the detail to the registry's kill-reason merge (the cancel reason it
185
+ * forwarded is the same string); an errored run fails with the script's
186
+ * failure message.
187
+ * @param result - settled engine result.
188
+ * @param name - the workflow's meta name, for the rendered result.
189
+ * @param maxChars - rendered-result ceiling in characters.
190
+ * @returns the outcome for the job registration.
191
+ */
192
+ function jobOutcomeOf(result, name, maxChars) {
193
+ switch (result.stopReason) {
194
+ case "completed": return {
195
+ status: "completed",
196
+ detail: `${result.agentsStarted} agent${result.agentsStarted === 1 ? "" : "s"}`,
197
+ result: renderResult(name, result.agentsStarted, result.value, maxChars)
198
+ };
199
+ case "cancelled": return { status: "killed" };
200
+ case "error": return {
201
+ status: "failed",
202
+ detail: result.error ?? "unknown error"
203
+ };
204
+ /* v8 ignore start -- defensive: WorkflowStopReason is a closed union, exhaustive by construction; a future variant fails here loudly */
205
+ default: return {
206
+ status: "failed",
207
+ detail: `workflow run ended abnormally (${String(result.stopReason)})`
208
+ };
209
+ }
210
+ }
211
+ /**
212
+ * Register a background run as an owned job. The engine run is started inside
213
+ * the job starter with no tool-step signal — the run belongs to the job, so a
214
+ * registry kill or owner teardown is what cancels it — and its settlement is
215
+ * the job's settlement: dispose, stop the mirrors, then map the stop reason
216
+ * onto the job outcome (a completed run's rendered return value rides
217
+ * `result` to the model's first read after settlement).
218
+ * @param ctx - plugin context (engine, optional jobs registry, logger).
219
+ * @param args - the validated tool call.
220
+ * @param parent - the calling agent; owns the job.
221
+ * @param recordsRun - whether this top-level call records durable run events.
222
+ * @param deps - the tool's recorder/mirror taps and the render cap.
223
+ * @returns the background result for the tool's output schema.
224
+ */
225
+ function startBackgroundRun(ctx, args, parent, recordsRun, deps) {
226
+ const jobs = ctx.get("jobs");
227
+ if (jobs === void 0) throw new Error("background jobs unavailable: load @qilin-agent/jobs and @qilin-agent/tool-jobs");
228
+ let run;
229
+ return {
230
+ kind: "background",
231
+ jobId: jobs.start({
232
+ kind: "workflow",
233
+ label: args.meta.name,
234
+ owner: parent.id,
235
+ run: (job) => {
236
+ run = ctx.workflowEngine.start({
237
+ script: args.script,
238
+ meta: args.meta,
239
+ ...args.args !== void 0 ? { args: args.args } : {},
240
+ parent
241
+ });
242
+ deps.mirror.start(run.id, job);
243
+ if (recordsRun) deps.recorder.start(parent.session, run);
244
+ return {
245
+ cancel: (reason) => {
246
+ run.cancel(reason ?? "background workflow job killed");
247
+ },
248
+ done: run.result.then(async (result) => {
249
+ try {
250
+ await run.dispose();
251
+ } catch (error) {
252
+ ctx.logger.warn(`background workflow run ${run.id} dispose failed: ${String(error)}`);
253
+ }
254
+ deps.mirror.stop(run.id);
255
+ if (recordsRun) {
256
+ deps.recorder.finish(run.id, result.stopReason);
257
+ deps.recorder.abandon(run.id);
258
+ }
259
+ return jobOutcomeOf(result, args.meta.name, deps.maxResultChars);
260
+ })
261
+ };
262
+ }
263
+ }),
264
+ runId: run.id
265
+ };
266
+ }
267
+ function apply(ctx, config) {
268
+ const { toolName, maxResultChars, enableRunInBackground } = config;
269
+ const recorder = createWorkflowRecorder(ctx);
270
+ const mirror = createWorkflowRecordMirror(ctx);
271
+ ctx.systemPrompt.section({
272
+ name: `tool:${toolName}`,
273
+ order: ctx.systemPrompt.getSectionOrder("TOOL_WORKFLOW"),
274
+ text: `Use the ${toolName} tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.`
275
+ });
276
+ ctx.tools.register(defineTool({
277
+ name: toolName,
278
+ description: DESCRIPTION,
279
+ parameters: {
280
+ script: {
281
+ type: "string",
282
+ required: true,
283
+ description: "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
284
+ },
285
+ meta: {
286
+ type: "object",
287
+ additionalProperties: true,
288
+ required: true,
289
+ description: "The workflow identity block (plain JSON — never code).",
290
+ properties: {
291
+ name: {
292
+ type: "string",
293
+ required: true,
294
+ description: "Short kebab-case workflow name."
295
+ },
296
+ description: {
297
+ type: "string",
298
+ required: true,
299
+ description: "One-line description of what the workflow does."
300
+ },
301
+ whenToUse: {
302
+ type: "string",
303
+ description: "Optional guidance on when this workflow applies."
304
+ },
305
+ phases: {
306
+ type: "array",
307
+ description: "Optional phase declarations matched by phase() calls.",
308
+ items: {
309
+ type: "object",
310
+ additionalProperties: true,
311
+ properties: {
312
+ title: {
313
+ type: "string",
314
+ required: true,
315
+ description: "The phase title phase() calls match by exact string."
316
+ },
317
+ detail: {
318
+ type: "string",
319
+ description: "Optional one-line description of the phase."
320
+ },
321
+ provider: {
322
+ type: "string",
323
+ description: "Optional provider override this phase is expected to use."
324
+ },
325
+ model: {
326
+ type: "string",
327
+ description: "Optional model override this phase is expected to use."
328
+ }
329
+ }
330
+ }
331
+ }
332
+ }
333
+ },
334
+ args: {
335
+ type: "object",
336
+ additionalProperties: true,
337
+ description: "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."
338
+ },
339
+ ...enableRunInBackground ? { run_in_background: {
340
+ type: "boolean",
341
+ description: "Run as a background job: return a job id immediately instead of waiting; the return value arrives with the completion notice."
342
+ } } : {}
343
+ },
344
+ output: {
345
+ schema: { oneOf: [{
346
+ type: "object",
347
+ additionalProperties: false,
348
+ properties: {
349
+ kind: {
350
+ type: "string",
351
+ required: true,
352
+ const: "background"
353
+ },
354
+ jobId: {
355
+ type: "string",
356
+ required: true
357
+ },
358
+ runId: {
359
+ type: "string",
360
+ required: true
361
+ }
362
+ }
363
+ }, {
364
+ type: "object",
365
+ additionalProperties: false,
366
+ properties: {
367
+ kind: {
368
+ type: "string",
369
+ required: true,
370
+ const: "foreground"
371
+ },
372
+ runId: {
373
+ type: "string",
374
+ required: true
375
+ },
376
+ agentsStarted: {
377
+ type: "integer",
378
+ required: true
379
+ },
380
+ result: {
381
+ type: "json",
382
+ required: true
383
+ }
384
+ }
385
+ }] },
386
+ render: (args, value) => [{
387
+ type: "text",
388
+ text: value.kind === "background" ? `workflow "${args.meta.name}" started in the background as job ${value.jobId}. Its return value arrives with the completion notice; check on it with job_output, stop it with job_kill.` : renderResult(args.meta.name, value.agentsStarted, value.result, maxResultChars)
389
+ }]
390
+ },
391
+ async execute(args, exec) {
392
+ const parent = exec.agent;
393
+ if (!parent) throw new Error("workflow tool requires a calling agent (exec.agent was undefined)");
394
+ if (args.run_in_background === true) {
395
+ if (!enableRunInBackground) throw new Error("run_in_background is disabled for this tool");
396
+ return startBackgroundRun(ctx, args, parent, exec.parent === void 0, {
397
+ recorder,
398
+ mirror,
399
+ maxResultChars
400
+ });
401
+ }
402
+ const run = ctx.workflowEngine.start({
403
+ script: args.script,
404
+ meta: args.meta,
405
+ ...args.args !== void 0 ? { args: args.args } : {},
406
+ parent,
407
+ signal: exec.signal
408
+ });
409
+ const recordsRun = exec.parent === void 0;
410
+ if (recordsRun) recorder.start(parent.session, run);
411
+ const onAbort = () => {
412
+ run.cancel("parent step aborted");
413
+ };
414
+ exec.signal.addEventListener("abort", onAbort, { once: true });
415
+ let result;
416
+ try {
417
+ result = await run.result;
418
+ const error = stopReasonError(result);
419
+ if (error !== void 0) throw new Error(error);
420
+ return {
421
+ kind: "foreground",
422
+ runId: run.id,
423
+ agentsStarted: result.agentsStarted,
424
+ result: result.value
425
+ };
426
+ } finally {
427
+ exec.signal.removeEventListener("abort", onAbort);
428
+ try {
429
+ await run.dispose();
430
+ if (recordsRun) {
431
+ /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */
432
+ if (result === void 0) throw new Error("workflow run settled without a result");
433
+ recorder.finish(run.id, result.stopReason);
434
+ }
435
+ } finally {
436
+ if (recordsRun) recorder.abandon(run.id);
437
+ }
438
+ }
439
+ },
440
+ presentCall: (args) => presentWorkflowCall(args),
441
+ presentResult: (args, result) => presentWorkflowResult(args, result)
442
+ }));
443
+ }
444
+ //#endregion
445
+ export { Config, apply, inject, name };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The model-facing `workflow` tool: run a JavaScript orchestration script that fans out
3
+ * subagents, and return the script's final value. It owns the model-facing schema and run lifecycle; script
4
+ * parsing, execution, caps, and cancellation live behind `ctx.workflowEngine`
5
+ * (`@qilin-agent/workflow`), so a hardened engine swaps in without touching what the model
6
+ * sees. Foreground execution awaits `run.result` and always disposes the run; non-completed reasons
7
+ * become tool errors. `run_in_background: true` instead registers the run as an owned `ctx.jobs` job
8
+ * and returns its id immediately — the job's output ring streams live progress, and the run's value
9
+ * arrives with the job's completion notice. Presentation is an args-only generic card
10
+ * titled from `meta.name`. Explicit-ask usage guidance is registered as the tool's own prompt
11
+ * section rather than deployment persona prose.
12
+ * @module @qilin-agent/tool-workflow
13
+ */
14
+ import type { Context } from '@qilin-agent/kylin';
15
+ import z from '@qilin-agent/schemastery';
16
+ declare module '@qilin-agent/jobs' {
17
+ interface JobKindMap {
18
+ workflow: 'workflow';
19
+ }
20
+ }
21
+ export declare const name = "tool-workflow";
22
+ export declare const inject: string[];
23
+ /** Config: the model-facing tool name plus result rendering caps. */
24
+ export interface Config {
25
+ /** The model-facing tool name to register (default `workflow`). */
26
+ toolName?: string;
27
+ /** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
28
+ maxResultChars?: number;
29
+ /**
30
+ * Expose `run_in_background` (default true); disabled calls are also
31
+ * rejected. A background run needs a live `ctx.jobs` registry with a
32
+ * controller serving the caller (`@qilin-agent/jobs-local` plus `@qilin-agent/tool-jobs`
33
+ * in the shipped composition); without one the call fails with the missing
34
+ * piece named.
35
+ */
36
+ enableRunInBackground?: boolean;
37
+ }
38
+ export declare const Config: z<Config>;
39
+ export declare function apply(ctx: Context, config: Config): void;
40
+ //# sourceMappingURL=index.d.ts.map