Skip to content

Commit 083ec24

Browse files
authored
feat: add rig/globals with ambient call, fix pipeline null propagation, fix tsconfig baseUrl (#339)
1 parent 8445bc3 commit 083ec24

8 files changed

Lines changed: 152 additions & 6 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"exports": {
66
".": "./skills/rig/rig.ts",
77
"./eslint": "./skills/rig/eslint/index.js",
8+
"./globals": "./skills/rig/globals.ts",
89
"./engines/anthropic": "./skills/rig/engines/anthropic.ts",
910
"./engines/codex": "./skills/rig/engines/codex.ts",
1011
"./engines/gemini": "./skills/rig/engines/gemini.ts",

skills/rig/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Defaults: `name: "agent"`, `model: "small"`, `maxTurns: 4`, string input/output,
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 })` |
6565
| 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 |
66+
| Ambient `call` outside `body` | Import `call` from `"rig/globals"`; it routes through the active workflow context automatically. Do not import from `"rig/globals"` unless you need it — this avoids polluting non-workflow code. |
6667
| Custom model-callable operation | `defineTool(name, { description, parameters, handler })` |
6768
| Structured-output retries | `maxTurns` on the agent plus `addons: [repair()]` |
6869
| Retry with final-turn warning | `addons: [steering(), repair()]` in that order |

skills/rig/globals.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Ambient workflow context helpers.
3+
*
4+
* Import from `"rig/globals"` to access `call`, `pipeline`, and `parallel`
5+
* as module-level functions that automatically delegate to the active workflow
6+
* run via `currentWorkflow()`. This keeps rig programs that port from
7+
* Claude dynamic workflows readable without threading context explicitly.
8+
*
9+
* @example
10+
* ```ts
11+
* import { call, pipeline } from "rig/globals";
12+
* import { agent } from "rig";
13+
*
14+
* const worker = agent({ name: "worker", instructions: "Do work." });
15+
* const results = await pipeline(inputs, (item) => call(worker, item));
16+
* ```
17+
*
18+
* @module rig/globals
19+
*/
20+
import type {
21+
AgentFn,
22+
AgentInputValue,
23+
InferSchema,
24+
PromptBuilder,
25+
Schema,
26+
Workflow,
27+
WorkflowCall,
28+
WorkflowCallOptions,
29+
WorkflowNestedOptions,
30+
} from "rig";
31+
import { currentWorkflow, parallel, pipeline } from "rig";
32+
33+
function requireContext(label: string): WorkflowCall {
34+
const ctx = currentWorkflow();
35+
if (ctx === undefined) {
36+
throw new Error(`${label} requires an active workflow run (call inside runWorkflow or a launcher program).`);
37+
}
38+
return ctx.call;
39+
}
40+
41+
function callImpl<Input, Output>(
42+
worker: AgentFn<Input, Output>,
43+
input: AgentInputValue<Input>,
44+
options?: WorkflowCallOptions,
45+
): Promise<Output | null> {
46+
return requireContext("call()")(worker, input, options);
47+
}
48+
49+
callImpl.text = (prompt: string | PromptBuilder, options?: WorkflowCallOptions): Promise<string | null> =>
50+
requireContext("call.text()").text(prompt, options);
51+
52+
callImpl.json = <const Output extends Schema>(
53+
prompt: string | PromptBuilder,
54+
output: Output,
55+
options?: WorkflowCallOptions,
56+
): Promise<InferSchema<Output> | null> =>
57+
requireContext("call.json()").json(prompt, output, options);
58+
59+
callImpl.workflow = <Input, Output>(
60+
child: Workflow<Input, Output>,
61+
args?: Input,
62+
options?: WorkflowNestedOptions,
63+
): Promise<Output> =>
64+
requireContext("call.workflow()").workflow(child, args, options);
65+
66+
/**
67+
* Ambient workflow call. Delegates to the active `WorkflowContext.call`,
68+
* which routes through the shared concurrency limiter and agent budget.
69+
* Throws if called outside a workflow run.
70+
*/
71+
export const call: WorkflowCall = callImpl as unknown as WorkflowCall;
72+
73+
export { pipeline, parallel };

skills/rig/references/dynamic-workflows.md

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ outside a run. A `workflow()` default export is nested into the same run, so it
9999
shares the launcher's limiter, budget, and event stream instead of starting a
100100
second run.
101101

102+
To use `call`, `pipeline`, and `parallel` at module scope without destructuring
103+
from `body`, import them from `"rig/globals"`:
104+
105+
```ts
106+
import { call, pipeline } from "rig/globals";
107+
```
108+
109+
These are ambient proxies that delegate to the active workflow context
110+
automatically. They throw if no workflow run is active. Prefer explicit
111+
`body({ call })` destructuring inside `workflow()` bodies and reserve
112+
`"rig/globals"` for top-level launcher programs ported from Claude dynamic
113+
workflows. Do not import `"rig/globals"` unless you need it.
114+
102115
## Context
103116

104117
| Member | Behavior |
@@ -135,11 +148,13 @@ brackets the child with `log` events. Restore a phase after the nested run if th
135148
child called `phase()`.
136149

137150
`parallel` turns rejected thunks into `null` holes. Agent failures passed through
138-
`pipeline` are already `null` because `call` handles them; other pipeline callback
139-
errors fail the run rather than hiding programming bugs. `WorkflowLimitError` is
140-
never converted to `null`: exceeding `maxAgents` fails the whole run so runaway
141-
scheduling cannot be hidden as an ordinary worker failure. Exceptions thrown
142-
elsewhere in `body` also fail the run.
151+
`pipeline` are already `null` because `call` handles them. When a `pipeline`
152+
stage returns `null`, subsequent stages for that item are skipped and `null`
153+
propagates to the output — this prevents passing a failed result to the next
154+
stage. Other pipeline callback errors fail the run rather than hiding programming
155+
bugs. `WorkflowLimitError` is never converted to `null`: exceeding `maxAgents`
156+
fails the whole run so runaway scheduling cannot be hidden as an ordinary worker
157+
failure. Exceptions thrown elsewhere in `body` also fail the run.
143158

144159
## Limits
145160

skills/rig/rig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2309,6 +2309,7 @@ export async function pipeline<Item>(
23092309
return Promise.all(items.map(async (item, index) => {
23102310
let value: unknown = item;
23112311
for (const stage of stages) {
2312+
if (value === null) break;
23122313
value = await stage(value, item, index);
23132314
}
23142315
return value;

src/workflow.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
currentWorkflow,
66
log,
77
phase,
8+
pipeline,
89
WorkflowLimitError,
910
parallel,
1011
runWorkflow,
@@ -13,6 +14,7 @@ import {
1314
type WorkflowEvent,
1415
} from "rig";
1516
import { s } from "rig";
17+
import { call as ambientCall } from "rig/globals";
1618

1719
function fakeAgent<Input, Output>(
1820
name: string,
@@ -149,6 +151,14 @@ describe("workflow primitives", () => {
149151
])).resolves.toEqual([1, null, 3]);
150152
});
151153

154+
it("pipeline skips subsequent stages when a stage returns null", async () => {
155+
const stage2 = vi.fn((_prev: unknown, item: number) => item);
156+
await expect(
157+
pipeline([1, 2, 3], (_item: number) => _item === 2 ? null : _item * 10, stage2),
158+
).resolves.toEqual([1, null, 3]);
159+
expect(stage2).toHaveBeenCalledTimes(2);
160+
});
161+
152162
it("until stops on completion or repeated progress keys", async () => {
153163
const complete = vi.fn(async (state: number | undefined) => ({
154164
state: (state ?? 0) + 1,
@@ -333,3 +343,46 @@ describe("ambient workflow context", () => {
333343
}).not.toThrow();
334344
});
335345
});
346+
347+
describe("rig/globals", () => {
348+
it("call() delegates to the active workflow context", async () => {
349+
const worker = fakeAgent<number, number>("worker", (value) => value * 2);
350+
const definition = workflow({
351+
meta: { name: "globals-call", description: "ambient call" },
352+
body: () => ambientCall(worker, 7),
353+
});
354+
355+
await expect(runWorkflow(definition)).resolves.toBe(14);
356+
});
357+
358+
it("call.text() delegates to the active workflow context", async () => {
359+
configureAgent(() => ({
360+
ask: async () => '"pong"',
361+
close: async () => {},
362+
}));
363+
const definition = workflow({
364+
meta: { name: "globals-call-text", description: "ambient call.text" },
365+
body: () => ambientCall.text("ping"),
366+
});
367+
368+
await expect(runWorkflow(definition)).resolves.toBe("pong");
369+
});
370+
371+
it("call.workflow() delegates to the active workflow context", async () => {
372+
const child = workflow({
373+
meta: { name: "child-globals", description: "child run" },
374+
body: () => 42,
375+
});
376+
const definition = workflow({
377+
meta: { name: "globals-call-workflow", description: "ambient call.workflow" },
378+
body: () => ambientCall.workflow(child),
379+
});
380+
381+
await expect(runWorkflow(definition)).resolves.toBe(42);
382+
});
383+
384+
it("call() throws outside a workflow run", () => {
385+
const worker = fakeAgent<number, number>("worker", (value) => value);
386+
expect(() => ambientCall(worker, 1)).toThrow("requires an active workflow run");
387+
});
388+
});

tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"compilerOptions": {
3-
"baseUrl": ".",
43
"target": "ES2022",
54
"module": "Node16",
65
"moduleResolution": "Node16",
@@ -20,6 +19,8 @@
2019
"noEmit": true,
2120
"paths": {
2221
"rig": ["./skills/rig/rig.ts"],
22+
"rig/eslint": ["./skills/rig/eslint/index.js"],
23+
"rig/globals": ["./skills/rig/globals.ts"],
2324
"rig/engines/anthropic": ["./skills/rig/engines/anthropic.ts"],
2425
"rig/engines/codex": ["./skills/rig/engines/codex.ts"],
2526
"rig/engines/gemini": ["./skills/rig/engines/gemini.ts"],

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default defineConfig({
55
resolve: {
66
alias: [
77
{ find: /^rig$/, replacement: resolve(__dirname, "skills/rig/rig.ts") },
8+
{ find: /^rig\/globals$/, replacement: resolve(__dirname, "skills/rig/globals.ts") },
89
{ find: /^rig\/engines\/anthropic$/, replacement: resolve(__dirname, "skills/rig/engines/anthropic.ts") },
910
{ find: /^rig\/engines\/codex$/, replacement: resolve(__dirname, "skills/rig/engines/codex.ts") },
1011
{ find: /^rig\/engines\/gemini$/, replacement: resolve(__dirname, "skills/rig/engines/gemini.ts") },

0 commit comments

Comments
 (0)