Skip to content

Commit fdabea2

Browse files
Copilotpelikhan
andauthored
Wrap launcher programs in a workflow run for top-level phase/log
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 77056ad commit fdabea2

8 files changed

Lines changed: 180 additions & 21 deletions

File tree

skills/rig/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Defaults: `name: "agent"`, `model: "small"`, `maxTurns: 4`, string input/output,
6262
| Deterministic TypeScript fan-out | `workflow({ meta, input?, body })` + `export default`; use `call`, `pipeline`, `parallel`, `until` inside `body` |
6363
| One-off prompt inside a workflow | `call.text(prompt)` for a string, `call.json(prompt, schema)` for structured output |
6464
| Reusable workflow step | Define an `agent({ input, output })` and `call(worker, input, { label, phase })` |
65+
| Phase or log from an agent program | Import `phase` / `log` from `rig` and call them at top level; the launcher runs every program inside a workflow |
6566
| Custom model-callable operation | `defineTool(name, { description, parameters, handler })` |
6667
| Structured-output retries | `maxTurns` on the agent plus `addons: [repair()]` |
6768
| Retry with final-turn warning | `addons: [steering(), repair()]` in that order |

skills/rig/references/claude-workflow-conversion.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ primitives from its context instead of from globals.
3434
| top-level `return value` | `return value` from `body` | Same |
3535
| `Workflow({ scriptPath, args })` from a session | `cat args.json \| node skills/rig/rig.ts program.ts` | See [runtime](runtime.md) |
3636

37+
Globals such as `phase` and `log` also exist as module-level imports from `rig`.
38+
The launcher runs every program — including one whose root export is an `agent`
39+
inside a workflow run, so a partially ported script can call `phase()` and `log()`
40+
at top level before the orchestration itself moves into `workflow({ body })`.
41+
3742
## Schema conversion
3843

3944
Dynamic workflows pass OpenAI-strict JSON Schema literals. rig schemas are

skills/rig/references/dynamic-workflows.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,28 @@ export default linter;
7777
- Omit `input` for a no-input program (inline mode) or add `input: s.object({ ... })` for file-mode programs that read stdin JSON.
7878
- Use `agent()` with `agents:` when an LLM should improvise the coordination order. Use `workflow()` when TypeScript owns the orchestration (fan-out, branching, convergence).
7979

80+
## Top-level constructs in agent programs
81+
82+
The launcher runs every program inside a workflow run, including programs whose
83+
root export is an `agent`, a string, or a prompt builder. Module evaluation
84+
happens inside that run, so `phase()` and `log()` imported from `rig` work at the
85+
program's top level without declaring a `workflow()`:
86+
87+
```ts
88+
import { agent, log, phase } from "rig";
89+
90+
phase("Review");
91+
log("reviewing the staged diff");
92+
93+
export default agent({ instructions: "Review the staged diff." });
94+
```
95+
96+
`currentWorkflow()` returns the active run context (`call`, `budget`, `signal`,
97+
`phase`, `log`) or `undefined` outside a run; `phase()` and `log()` are no-ops
98+
outside a run. A `workflow()` default export is nested into the same run, so it
99+
shares the launcher's limiter, budget, and event stream instead of starting a
100+
second run.
101+
80102
## Context
81103

82104
| Member | Behavior |

skills/rig/references/runtime.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ Stdin coercion follows the root schema:
3939

4040
The launcher writes string results, or the string `text` field of an object result, directly to stdout. It JSON-serializes other results.
4141

42+
Both modes evaluate the program and run its root inside a workflow run, so
43+
top-level `phase()` and `log()` work in any program and `currentWorkflow()` is
44+
defined from module scope. A `workflow` default export nests into that run
45+
instead of starting a second one. Run events are emitted under the
46+
`workflow:event` debug category.
47+
4248
Add `--server` in either mode to start the Copilot server over stdio and force the Copilot engine. Without it, `copilotEngine()` connects over HTTP using `COPILOT_SDK_URI`, then `localhost:7777`.
4349

4450
Use `--help`, `-h`, `help`, `/help`, or `/?` to print launcher usage.

skills/rig/rig.ts

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import { writeSync } from "node:fs";
9898
import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
9999
import { execFile } from "node:child_process";
100100
import { promisify } from "node:util";
101+
import { AsyncLocalStorage } from "node:async_hooks";
101102
import { CopilotClient, RuntimeConnection, approveAll } from "@github/copilot-sdk";
102103
import type { CopilotClientOptions } from "@github/copilot-sdk";
103104

@@ -694,6 +695,7 @@ const debugAgentComplete = debug("agent:complete");
694695
const debugAgentRetry = debug("agent:retry");
695696
const debugAgentFailure = debug("agent:failure");
696697
const debugAgentClose = debug("agent:close");
698+
const debugWorkflowEvent = debug("workflow:event");
697699

698700
export type AgentAddonContext = {
699701
spec: NormalizedAgentSpec<any, any>;
@@ -1454,7 +1456,9 @@ export async function launchRigProgram(programPath: string, options: LaunchOptio
14541456
const resolvedPath = isAbsolute(programPath) ? programPath : resolve(cwd, programPath);
14551457

14561458
configureAgent(defaultAgentFactory({ cwd, ...(options.startServer ? { startServer: true } : {}) }));
1457-
await import(pathToFileURL(resolvedPath).href);
1459+
await runInRootWorkflow("launcher-program", async () => {
1460+
await import(pathToFileURL(resolvedPath).href);
1461+
});
14581462
}
14591463

14601464
async function readStdin(stream: NodeJS.ReadableStream): Promise<string> {
@@ -1506,7 +1510,12 @@ function asRootProgram(value: unknown, name: string): AgentFn | undefined {
15061510
const hasInput = "inputSchema" in w;
15071511
const inputSchema: Schema = hasInput ? (w.inputSchema as Schema) : defaultStringSchema;
15081512
const fn = Object.assign(
1509-
async (input: unknown) => runWorkflow(w, hasInput ? { args: input } : {}),
1513+
async (input: unknown) => {
1514+
const args = hasInput ? input : undefined;
1515+
const ambient = currentWorkflow();
1516+
// Nest into the launcher's root run so limits, budget, and events are shared.
1517+
return ambient ? ambient.call.workflow(w, args) : runWorkflow(w, { args });
1518+
},
15101519
{
15111520
inputSchema,
15121521
outputSchema: defaultStringSchema as Schema,
@@ -1521,6 +1530,19 @@ function asRootProgram(value: unknown, name: string): AgentFn | undefined {
15211530
return undefined;
15221531
}
15231532

1533+
/**
1534+
* Runs a launcher program inside a workflow run so a program that exports an
1535+
* agent (or a plain prompt) can still use top-level workflow constructs such as
1536+
* `phase()` and `log()`. Module evaluation happens inside `body`, so top-level
1537+
* program statements observe the ambient run too.
1538+
*/
1539+
async function runInRootWorkflow<Output>(name: string, body: () => Promise<Output>): Promise<Output> {
1540+
return runWorkflow<undefined, Output>(
1541+
{ meta: { name, description: "Rig program root" }, body },
1542+
{ onEvent: (event) => debugWorkflowEvent(event) },
1543+
);
1544+
}
1545+
15241546
function noInputInvocation(agentFn: AgentFn): unknown | undefined {
15251547
const schema = agentFn.inputSchema;
15261548
if ("type" in schema && schema.type === "string") {
@@ -1720,13 +1742,14 @@ async function runRootAgentFromStdin(
17201742
}
17211743

17221744
configureAgent(defaultAgentFactory({ cwd, ...(options.startServer ? { startServer: true } : {}) }));
1723-
const mod = await import(pathToFileURL(resolvedPath).href);
1724-
const rootAgent = asRootProgram(mod.default, "launcher-root");
1725-
if (!rootAgent) {
1726-
throw new Error("Expected program to export a root value (agent, workflow, string, or prompt builder) as default export.");
1727-
}
1728-
1729-
const result = await rootAgent(coerceStdinInput(rootAgent, prompt));
1745+
const result = await runInRootWorkflow("launcher-root", async () => {
1746+
const mod = await import(pathToFileURL(resolvedPath).href);
1747+
const rootAgent = asRootProgram(mod.default, "launcher-root");
1748+
if (!rootAgent) {
1749+
throw new Error("Expected program to export a root value (agent, workflow, string, or prompt builder) as default export.");
1750+
}
1751+
return rootAgent(coerceStdinInput(rootAgent, prompt));
1752+
});
17301753
io.stdout.write(renderStdout(result));
17311754
}
17321755

@@ -1754,16 +1777,18 @@ async function runProgramCodeFromStdin(
17541777
return;
17551778
}
17561779
configureAgent(defaultAgentFactory({ cwd, ...(options.startServer ? { startServer: true } : {}) }));
1757-
const mod = await import(pathToFileURL(tempProgramPath).href);
1758-
const rootAgent = asRootProgram(mod.default, "launcher-inline-root");
1759-
if (!rootAgent) {
1760-
throw new Error("Expected program to export a root value (agent, workflow, string, or prompt builder) as default export.");
1761-
}
1762-
const input = noInputInvocation(rootAgent);
1763-
if (input === undefined) {
1764-
throw new Error("Expected stdin program root agent to have no input (omit input or use input: s.object({})).");
1765-
}
1766-
const result = await rootAgent(input);
1780+
const result = await runInRootWorkflow("launcher-inline-root", async () => {
1781+
const mod = await import(pathToFileURL(tempProgramPath).href);
1782+
const rootAgent = asRootProgram(mod.default, "launcher-inline-root");
1783+
if (!rootAgent) {
1784+
throw new Error("Expected program to export a root value (agent, workflow, string, or prompt builder) as default export.");
1785+
}
1786+
const input = noInputInvocation(rootAgent);
1787+
if (input === undefined) {
1788+
throw new Error("Expected stdin program root agent to have no input (omit input or use input: s.object({})).");
1789+
}
1790+
return rootAgent(input);
1791+
});
17671792
io.stdout.write(renderStdout(result));
17681793
} finally {
17691794
await rm(tempDir, { recursive: true, force: true });
@@ -2116,6 +2141,27 @@ export function workflow(
21162141
};
21172142
}
21182143

2144+
const workflowStore = new AsyncLocalStorage<WorkflowContext<unknown>>();
2145+
2146+
/**
2147+
* Returns the context of the innermost active workflow run, or `undefined`
2148+
* outside a run. Launcher programs always run inside a workflow, so a rig
2149+
* program can reach `call`, `budget`, and `signal` from module scope.
2150+
*/
2151+
export function currentWorkflow(): WorkflowContext<unknown> | undefined {
2152+
return workflowStore.getStore();
2153+
}
2154+
2155+
/** Sets the ambient phase of the active workflow run. No-op outside a run. */
2156+
export function phase(name: string): void {
2157+
currentWorkflow()?.phase(name);
2158+
}
2159+
2160+
/** Emits a structured log event on the active workflow run. No-op outside a run. */
2161+
export function log(message: string): void {
2162+
currentWorkflow()?.log(message);
2163+
}
2164+
21192165
export type WorkflowLimits = {
21202166
concurrency?: number;
21212167
maxAgents?: number;
@@ -2424,9 +2470,13 @@ export async function runWorkflow<Input, Output>(
24242470
): Promise<ChildOutput> => {
24252471
const name = nestedOptions.label ?? child.meta.name;
24262472
const outerPhase = currentPhase;
2473+
const childContext = makeContext(args as ChildInput);
24272474
writeLog(`workflow ${name} started`);
24282475
try {
2429-
return await child.body(makeContext(args as ChildInput));
2476+
return await workflowStore.run(
2477+
childContext as WorkflowContext<unknown>,
2478+
() => child.body(childContext),
2479+
);
24302480
} finally {
24312481
currentPhase = outerPhase;
24322482
writeLog(`workflow ${name} finished`);
@@ -2436,7 +2486,9 @@ export async function runWorkflow<Input, Output>(
24362486
const context = makeContext(options.args as Input);
24372487

24382488
try {
2439-
const body = Promise.resolve(definition.body(context));
2489+
const body = Promise.resolve(
2490+
workflowStore.run(context as WorkflowContext<unknown>, () => definition.body(context)),
2491+
);
24402492
const output = await (wallLimit === undefined ? body : Promise.race([body, wallLimit]));
24412493
emit({
24422494
type: "run_done",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { agent, currentWorkflow, log, phase } from "rig";
2+
3+
phase("Prepare");
4+
log("program loaded");
5+
6+
(globalThis as { __launcherAmbientRun?: boolean }).__launcherAmbientRun = currentWorkflow() !== undefined;
7+
8+
const root = agent({
9+
name: "launcher-stdin-ambient-root",
10+
});
11+
12+
export default root;

src/launcher.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,24 @@ it("supports stdin mode for string input/output root agents", async () => {
114114
expect(output.join("")).toBe("done");
115115
});
116116

117+
it("wraps an agent program in a workflow so top-level constructs work", async () => {
118+
const fixturePath = resolve(dirname(fileURLToPath(import.meta.url)), "./launcher.stdin-ambient.fixture.ts");
119+
const stdin = Readable.from(["Review this patch"]);
120+
const output: string[] = [];
121+
const stdout = new Writable({
122+
write(chunk, _encoding, callback) {
123+
output.push(chunk.toString());
124+
callback();
125+
},
126+
});
127+
128+
mocks.setSendAndWaitImpl(async () => JSON.stringify("done"));
129+
await runLauncherCli([fixturePath], {}, { stdin, stdout });
130+
131+
expect((globalThis as { __launcherAmbientRun?: boolean }).__launcherAmbientRun).toBe(true);
132+
expect(output.join("")).toBe("done");
133+
});
134+
117135
it("supports stdin mode when root default export is a string", async () => {
118136
const fixturePath = resolve(dirname(fileURLToPath(import.meta.url)), "./launcher.stdin-default-string.fixture.ts");
119137
const stdin = Readable.from(["Review this patch"]);

src/workflow.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { describe, expect, it, vi } from "vitest";
22
import type { AgentFn, CallOptions } from "rig";
33
import {
44
configureAgent,
5+
currentWorkflow,
6+
log,
7+
phase,
58
WorkflowLimitError,
69
parallel,
710
runWorkflow,
@@ -290,3 +293,43 @@ describe("workflow one-off agents", () => {
290293
expect(prompts).toHaveLength(2);
291294
});
292295
});
296+
297+
describe("ambient workflow context", () => {
298+
it("routes top-level phase and log to the active run", async () => {
299+
const worker = fakeAgent<number, number>("worker", (value) => value);
300+
const events: WorkflowEvent[] = [];
301+
const definition = workflow({
302+
meta: { name: "ambient", description: "ambient helpers" },
303+
body: async ({ call }) => {
304+
phase("Work");
305+
log("started");
306+
return call(worker, 1);
307+
},
308+
});
309+
310+
await expect(runWorkflow(definition, { onEvent: (event) => events.push(event) })).resolves.toBe(1);
311+
expect(events.find((event) => event.type === "phase_start")).toMatchObject({ phase: "Work" });
312+
expect(events.find((event) => event.type === "log")).toMatchObject({ message: "started", phase: "Work" });
313+
expect(events.find((event) => event.type === "agent_start")).toMatchObject({ phase: "Work" });
314+
});
315+
316+
it("exposes the run context through currentWorkflow and clears it afterwards", async () => {
317+
const definition = workflow({
318+
meta: { name: "context", description: "context lookup" },
319+
body: async () => {
320+
await Promise.resolve();
321+
return currentWorkflow()?.budget.total;
322+
},
323+
});
324+
325+
await expect(runWorkflow(definition, { limits: { maxAgents: 7 } })).resolves.toBe(7);
326+
expect(currentWorkflow()).toBeUndefined();
327+
});
328+
329+
it("ignores top-level phase and log outside a run", () => {
330+
expect(() => {
331+
phase("Work");
332+
log("no run");
333+
}).not.toThrow();
334+
});
335+
});

0 commit comments

Comments
 (0)