@pumped-fn/agent-sdk 1.0.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 +21 -0
- package/PATTERNS.md +458 -0
- package/README.md +515 -0
- package/dist/index.cjs +1336 -0
- package/dist/index.d.cts +478 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +478 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1281 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
# @pumped-fn/agent-sdk
|
|
2
|
+
|
|
3
|
+
Agent workflow and application helpers for `@pumped-fn/lite`.
|
|
4
|
+
|
|
5
|
+
This package does not add a hosted runtime or filesystem framework. It gives names and conventions to the primitives lite already has:
|
|
6
|
+
|
|
7
|
+
| Lite primitive | Agent SDK use |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `flow()` + `step({ workflow: true })` | Workflow boundary |
|
|
10
|
+
| `flow()` | Worker, tool, subagent turn, durable step, CLI-backed LLM call |
|
|
11
|
+
| state/service/atom | Provider, config, registry, material state |
|
|
12
|
+
| `resource()` | Per-run agent event capture |
|
|
13
|
+
| typed tag | Routing config, ambient run data, model and sandbox capabilities |
|
|
14
|
+
| `ctx.exec()` | Step boundary for replay, remote routing, timeout, and suspend |
|
|
15
|
+
| `workflowExtension()` | Replay, suspend, timeout, and event-log policy |
|
|
16
|
+
| `extension()` | Agent remote-routing policy |
|
|
17
|
+
|
|
18
|
+
The core idea: author orchestration as normal TypeScript `flow()` code. Put every side effect behind `ctx.exec()`. Then an extension can replay, memoize, route, or suspend those steps without changing workflow code.
|
|
19
|
+
|
|
20
|
+
```mermaid
|
|
21
|
+
flowchart TD
|
|
22
|
+
Root["workflow flow or agent turn"] --> Exec["ctx.exec"]
|
|
23
|
+
Exec --> Config["read step config"]
|
|
24
|
+
Config --> Replay["completed? return cached"]
|
|
25
|
+
Config --> Durable["durable? write pending + suspend"]
|
|
26
|
+
Config --> Remote["remote? hand to worker runner"]
|
|
27
|
+
Config --> Local["otherwise run next()"]
|
|
28
|
+
Local --> Tool["tool or subagent flow"]
|
|
29
|
+
Local --> Model["model tag provider"]
|
|
30
|
+
Model --> CLI["optional Codex/Claude CLI worker"]
|
|
31
|
+
Tool --> Events["events resource"]
|
|
32
|
+
Model --> Events
|
|
33
|
+
CLI --> Events
|
|
34
|
+
Events --> Log["write completed"]
|
|
35
|
+
Remote --> Log
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What Is In This Package
|
|
39
|
+
|
|
40
|
+
- `workflow` runtime tag for workflow-scoped deps.
|
|
41
|
+
- `workflowExtension()` for replay, suspend, timeout, and event-log policy.
|
|
42
|
+
- `extension()` for agent remote dispatch.
|
|
43
|
+
- `step()` config: `workflow`, `remote`, `durable`, `kind`, `timeoutMs`.
|
|
44
|
+
- `workflowRun()` context tag for workflow-scoped run data.
|
|
45
|
+
- `abortSignal` tag for cooperative timeout cancellation.
|
|
46
|
+
- `runtime` tag for named worker delegation.
|
|
47
|
+
- `WorkerRegistry` for named worker calls through `runtime.delegate()`.
|
|
48
|
+
- `agent()`, `tool()`, `skill()`, and `sub()` for an agent application surface over lite.
|
|
49
|
+
- `skillCalls` and `loadedSkills` for on-demand skill content.
|
|
50
|
+
- `agent.turn` flow for model rounds that execute tools and subagents through `ctx.exec()`.
|
|
51
|
+
- `session()` and `send()` for continuing message history backed by materials.
|
|
52
|
+
- `events` for per-boundary run inspection.
|
|
53
|
+
- `model` and `sandbox` for swappable provider and execution capabilities.
|
|
54
|
+
- `guard()` for harness anti-goal state collected from the first model run.
|
|
55
|
+
- `channel()` and `schedule()` for inbound and clock-driven adapter flows.
|
|
56
|
+
- `suite()`, `runEval()`, deterministic checks, and judge quorum helpers.
|
|
57
|
+
- `inspect()` for workflow-log run inspection.
|
|
58
|
+
- `summary()` for JSON-safe eval reports.
|
|
59
|
+
- `http()` for Fetch request adapters.
|
|
60
|
+
- `material()`, `patchMaterial()`, and `derivedMaterial()` for small task-scoped JSON materials.
|
|
61
|
+
- `cliWorker()`, `claudeCliWorker()`, and `codexCliWorker()` for real CLI-backed work.
|
|
62
|
+
- `claudeHarness()` and `codexHarness()` for non-interactive CLI model adapters with optional bwrap isolation.
|
|
63
|
+
|
|
64
|
+
`step()` is one defaulted config tag. Flow tags set defaults. Exec tags override per call.
|
|
65
|
+
|
|
66
|
+
Transport is outside this core package. Tests use `@pumped-fn/agent-sdk-test` with an in-memory event log. A NATS, HTTP, or queue package can implement the same `WorkflowEventLog` and `RemoteRunner` contracts.
|
|
67
|
+
|
|
68
|
+
## Agent Application
|
|
69
|
+
|
|
70
|
+
Use `agent()` when a model should choose tools or delegate to subagents, but keep every executable capability as a lite flow. A model is just a swappable provider. Tools and subagent turns run through `ctx.exec()`, so workflow replay, remote routing, timeouts, and event capture still apply at the same seam as the rest of the graph.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createScope, flow, typed } from "@pumped-fn/lite"
|
|
74
|
+
import {
|
|
75
|
+
events,
|
|
76
|
+
agent,
|
|
77
|
+
model,
|
|
78
|
+
skill,
|
|
79
|
+
sub,
|
|
80
|
+
tool,
|
|
81
|
+
type Model,
|
|
82
|
+
} from "@pumped-fn/agent-sdk"
|
|
83
|
+
|
|
84
|
+
const lookupTicket = tool({
|
|
85
|
+
description: "Load a ticket by id.",
|
|
86
|
+
flow: flow({
|
|
87
|
+
name: "lookup-ticket",
|
|
88
|
+
parse: typed<{ id: string }>(),
|
|
89
|
+
factory: (ctx) => ({ id: ctx.input.id, title: `ticket:${ctx.input.id}` }),
|
|
90
|
+
}),
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const summarizeModel: Model = {
|
|
94
|
+
complete: (_ctx, request) => ({
|
|
95
|
+
content: `summary:${request.messages.at(-1)?.content ?? ""}`,
|
|
96
|
+
stop: true,
|
|
97
|
+
}),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const summarize = agent({
|
|
101
|
+
name: "summarize-ticket",
|
|
102
|
+
tags: [model(summarizeModel)],
|
|
103
|
+
instructions: "Summarize the ticket context.",
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const triageModel: Model = {
|
|
107
|
+
complete: (_ctx, request) => request.loadedSkills.length === 0
|
|
108
|
+
? {
|
|
109
|
+
content: "need policy",
|
|
110
|
+
skillCalls: [{ name: "triage-policy" }],
|
|
111
|
+
}
|
|
112
|
+
: request.round === 1
|
|
113
|
+
? {
|
|
114
|
+
content: "checking",
|
|
115
|
+
toolCalls: [{ name: "lookup-ticket", input: { id: "42" } }],
|
|
116
|
+
subagentCalls: [{ name: "summarize-ticket", input: { prompt: "ticket 42" } }],
|
|
117
|
+
}
|
|
118
|
+
: {
|
|
119
|
+
content: `ready:${request.messages.map((message) => message.content).join("|")}`,
|
|
120
|
+
stop: true,
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const triage = agent({
|
|
125
|
+
name: "triage-ticket",
|
|
126
|
+
tags: [model(triageModel)],
|
|
127
|
+
instructions: "Triage tickets with tools and delegated summaries.",
|
|
128
|
+
skills: [
|
|
129
|
+
skill({
|
|
130
|
+
name: "triage-policy",
|
|
131
|
+
description: "Ticket triage policy.",
|
|
132
|
+
content: "Escalate unclear incidents.",
|
|
133
|
+
}),
|
|
134
|
+
],
|
|
135
|
+
tools: [lookupTicket],
|
|
136
|
+
subagents: [
|
|
137
|
+
sub({
|
|
138
|
+
description: "Summarizes ticket context.",
|
|
139
|
+
agent: summarize,
|
|
140
|
+
}),
|
|
141
|
+
],
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const scope = createScope()
|
|
145
|
+
const ctx = scope.createContext()
|
|
146
|
+
const result = await ctx.exec({
|
|
147
|
+
flow: triage.turn,
|
|
148
|
+
input: { prompt: "triage FEAT-42" },
|
|
149
|
+
})
|
|
150
|
+
const trace = await ctx.resolve(events)
|
|
151
|
+
await ctx.close()
|
|
152
|
+
await scope.dispose()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`result.toolResults`, `result.subagentResults`, and `trace.events` are deterministic inspection surfaces. Tests can set a default model with scope tags, define one on the agent flow, or override one turn with exec tags without module mocks.
|
|
156
|
+
|
|
157
|
+
## Sessions
|
|
158
|
+
|
|
159
|
+
Use `session()` when a continuing agent needs message history. The session is a material, so history is ordinary scope state with revisioned patches.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { session, send } from "@pumped-fn/agent-sdk"
|
|
163
|
+
|
|
164
|
+
const thread = session("triage-session")
|
|
165
|
+
|
|
166
|
+
await send(ctx, thread, triage, { prompt: "triage FEAT-42" })
|
|
167
|
+
await send(ctx, thread, triage, { prompt: "summarize the decision" })
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
For persistent durability, pair the session material with a workflow event-log adapter. The core session API stays storage-agnostic.
|
|
171
|
+
|
|
172
|
+
## Evals
|
|
173
|
+
|
|
174
|
+
`suite()` accepts deterministic checks and zero or at least two judges. One judge is rejected because a subjective gate should not rest on a single model answer.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import {
|
|
178
|
+
suite,
|
|
179
|
+
judge,
|
|
180
|
+
includes,
|
|
181
|
+
runEval,
|
|
182
|
+
summary,
|
|
183
|
+
} from "@pumped-fn/agent-sdk"
|
|
184
|
+
|
|
185
|
+
const accepts = judge({
|
|
186
|
+
name: "accepts",
|
|
187
|
+
evaluate: () => ({ name: "accepts", passed: true, score: 1 }),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const grounded = judge({
|
|
191
|
+
name: "grounded",
|
|
192
|
+
evaluate: () => ({ name: "grounded", passed: true, score: 1 }),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
const evaluation = suite({
|
|
196
|
+
name: "triage-quality",
|
|
197
|
+
agent: triage,
|
|
198
|
+
cases: [
|
|
199
|
+
{
|
|
200
|
+
name: "answers with readiness",
|
|
201
|
+
input: { prompt: "triage FEAT-42" },
|
|
202
|
+
checks: [includes("ready")],
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
judges: [accepts, grounded],
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const report = await runEval(ctx, evaluation)
|
|
209
|
+
const artifact = summary(report)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Runs And HTTP
|
|
213
|
+
|
|
214
|
+
Use `inspect()` with any log that implements `RunLog`. `@pumped-fn/agent-sdk-test` provides an in-memory log; production packages can back the same contract with SQL, NATS, object storage, or a trace backend.
|
|
215
|
+
|
|
216
|
+
```ts
|
|
217
|
+
import { inspect, workflowRun } from "@pumped-fn/agent-sdk"
|
|
218
|
+
|
|
219
|
+
const ctx = scope.createContext({
|
|
220
|
+
tags: [workflowRun({ taskId: "triage-42", runId: "run-1" })],
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
await ctx.exec({
|
|
224
|
+
flow: triage.turn,
|
|
225
|
+
input: { prompt: "triage FEAT-42" },
|
|
226
|
+
})
|
|
227
|
+
const run = await inspect(log, { taskId: "triage-42", runId: "run-1" })
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Use `http()` when an existing Fetch-compatible server should expose an agent turn. Auth, routing, and provider request verification stay outside the core package.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { http } from "@pumped-fn/agent-sdk"
|
|
234
|
+
|
|
235
|
+
const handle = http({ agent: triage })
|
|
236
|
+
const response = await ctx.exec({
|
|
237
|
+
flow: handle,
|
|
238
|
+
input: new Request("https://agent.local/run", {
|
|
239
|
+
method: "POST",
|
|
240
|
+
body: JSON.stringify({ prompt: "triage FEAT-42" }),
|
|
241
|
+
}),
|
|
242
|
+
})
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Channels, Schedules, and Sandboxes
|
|
246
|
+
|
|
247
|
+
Channels and schedules are flow adapters. They translate an external event or clock tick into an agent turn input, then execute that turn through `ctx.exec()`.
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { createScope, flow, tags, typed } from "@pumped-fn/lite"
|
|
251
|
+
import {
|
|
252
|
+
channel,
|
|
253
|
+
schedule,
|
|
254
|
+
tool,
|
|
255
|
+
sandbox,
|
|
256
|
+
} from "@pumped-fn/agent-sdk"
|
|
257
|
+
|
|
258
|
+
const readWorkspace = tool({
|
|
259
|
+
description: "Read a file from the agent workspace.",
|
|
260
|
+
flow: flow({
|
|
261
|
+
name: "read-workspace",
|
|
262
|
+
parse: typed<{ path: string }>(),
|
|
263
|
+
deps: { sandbox: tags.required(sandbox) },
|
|
264
|
+
factory: (ctx, deps) => deps.sandbox.readFile(ctx.input.path),
|
|
265
|
+
}),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const slack = channel({
|
|
269
|
+
name: "slack-message",
|
|
270
|
+
parse: typed<{ text: string }>(),
|
|
271
|
+
agent: triage,
|
|
272
|
+
input: (ctx) => ({ prompt: ctx.input.text }),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const daily = schedule({
|
|
276
|
+
name: "daily-digest",
|
|
277
|
+
agent: triage,
|
|
278
|
+
input: () => ({ prompt: "daily digest" }),
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
const scope = createScope({
|
|
282
|
+
tags: [
|
|
283
|
+
sandbox({
|
|
284
|
+
readFile: (path) => `file:${path}`,
|
|
285
|
+
writeFile: () => undefined,
|
|
286
|
+
exec: (command, args = []) => ({
|
|
287
|
+
stdout: [command, ...args].join(" "),
|
|
288
|
+
stderr: "",
|
|
289
|
+
exitCode: 0,
|
|
290
|
+
}),
|
|
291
|
+
}),
|
|
292
|
+
],
|
|
293
|
+
})
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Standalone Suspense
|
|
297
|
+
|
|
298
|
+
Suspense is the reusable substrate under the workflow extension. It only knows about `(taskId, runId, step)`, an event log, and `ctx.exec()`. Mark replayable steps with `replay(true)` and externally resolved steps with `suspend(true)`.
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { createScope, flow } from "@pumped-fn/lite"
|
|
302
|
+
import {
|
|
303
|
+
extension,
|
|
304
|
+
suspend,
|
|
305
|
+
taskId,
|
|
306
|
+
runId,
|
|
307
|
+
stepCounter,
|
|
308
|
+
} from "@pumped-fn/lite-extension-suspense"
|
|
309
|
+
|
|
310
|
+
const externalSync = flow({
|
|
311
|
+
name: "external-sync",
|
|
312
|
+
tags: [suspend(true)],
|
|
313
|
+
factory: () => "unreachable until resolved",
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const log = makeEventLog()
|
|
317
|
+
const scope = createScope({
|
|
318
|
+
extensions: [extension({ log })],
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const ctx = scope.createContext({
|
|
322
|
+
tags: [
|
|
323
|
+
taskId("doc-1"),
|
|
324
|
+
runId("sync-1"),
|
|
325
|
+
stepCounter({ next: 0 }),
|
|
326
|
+
],
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
await ctx.exec({ flow: externalSync })
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
First run writes a pending entry and throws `SuspendSignal`. A resolver writes the value into the log, then replay returns the resolved value and continues. Sync can use the same shape for "wait until remote commit arrives", "wait until peer state catches up", or "resume after external acknowledgement".
|
|
333
|
+
|
|
334
|
+
## Minimal Workflow
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
import { createScope, flow, tags, typed } from "@pumped-fn/lite"
|
|
338
|
+
import {
|
|
339
|
+
runtime,
|
|
340
|
+
extension,
|
|
341
|
+
workflowRun,
|
|
342
|
+
workflow as workflowRuntime,
|
|
343
|
+
workflowExtension,
|
|
344
|
+
step,
|
|
345
|
+
workerRegistry,
|
|
346
|
+
workers,
|
|
347
|
+
} from "@pumped-fn/agent-sdk"
|
|
348
|
+
|
|
349
|
+
const summarize = flow({
|
|
350
|
+
name: "summarize",
|
|
351
|
+
parse: typed<{ text: string }>(),
|
|
352
|
+
tags: [step({ kind: "llm" })],
|
|
353
|
+
factory: async (ctx) => `summary: ${ctx.input.text}`,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const processIssue = flow({
|
|
357
|
+
name: "process_issue",
|
|
358
|
+
parse: typed<{ body: string }>(),
|
|
359
|
+
tags: [
|
|
360
|
+
step({ workflow: true }),
|
|
361
|
+
workers(workerRegistry([summarize])),
|
|
362
|
+
],
|
|
363
|
+
deps: {
|
|
364
|
+
workflow: tags.required(workflowRuntime),
|
|
365
|
+
runtime: tags.required(runtime),
|
|
366
|
+
},
|
|
367
|
+
factory: async (ctx, { workflow, runtime }) => {
|
|
368
|
+
const summary = await runtime.delegate<string, { text: string }>("summarize", {
|
|
369
|
+
text: ctx.input.body,
|
|
370
|
+
})
|
|
371
|
+
return { taskId: workflow.taskId, summary }
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
const eventLog = makeEventLog()
|
|
376
|
+
const scope = createScope({
|
|
377
|
+
extensions: [
|
|
378
|
+
workflowExtension({ log: eventLog }),
|
|
379
|
+
extension(),
|
|
380
|
+
],
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
const ctx = scope.createContext({
|
|
384
|
+
tags: [workflowRun({
|
|
385
|
+
taskId: "issue-123",
|
|
386
|
+
runId: "run-1",
|
|
387
|
+
})],
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
const result = await ctx.exec({ flow: processIssue, input: { body: "..." } })
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`workflowRun()` is a tag and belongs in `createContext({ tags: [...] })`. `runtime.delegate()` is just `ctx.exec({ flow, input })` plus a registry lookup. Supply that registry through a `workers(registry)` flow or context tag. `workflow` and `runtime` are required deps; if the matching extension is missing, dependency resolution fails before the factory runs.
|
|
394
|
+
|
|
395
|
+
## AI Is Just A Provider
|
|
396
|
+
|
|
397
|
+
Claude, Codex, Anthropic SDK, OpenAI SDK, local model, and test fake should all fit behind the same shape: a flow calls an injected provider or a CLI helper.
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
import { createScope, flow, preset, service, typed, type Lite } from "@pumped-fn/lite"
|
|
401
|
+
import { step } from "@pumped-fn/agent-sdk"
|
|
402
|
+
|
|
403
|
+
interface Model {
|
|
404
|
+
complete(ctx: Lite.ExecutionContext, prompt: string): Promise<string>
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const model = service<Model>({
|
|
408
|
+
factory: () => ({
|
|
409
|
+
complete: async (_ctx, prompt) => runRealModel(prompt),
|
|
410
|
+
}),
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
export const classify = flow({
|
|
414
|
+
name: "classify",
|
|
415
|
+
parse: typed<{ text: string }>(),
|
|
416
|
+
deps: { model },
|
|
417
|
+
tags: [step({ kind: "llm" })],
|
|
418
|
+
factory: async (ctx, { model }) => {
|
|
419
|
+
const answer = await model.complete(ctx, `Classify:\n${ctx.input.text}`)
|
|
420
|
+
return JSON.parse(answer) as { label: string }
|
|
421
|
+
},
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
const fakeModel: Model = {
|
|
425
|
+
complete: async () => JSON.stringify({ label: "test" }),
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const testScope = createScope({
|
|
429
|
+
presets: [preset(model, fakeModel)],
|
|
430
|
+
})
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
The CLI helpers are convenience adapters. Harnesses turn popular local CLIs into `Model` providers for `agent()`. Application composition should usually use the provider packages so the core SDK stays transport-neutral and the model is swappable at the scope seam.
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
import { createScope } from "@pumped-fn/lite"
|
|
437
|
+
import { agent, guard } from "@pumped-fn/agent-sdk"
|
|
438
|
+
import { claude } from "@pumped-fn/agent-sdk-claude"
|
|
439
|
+
import { codex } from "@pumped-fn/agent-sdk-codex"
|
|
440
|
+
|
|
441
|
+
const shared = guard("review-guard")
|
|
442
|
+
|
|
443
|
+
const reviewer = agent({
|
|
444
|
+
name: "reviewer",
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
const scope = createScope({
|
|
448
|
+
tags: [
|
|
449
|
+
codex({
|
|
450
|
+
sandbox: "read-only",
|
|
451
|
+
guard: shared,
|
|
452
|
+
timeoutMs: 120_000,
|
|
453
|
+
}),
|
|
454
|
+
],
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
const otherScope = createScope({ tags: [claude({ guard: shared })] })
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`@pumped-fn/agent-sdk-codex` and `@pumped-fn/agent-sdk-claude` return lazy `model` tags. Tagging a scope does not create the CLI harness; first model use does. Replace them with each other or `model(fake)` at `createScope` or `createContext` without changing the agent graph.
|
|
461
|
+
|
|
462
|
+
`codexHarness()` uses `codex exec --ephemeral --ignore-user-config`. `claudeHarness()` uses `claude -p --no-session-persistence` and rejects `--bare`; pass explicit `extraArgs` for other CLI flags. The default prompt asks for JSON with `content`, optional `guard`, and optional skill/tool/subagent calls. `guard` is the anti-goal; the first non-empty value is stored in material state and injected into later prompts. Pass a shared `guard("name")` atom when multiple harnesses should see the same anti-goal.
|
|
463
|
+
|
|
464
|
+
Harnesses default to bwrap isolation with network enabled because the CLIs need provider access. The sandbox mounts the workspace read-only by default, a temporary home, minimal runtime/cert/DNS paths, and only explicit credential directories such as `isolate: { network: true, codexHome: process.env.CODEX_HOME }`. Use `isolate: false` only when another trusted boundary already isolates the process.
|
|
465
|
+
|
|
466
|
+
For stable tests, prefer provider state plus presets.
|
|
467
|
+
|
|
468
|
+
## Replay Contract
|
|
469
|
+
|
|
470
|
+
`ctx.exec()` is the durable step boundary. On first execution, the workflow extension assigns `(taskId, runId, step)` and writes the result. On replay, the same code runs from the top, but completed steps return cached values before dependencies or factory code run.
|
|
471
|
+
|
|
472
|
+
That means workflow bodies must be deterministic between `ctx.exec()` calls:
|
|
473
|
+
|
|
474
|
+
- Use `ctx.exec({ flow })` for side effects.
|
|
475
|
+
- Use provider state/services for swappable integrations.
|
|
476
|
+
- Do not read time, random, network, filesystem, or process state directly in workflow orchestration code.
|
|
477
|
+
- Keep dependency factories pure enough that replay skipping them is valid.
|
|
478
|
+
- `timeoutMs` rejects the step promise and aborts the `abortSignal` tag. Work must observe the signal to stop cooperatively.
|
|
479
|
+
|
|
480
|
+
## Materials
|
|
481
|
+
|
|
482
|
+
Materials are state-backed task data with a patch-oriented API. Patches serialize per material; pass `expectedRevision` when callers need optimistic conflict detection. Materials are keep-alive by default and can opt out with `keepAlive: false`.
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
const status = material("pr-status", {
|
|
486
|
+
kind: "json",
|
|
487
|
+
initialState: { prs: {} as Record<string, unknown> },
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
await patchMaterial(ctx, status, [
|
|
491
|
+
{ op: "add", path: "/prs/12", value: { state: "ok" } },
|
|
492
|
+
])
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Derived materials are plain derived state that recomputes from source material state.
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
const html = derivedMaterial("status-html", status, renderStatus, { kind: "text" })
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
## Testing
|
|
502
|
+
|
|
503
|
+
Use `@pumped-fn/agent-sdk-test` for in-memory replay and fake remote routing:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
import { kit } from "@pumped-fn/agent-sdk-test"
|
|
507
|
+
|
|
508
|
+
const { extensions, log } = kit({
|
|
509
|
+
remoteRunner: {
|
|
510
|
+
run: async (event) => ({ routed: event.targetName }),
|
|
511
|
+
},
|
|
512
|
+
})
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Use `extensions` in `createScope({ extensions })`. This keeps tests fast and proves the same extension contract a NATS-backed runtime will use.
|