From 08403d63c2679809b565d1f26cbc38f414c6c8bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:29:46 +0000 Subject: [PATCH] feat: support workflow as default export, update SKILL.md and docs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- scripts/run-sample.test.ts | 2 +- skills/rig/SKILL.md | 12 +++---- skills/rig/references/dynamic-workflows.md | 35 +++++++++++++++++++ skills/rig/rig.ts | 30 ++++++++++++++-- .../samples/60-workflow-parallel-linter.md | 28 +++++++++++++++ src/launcher.test.ts | 2 +- 6 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 skills/rig/samples/60-workflow-parallel-linter.md diff --git a/scripts/run-sample.test.ts b/scripts/run-sample.test.ts index a5334f1..3cb5181 100644 --- a/scripts/run-sample.test.ts +++ b/scripts/run-sample.test.ts @@ -257,7 +257,7 @@ describe("skill markdown samples", () => { const runnableCode = withTypecheckModel(code); expect(code.split("\n").length).toBeLessThanOrEqual(30); expect(code).toContain("export default"); - expect(code).toContain("// Agent role:"); + expect(code).toMatch(/\/\/ (?:Agent|Workflow) role:/); expect(code).not.toContain("console.log"); expect((code.match(/^import .* from "rig";$/gm) ?? [])).toHaveLength(1); expect(code).not.toMatch(/^await\s+\w+\(/m); diff --git a/skills/rig/SKILL.md b/skills/rig/SKILL.md index e895d80..19f2e07 100644 --- a/skills/rig/SKILL.md +++ b/skills/rig/SKILL.md @@ -29,13 +29,13 @@ export default reviewDiff; ## Construction rules -1. Import current APIs from `"rig"` once and define agents with `agent({ ... })`. -2. Add a `// Agent role: ...` comment above each agent. +1. Import current APIs from `"rig"` once and define agents with `agent({ ... })` or workflows with `workflow({ ... })`. +2. Add a `// Agent role: ...` comment above each `agent()` and a `// Workflow role: ...` comment above each `workflow()`. 3. Omit `input`/`output` when free-form strings suffice; otherwise use explicit `s.*` schemas. 4. Put known workspace context in ``p`...` `` with `p.read`, `p.bash`, or another intent. Use `input` only for caller-supplied values. 5. Keep outputs strict and small; prefer `s.enum`, `s.literal`, `s.path`, and `s.int` when they express the contract. 6. Add narrow, named subagents only when delegation helps; attach them as `agents: { name }`. -7. Export exactly one root value. Do not invoke it or print its result in generated programs. +7. Export exactly one root value — an `agent` or a `workflow`. Do not invoke it or print its result in generated programs. Defaults: `name: "agent"`, `model: "small"`, `maxTurns: 4`, string input/output, and no addons. @@ -52,7 +52,7 @@ Defaults: `name: "agent"`, `model: "small"`, `maxTurns: 4`, string input/output, | String-keyed map | `s.record(value)`; keys are always `string` — do not wrap in `s.object`; use `s.record(s.int)` for count maps | | Numeric schema choice | `s.int` for counts/line numbers; `s.number` for measurements and ratios | | Optional versus nullable | `s.optional(shape)` for omission; `s.nullable(shape)` for explicit `null` | -| Custom model-callable operation | `defineTool(name, { description, parameters, handler })` | +| Deterministic TypeScript fan-out | `workflow({ meta, input?, body })` + `export default`; use `call`, `pipeline`, `parallel`, `until` inside `body` | | Structured-output retries | `maxTurns` on the agent plus `addons: repair()` | | Retry with final-turn warning | `addons: [steering(), repair()]` in that order | @@ -68,7 +68,7 @@ Prompt intents are declarative instructions, not in-process operations. Prefer f ## Runnable output -For runnable markdown, emit exactly one fenced `rig` block with one default-exported root and no required external input. Never call the root inside the fence. +For runnable markdown, emit exactly one fenced `rig` block with one default-exported root (`agent` or `workflow`) and no required external input. Never call the root inside the fence. Add a `// Agent role: ...` comment above each `agent()` and a `// Workflow role: ...` comment above each `workflow()`. Before running generated TypeScript: @@ -83,7 +83,7 @@ cat program.ts | node skills/rig/rig.ts --typecheck - Schemas use only current `s.*` helpers and constrain important output. - Every import, addon, tool, and helper follows the current API. - Every subagent is named, reachable, and narrowly scoped. -- The program has one default export and no `console.log`. +- The program has one default export (an `agent` or a `workflow`) and no `console.log`. - Linting and typechecking pass. ## Focused references diff --git a/skills/rig/references/dynamic-workflows.md b/skills/rig/references/dynamic-workflows.md index 589d2e6..facf30e 100644 --- a/skills/rig/references/dynamic-workflows.md +++ b/skills/rig/references/dynamic-workflows.md @@ -40,6 +40,41 @@ const results = await runWorkflow(audit, { `meta` describes the workflow for tools and progress displays. `input` preserves Rig schema inference in both `body` and `runWorkflow({ args })`. +## Workflow as default export + +A `workflow` can be the default export of a rig program. The launcher wraps it +in `runWorkflow` automatically — no manual call is needed: + +```ts +import { agent, s, workflow } from "rig"; + +// Agent role: check one file for linting issues. +const lintFile = agent({ + name: "lintFile", + model: "nano", + input: s.object({ file: s.path }), + output: s.object({ issues: s.array(s.string) }), + instructions: "Check the file for linting issues.", +}); + +// Workflow role: lint source files discovered at runtime. +const linter = workflow({ + meta: { name: "linter", description: "Lint TypeScript files in parallel", phases: ["Discover", "Lint"] }, + body: async ({ call, phase, pipeline }) => { + phase("Discover"); + const raw = await call.text("List TypeScript source files to lint, one path per line."); + const files = (raw ?? "").split("\n").map((f) => f.trim()).filter(Boolean); + phase("Lint"); + return pipeline(files, (file) => call(lintFile, { file }, { label: file })); + }, +}); + +export default linter; +``` + +- Omit `input` for a no-input program (inline mode) or add `input: s.object({ ... })` for file-mode programs that read stdin JSON. +- Use `agent()` with `agents:` when an LLM should improvise the coordination order. Use `workflow()` when TypeScript owns the orchestration (fan-out, branching, convergence). + ## Context | Member | Behavior | diff --git a/skills/rig/rig.ts b/skills/rig/rig.ts index 3d28b9c..36bcd05 100644 --- a/skills/rig/rig.ts +++ b/skills/rig/rig.ts @@ -1474,15 +1474,41 @@ function asRootAgent(value: unknown): AgentFn | undefined { return value as AgentFn; } +function isWorkflow(value: unknown): value is Workflow { + return ( + typeof value === "object" && + value !== null && + "meta" in value && + typeof (value as Record)["meta"] === "object" && + "body" in value && + typeof (value as Record)["body"] === "function" + ); +} + /** * Normalizes supported launcher root exports to an agent function. * Strings and prompt builders are wrapped in a default agent. + * Workflow objects are wrapped in a runWorkflow call. */ function asRootProgram(value: unknown, name: string): AgentFn | undefined { const rootAgent = asRootAgent(value); if (rootAgent) { return rootAgent; } + if (isWorkflow(value)) { + const w = value; + const hasInput = "inputSchema" in w; + const inputSchema: Schema = hasInput ? (w.inputSchema as Schema) : defaultStringSchema; + const fn = Object.assign( + async (input: unknown) => runWorkflow(w, hasInput ? { args: input } : {}), + { + inputSchema, + outputSchema: defaultStringSchema as Schema, + agentName: w.meta.name, + }, + ); + return fn as unknown as AgentFn; + } if (typeof value === "string" || value instanceof PromptBuilder) { return agent({ name, instructions: value }) as AgentFn; } @@ -1691,7 +1717,7 @@ async function runRootAgentFromStdin( const mod = await import(pathToFileURL(resolvedPath).href); const rootAgent = asRootProgram(mod.default, "launcher-root"); if (!rootAgent) { - throw new Error("Expected program to export a root value (agent, string, or prompt builder) as default export."); + throw new Error("Expected program to export a root value (agent, workflow, string, or prompt builder) as default export."); } const result = await rootAgent(coerceStdinInput(rootAgent, prompt)); @@ -1725,7 +1751,7 @@ async function runProgramCodeFromStdin( const mod = await import(pathToFileURL(tempProgramPath).href); const rootAgent = asRootProgram(mod.default, "launcher-inline-root"); if (!rootAgent) { - throw new Error("Expected program to export a root value (agent, string, or prompt builder) as default export."); + throw new Error("Expected program to export a root value (agent, workflow, string, or prompt builder) as default export."); } const input = noInputInvocation(rootAgent); if (input === undefined) { diff --git a/skills/rig/samples/60-workflow-parallel-linter.md b/skills/rig/samples/60-workflow-parallel-linter.md new file mode 100644 index 0000000..1044a73 --- /dev/null +++ b/skills/rig/samples/60-workflow-parallel-linter.md @@ -0,0 +1,28 @@ +# 60 - Parallel File Linter (Workflow) + +```rig +import { agent, s, workflow } from "rig"; + +// Agent role: check one TypeScript file for code quality issues. +const lintFile = agent({ + name: "lintFile", + model: "nano", + input: s.object({ file: s.path }), + output: s.object({ issues: s.array(s.string) }), + instructions: "Check the file for linting issues.", +}); + +// Workflow role: discover and lint TypeScript source files in parallel. +const linter = workflow({ + meta: { name: "linter", description: "Lint TypeScript files in parallel", phases: ["Discover", "Lint"] }, + body: async ({ call, phase, pipeline }) => { + phase("Discover"); + const raw = await call.text("List TypeScript source files to lint, one path per line."); + const files = (raw ?? "").split("\n").map((f) => f.trim()).filter(Boolean); + phase("Lint"); + return pipeline(files, (file) => call(lintFile, { file }, { label: file })); + }, +}); + +export default linter; +``` diff --git a/src/launcher.test.ts b/src/launcher.test.ts index 2501a5b..29e1116 100644 --- a/src/launcher.test.ts +++ b/src/launcher.test.ts @@ -190,7 +190,7 @@ it("requires stdin-mode root agent to be a default export", async () => { await expect( runLauncherCli([fixturePath], {}, { stdin, stdout }), - ).rejects.toThrow("Expected program to export a root value (agent, string, or prompt builder) as default export."); + ).rejects.toThrow("Expected program to export a root value (agent, workflow, string, or prompt builder) as default export."); }); it("rejects stdin mode when prompt is empty", async () => {