@db-lyon/flowkit 0.11.2 → 0.13.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/README.md +386 -375
- package/dist/.tsbuildinfo +1 -0
- package/dist/flow/runner.d.ts +23 -2
- package/dist/flow/runner.d.ts.map +1 -1
- package/dist/flow/runner.js +89 -29
- package/dist/flow/runner.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/references.d.ts +41 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +110 -0
- package/dist/references.js.map +1 -0
- package/dist/task/agent-task.d.ts.map +1 -1
- package/dist/task/agent-task.js +1 -7
- package/dist/task/agent-task.js.map +1 -1
- package/dist/task/base-task.d.ts +28 -3
- package/dist/task/base-task.d.ts.map +1 -1
- package/dist/task/base-task.js +13 -4
- package/dist/task/base-task.js.map +1 -1
- package/dist/task/shell-task.d.ts +2 -0
- package/dist/task/shell-task.d.ts.map +1 -1
- package/dist/task/shell-task.js +186 -40
- package/dist/task/shell-task.js.map +1 -1
- package/dist/task/shell-termination.d.ts +34 -0
- package/dist/task/shell-termination.d.ts.map +1 -0
- package/dist/task/shell-termination.js +179 -0
- package/dist/task/shell-termination.js.map +1 -0
- package/dist/task/task-resolution.d.ts +33 -0
- package/dist/task/task-resolution.d.ts.map +1 -0
- package/dist/task/task-resolution.js +34 -0
- package/dist/task/task-resolution.js.map +1 -0
- package/docs/ai-agents.md +368 -368
- package/docs/api-reference.md +458 -430
- package/docs/configuration.md +347 -343
- package/docs/custom-tasks.md +258 -203
- package/package.json +53 -52
package/docs/ai-agents.md
CHANGED
|
@@ -1,368 +1,368 @@
|
|
|
1
|
-
# AI agents
|
|
2
|
-
|
|
3
|
-
Flowkit can drive LLM calls as ordinary steps. A flow can mix deterministic
|
|
4
|
-
tasks with steps that prompt a model, extract structured data, or run a
|
|
5
|
-
tool-calling agent — and the model output flows into later steps through the
|
|
6
|
-
same `${steps.<id>.<path>}` references as anything else.
|
|
7
|
-
|
|
8
|
-
Flowkit ships **no SDK dependencies**. You supply a provider that adapts your
|
|
9
|
-
model of choice to a small neutral contract; the engine stays model-agnostic.
|
|
10
|
-
|
|
11
|
-
## Wiring a provider
|
|
12
|
-
|
|
13
|
-
Implement `LLMProvider` and attach it to the task context as `llm`. The
|
|
14
|
-
provider's only job is to translate flowkit's neutral request/response shape to
|
|
15
|
-
and from your SDK.
|
|
16
|
-
|
|
17
|
-
```typescript
|
|
18
|
-
import { FlowRunner, type LLMProvider } from '@db-lyon/flowkit';
|
|
19
|
-
import Anthropic from '@anthropic-ai/sdk';
|
|
20
|
-
|
|
21
|
-
const client = new Anthropic();
|
|
22
|
-
|
|
23
|
-
const provider: LLMProvider = {
|
|
24
|
-
async complete(req) {
|
|
25
|
-
const res = await client.messages.create(
|
|
26
|
-
{
|
|
27
|
-
model: req.model ?? 'claude-opus-4-8',
|
|
28
|
-
max_tokens: req.maxTokens ?? 1024,
|
|
29
|
-
system: req.system,
|
|
30
|
-
temperature: req.temperature,
|
|
31
|
-
messages: toAnthropicMessages(req), // map req.prompt / req.messages
|
|
32
|
-
tools: req.tools?.map(toAnthropicTool),
|
|
33
|
-
},
|
|
34
|
-
{ signal: req.signal }, // honor cancellation/timeout
|
|
35
|
-
);
|
|
36
|
-
return {
|
|
37
|
-
text: textOf(res),
|
|
38
|
-
toolCalls: toolCallsOf(res),
|
|
39
|
-
finishReason: res.stop_reason ?? undefined,
|
|
40
|
-
usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens },
|
|
41
|
-
model: res.model,
|
|
42
|
-
};
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const runner = new FlowRunner({
|
|
47
|
-
tasks: config.tasks,
|
|
48
|
-
flows: config.flows,
|
|
49
|
-
registry,
|
|
50
|
-
context: { logger, llm: provider }, // <- the seam
|
|
51
|
-
});
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
A provider may ignore any field it does not support. A request that only sets
|
|
55
|
-
`prompt` works against the simplest possible adapter.
|
|
56
|
-
|
|
57
|
-
> Model id, pricing, and SDK specifics for Claude live in the `claude-api`
|
|
58
|
-
> reference — don't hard-code a stale model id.
|
|
59
|
-
|
|
60
|
-
## Single-shot prompts — `AgentPromptTask`
|
|
61
|
-
|
|
62
|
-
`class_path: agent_prompt`. One prompt in, one response out. Register it:
|
|
63
|
-
|
|
64
|
-
```typescript
|
|
65
|
-
import { AgentPromptTask } from '@db-lyon/flowkit';
|
|
66
|
-
registry.register('agent_prompt', AgentPromptTask as any);
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
```yaml
|
|
70
|
-
tasks:
|
|
71
|
-
summarize:
|
|
72
|
-
class_path: agent_prompt
|
|
73
|
-
options:
|
|
74
|
-
system: You extract action items from meeting notes.
|
|
75
|
-
prompt: "Summarize:\n${steps.1.text}"
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
`result.data`: `text` (always), plus `parsed`, `usage`, `finishReason`, `model`,
|
|
79
|
-
and `truncated` when present.
|
|
80
|
-
|
|
81
|
-
### Structured output
|
|
82
|
-
|
|
83
|
-
Pass a JSON Schema. The response is validated against it; on a mismatch the
|
|
84
|
-
model is re-prompted with the concrete validation errors (the **repair loop**)
|
|
85
|
-
before the step fails.
|
|
86
|
-
|
|
87
|
-
```yaml
|
|
88
|
-
tasks:
|
|
89
|
-
extract:
|
|
90
|
-
class_path: agent_prompt
|
|
91
|
-
options:
|
|
92
|
-
prompt: "Pull the ticket fields from:\n${steps.1.text}"
|
|
93
|
-
schema:
|
|
94
|
-
type: object
|
|
95
|
-
required: [title, priority]
|
|
96
|
-
properties:
|
|
97
|
-
title: { type: string }
|
|
98
|
-
priority: { type: string, enum: [low, medium, high] }
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
On success `result.data.parsed` holds the validated object. If the model never
|
|
102
|
-
conforms, the step fails with a `StructuredOutputError` and `result.data.text`
|
|
103
|
-
carries the last raw output for debugging.
|
|
104
|
-
|
|
105
|
-
> **Reference paths are rooted at the step's `data`.** In `${steps.<id>.<path>}`,
|
|
106
|
-
> `<path>` is relative to that step's `result.data`, so a later step reads a
|
|
107
|
-
> structured field as `${steps.extract.parsed.title}` or the raw text as
|
|
108
|
-
> `${steps.1.text}` — **not** `${steps.1.data.text}` (that resolves to
|
|
109
|
-
> `data.data.text`, which is undefined). `<id>` is a step number or a task name.
|
|
110
|
-
|
|
111
|
-
The bundled validator covers the JSON Schema subset used for structured output
|
|
112
|
-
(`type`, `enum`, `const`, `required`, `properties`, `items`,
|
|
113
|
-
`additionalProperties`, length/number bounds, `anyOf`/`oneOf`/`allOf`/`not`,
|
|
114
|
-
and OpenAPI-style `nullable`). Unknown keywords are ignored rather than
|
|
115
|
-
rejected.
|
|
116
|
-
|
|
117
|
-
## Tool-calling agents — `AgentTask`
|
|
118
|
-
|
|
119
|
-
`class_path: agent`. A multi-turn loop: the model requests tool calls, the agent
|
|
120
|
-
runs them, feeds the results back, and repeats until the model gives a final
|
|
121
|
-
answer or `maxIterations` is hit.
|
|
122
|
-
|
|
123
|
-
```typescript
|
|
124
|
-
import { AgentTask } from '@db-lyon/flowkit';
|
|
125
|
-
registry.register('agent', AgentTask as any);
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
A tool references an existing flowkit primitive — a `task:`, a `flow:`, or
|
|
129
|
-
another `agent:` — or a **context handler** (a function on `ctx.agentTools`,
|
|
130
|
-
matched by `name:`). Tool dispatch reuses the registry and options machinery, so
|
|
131
|
-
there is no separate tool concept to maintain. A task-backed tool inherits its
|
|
132
|
-
configured `class_path` and `options` defaults, with the model's arguments
|
|
133
|
-
layered on top, so a task behaves the same as a tool as it does as a flow step.
|
|
134
|
-
`flow:` and `agent:` tools require a `FlowRunner` context (see "Declarative
|
|
135
|
-
agents" below).
|
|
136
|
-
|
|
137
|
-
```yaml
|
|
138
|
-
tasks:
|
|
139
|
-
research:
|
|
140
|
-
class_path: agent
|
|
141
|
-
options:
|
|
142
|
-
system: You answer questions using the available tools.
|
|
143
|
-
prompt: "How many open PRs touch the auth module?"
|
|
144
|
-
maxIterations: 6
|
|
145
|
-
tools:
|
|
146
|
-
- task: shell # a flowkit task, exposed to the model
|
|
147
|
-
name: run_command
|
|
148
|
-
description: Run a read-only shell command and return its output.
|
|
149
|
-
parameters:
|
|
150
|
-
type: object
|
|
151
|
-
required: [command]
|
|
152
|
-
properties:
|
|
153
|
-
command: { type: string }
|
|
154
|
-
- name: search_docs # a ctx.agentTools handler
|
|
155
|
-
description: Full-text search the internal docs.
|
|
156
|
-
parameters:
|
|
157
|
-
type: object
|
|
158
|
-
required: [query]
|
|
159
|
-
properties:
|
|
160
|
-
query: { type: string }
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
```typescript
|
|
164
|
-
const runner = new FlowRunner({
|
|
165
|
-
/* ... */
|
|
166
|
-
context: {
|
|
167
|
-
logger,
|
|
168
|
-
llm: provider,
|
|
169
|
-
agentTools: {
|
|
170
|
-
search_docs: async ({ query }) => docs.search(query as string),
|
|
171
|
-
},
|
|
172
|
-
},
|
|
173
|
-
});
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
`result.data`: `text` (final answer), `iterations`, `toolCalls` (every call with
|
|
177
|
-
its name, arguments, `ok`, and truncated `result`), `usage` (aggregated), and
|
|
178
|
-
`finishReason`. Add a `schema` option to get a validated `parsed` final answer —
|
|
179
|
-
the agent reuses the final turn when it already conforms and only spends an
|
|
180
|
-
extra round-trip on the structured pass when it does not.
|
|
181
|
-
|
|
182
|
-
### Parallel tool calls
|
|
183
|
-
|
|
184
|
-
When the model requests several tools in one turn — including several sub-agents
|
|
185
|
-
— they execute concurrently, bounded by `maxConcurrency` (default 4), and their
|
|
186
|
-
results are reassembled in call order so the conversation stays deterministic.
|
|
187
|
-
This is the only concurrency mechanism: there is no parallel flow-step
|
|
188
|
-
construct. Two parallel agentic loops are modeled as two sub-agents of one
|
|
189
|
-
coordinating agent.
|
|
190
|
-
|
|
191
|
-
### Tool safety
|
|
192
|
-
|
|
193
|
-
- **Allowlist** — only declared tools are callable. An unknown tool name is
|
|
194
|
-
reported back to the model, never executed.
|
|
195
|
-
- **Argument validation** — the model's arguments are checked against each
|
|
196
|
-
tool's `parameters` schema before the tool runs; invalid arguments are fed
|
|
197
|
-
back for the model to correct.
|
|
198
|
-
- **Bounded results** — each tool result is truncated to `maxToolResultChars`
|
|
199
|
-
(default 8000). Sub-agent (`agent:`) results use a separate
|
|
200
|
-
`maxAgentResultChars` (default unbounded) so code, diffs, and stack traces are
|
|
201
|
-
not clipped mid-payload like opaque tool output.
|
|
202
|
-
- **Bounded loops** — `maxIterations` (default 8) caps model turns; exceeding it
|
|
203
|
-
fails the step.
|
|
204
|
-
- **Bounded spend** — `tokenBudget` is a true aggregate ceiling: a budgeted
|
|
205
|
-
agent and its entire sub-agent tree charge one shared ledger, so fan-out
|
|
206
|
-
cannot multiply spend past the cap. Reaching it fails the step. Any agent
|
|
207
|
-
whose toolset includes an `agent:` tool **must** set a `tokenBudget` (or run
|
|
208
|
-
under an ancestor that did) — the engine rejects an unbounded fan-out.
|
|
209
|
-
- **Bounded recursion** — `maxAgentDepth` (default 6) caps how deep
|
|
210
|
-
agents-calling-agents may nest.
|
|
211
|
-
|
|
212
|
-
## Declarative agents
|
|
213
|
-
|
|
214
|
-
Inline `agent` tasks are fine for one-offs, but agents you reuse across flows
|
|
215
|
-
(and that call each other) belong in the `agents:` root key of your config. It is
|
|
216
|
-
additive — CumulusCI never had it, so `tasks:` and `flows:` stay byte-identical.
|
|
217
|
-
|
|
218
|
-
```yaml
|
|
219
|
-
agents:
|
|
220
|
-
developer:
|
|
221
|
-
description: Researches and implements a change.
|
|
222
|
-
model: claude-opus-4-8
|
|
223
|
-
system: You implement the requested change using the available tools.
|
|
224
|
-
tools:
|
|
225
|
-
- task: shell
|
|
226
|
-
name: run_command
|
|
227
|
-
parameters:
|
|
228
|
-
type: object
|
|
229
|
-
required: [command]
|
|
230
|
-
properties: { command: { type: string } }
|
|
231
|
-
schema:
|
|
232
|
-
type: object
|
|
233
|
-
required: [summary]
|
|
234
|
-
properties: { summary: { type: string } }
|
|
235
|
-
budget:
|
|
236
|
-
maxIterations: 8
|
|
237
|
-
tokenBudget: 200000
|
|
238
|
-
maxConcurrency: 4
|
|
239
|
-
maxAgentDepth: 4
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
Wire the config and a provider into the runner. The runner compiles each agent,
|
|
243
|
-
registers the `agent` class, and enables `flow:`/`agent:` tools:
|
|
244
|
-
|
|
245
|
-
```typescript
|
|
246
|
-
const runner = new FlowRunner({
|
|
247
|
-
tasks: config.tasks,
|
|
248
|
-
flows: config.flows,
|
|
249
|
-
agents: config.agents, // <- the AI-native layer
|
|
250
|
-
registry,
|
|
251
|
-
context: { logger, llm: provider },
|
|
252
|
-
});
|
|
253
|
-
```
|
|
254
|
-
|
|
255
|
-
A declared agent is usable two ways, both through machinery that already exists:
|
|
256
|
-
|
|
257
|
-
- **As a flow step** — reference it like any task; supply its prompt in the step:
|
|
258
|
-
|
|
259
|
-
```yaml
|
|
260
|
-
flows:
|
|
261
|
-
ship:
|
|
262
|
-
steps:
|
|
263
|
-
1: { flow: dev_org }
|
|
264
|
-
2: { task: developer, options: { prompt: "Implement ${steps.1.ticket}" } }
|
|
265
|
-
3: { task: submit_pr }
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
- **As another agent's tool** — list it under `tools:` with `agent:`. When the
|
|
269
|
-
parent calls it, the model's `prompt` argument becomes the sub-agent's input,
|
|
270
|
-
and the sub-agent's result is fed back. Recursion is bounded by `maxAgentDepth`.
|
|
271
|
-
|
|
272
|
-
### The shape this targets
|
|
273
|
-
|
|
274
|
-
A flow that builds a dev org, researches and develops (with parallel sub-agents),
|
|
275
|
-
deploys, iterates on failed deploys, runs tests, iterates on failed tests, and
|
|
276
|
-
opens a PR collapses to a sequential flow spine where every loop and fork lives
|
|
277
|
-
inside an agent:
|
|
278
|
-
|
|
279
|
-
```yaml
|
|
280
|
-
agents:
|
|
281
|
-
developer: { system: "...", tools: [ { agent: researcher }, { task: shell } ] }
|
|
282
|
-
researcher: { system: "..." } # fanned out as parallel sub-agents
|
|
283
|
-
deployer: { system: "...", tools: [ { task: deploy_scratch } ] } # edit/redeploy loop
|
|
284
|
-
tester: { system: "...", tools: [ { task: run_tests }, { task: shell } ] } # fix/retest loop
|
|
285
|
-
|
|
286
|
-
flows:
|
|
287
|
-
ship:
|
|
288
|
-
steps:
|
|
289
|
-
1: { flow: dev_org }
|
|
290
|
-
2: { task: developer, options: { prompt: "..." } }
|
|
291
|
-
3: { task: deployer, options: { prompt: "Deploy and fix failures." } }
|
|
292
|
-
4: { task: tester, options: { prompt: "Make the tests pass." } }
|
|
293
|
-
5: { task: submit_pr }
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
Steps 3 and 4 are not flow loops. The fix-retest cycle is each agent's own
|
|
297
|
-
tool-use loop. Parallel research in step 2 is the developer emitting several
|
|
298
|
-
`researcher` sub-agent calls in one turn. No `loop:` and no parallel flow step
|
|
299
|
-
appear anywhere.
|
|
300
|
-
|
|
301
|
-
## Testing agents
|
|
302
|
-
|
|
303
|
-
Test the agent loop without a live model by passing a stub `LLMProvider` that
|
|
304
|
-
scripts the turns: each `complete()` call returns the next response, and a
|
|
305
|
-
`finishReason: 'tool_use'` response with `toolCalls` drives the loop into your
|
|
306
|
-
tools. Assert on the returned `data` (final `text`/`parsed`, the `toolCalls`
|
|
307
|
-
record, `usage`).
|
|
308
|
-
|
|
309
|
-
```typescript
|
|
310
|
-
import { AgentTask } from '@db-lyon/flowkit';
|
|
311
|
-
import type { LLMProvider, LLMCompletionResponse } from '@db-lyon/flowkit';
|
|
312
|
-
|
|
313
|
-
// Scripted provider: returns the responses in order.
|
|
314
|
-
function scripted(responses: LLMCompletionResponse[]): LLMProvider {
|
|
315
|
-
let i = 0;
|
|
316
|
-
return { async complete() { return responses[Math.min(i++, responses.length - 1)]!; } };
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const provider = scripted([
|
|
320
|
-
// turn 1: ask for a tool
|
|
321
|
-
{ text: '', finishReason: 'tool_use', toolCalls: [{ id: '1', name: 'add', arguments: { a: 2, b: 3 } }] },
|
|
322
|
-
// turn 2: final answer
|
|
323
|
-
{ text: 'the sum is 5', finishReason: 'stop' },
|
|
324
|
-
]);
|
|
325
|
-
|
|
326
|
-
const task = new AgentTask(
|
|
327
|
-
{ llm: provider, agentTools: { add: ({ a, b }) => ({ sum: (a as number) + (b as number) }) } },
|
|
328
|
-
{ prompt: 'add 2 and 3', tools: [{ name: 'add', parameters: { type: 'object', required: ['a', 'b'] } }] },
|
|
329
|
-
);
|
|
330
|
-
const result = await task.run();
|
|
331
|
-
// result.data.text === 'the sum is 5'; result.data.toolCalls[0].ok === true
|
|
332
|
-
```
|
|
333
|
-
|
|
334
|
-
For sub-agent and flow tools, drive them through a `FlowRunner` configured with
|
|
335
|
-
`agents:` and a provider that branches on `req.system` so one stub can serve
|
|
336
|
-
several agents.
|
|
337
|
-
|
|
338
|
-
## Robustness controls
|
|
339
|
-
|
|
340
|
-
These option fields apply to both `agent_prompt` and `agent`:
|
|
341
|
-
|
|
342
|
-
| Option | Default | Effect |
|
|
343
|
-
| ----------------- | ------- | ------------------------------------------------------------- |
|
|
344
|
-
| `timeout` | 60000 | Per-call timeout in ms; the provider's `signal` is aborted. |
|
|
345
|
-
| `retries` | 2 | Transport retries on failure, with exponential backoff. |
|
|
346
|
-
| `retryDelay` | 500 | Base backoff in ms; doubles each retry. |
|
|
347
|
-
| `repairAttempts` | 1 | Structured-output re-prompts before failing. |
|
|
348
|
-
| `maxOutputChars` | 0 | Cap on response text length (0 = unlimited). |
|
|
349
|
-
|
|
350
|
-
Programmatic callers can use `runCompletion(provider, request, options, logger)`
|
|
351
|
-
directly — it is the shared core both tasks build on, and it accepts a
|
|
352
|
-
`retryOn(err)` predicate for fine-grained retry control.
|
|
353
|
-
|
|
354
|
-
## Security notes
|
|
355
|
-
|
|
356
|
-
- **Prompt injection.** Templating prior step output (`${steps...}`) or feeding
|
|
357
|
-
tool results (file contents, shell output) back to the model means untrusted
|
|
358
|
-
text reaches it and can carry instructions. A `schema` does **not** defend
|
|
359
|
-
against this — it validates the shape of *outbound* output, not the inbound
|
|
360
|
-
text that carries an injection. The real control is the toolbox: an `agent`
|
|
361
|
-
whose tools include `shell` can run whatever the model is talked into. Prefer
|
|
362
|
-
narrow, read-only tools and scope every tool tightly.
|
|
363
|
-
- **Secret hygiene.** The tasks log prompts only at `debug`, and previews are
|
|
364
|
-
whitespace-collapsed and length-capped. Use `redact()` before logging
|
|
365
|
-
provider config so API keys and tokens never reach your logs.
|
|
366
|
-
- **Resource bounds.** `timeout`, `maxOutputChars`, `maxToolResultChars`, and
|
|
367
|
-
`maxIterations` together bound how long a step runs, how much it can emit, and
|
|
368
|
-
how far an agent can wander.
|
|
1
|
+
# AI agents
|
|
2
|
+
|
|
3
|
+
Flowkit can drive LLM calls as ordinary steps. A flow can mix deterministic
|
|
4
|
+
tasks with steps that prompt a model, extract structured data, or run a
|
|
5
|
+
tool-calling agent — and the model output flows into later steps through the
|
|
6
|
+
same `${steps.<id>.<path>}` references as anything else.
|
|
7
|
+
|
|
8
|
+
Flowkit ships **no SDK dependencies**. You supply a provider that adapts your
|
|
9
|
+
model of choice to a small neutral contract; the engine stays model-agnostic.
|
|
10
|
+
|
|
11
|
+
## Wiring a provider
|
|
12
|
+
|
|
13
|
+
Implement `LLMProvider` and attach it to the task context as `llm`. The
|
|
14
|
+
provider's only job is to translate flowkit's neutral request/response shape to
|
|
15
|
+
and from your SDK.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { FlowRunner, type LLMProvider } from '@db-lyon/flowkit';
|
|
19
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
20
|
+
|
|
21
|
+
const client = new Anthropic();
|
|
22
|
+
|
|
23
|
+
const provider: LLMProvider = {
|
|
24
|
+
async complete(req) {
|
|
25
|
+
const res = await client.messages.create(
|
|
26
|
+
{
|
|
27
|
+
model: req.model ?? 'claude-opus-4-8',
|
|
28
|
+
max_tokens: req.maxTokens ?? 1024,
|
|
29
|
+
system: req.system,
|
|
30
|
+
temperature: req.temperature,
|
|
31
|
+
messages: toAnthropicMessages(req), // map req.prompt / req.messages
|
|
32
|
+
tools: req.tools?.map(toAnthropicTool),
|
|
33
|
+
},
|
|
34
|
+
{ signal: req.signal }, // honor cancellation/timeout
|
|
35
|
+
);
|
|
36
|
+
return {
|
|
37
|
+
text: textOf(res),
|
|
38
|
+
toolCalls: toolCallsOf(res),
|
|
39
|
+
finishReason: res.stop_reason ?? undefined,
|
|
40
|
+
usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens },
|
|
41
|
+
model: res.model,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const runner = new FlowRunner({
|
|
47
|
+
tasks: config.tasks,
|
|
48
|
+
flows: config.flows,
|
|
49
|
+
registry,
|
|
50
|
+
context: { logger, llm: provider }, // <- the seam
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A provider may ignore any field it does not support. A request that only sets
|
|
55
|
+
`prompt` works against the simplest possible adapter.
|
|
56
|
+
|
|
57
|
+
> Model id, pricing, and SDK specifics for Claude live in the `claude-api`
|
|
58
|
+
> reference — don't hard-code a stale model id.
|
|
59
|
+
|
|
60
|
+
## Single-shot prompts — `AgentPromptTask`
|
|
61
|
+
|
|
62
|
+
`class_path: agent_prompt`. One prompt in, one response out. Register it:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
import { AgentPromptTask } from '@db-lyon/flowkit';
|
|
66
|
+
registry.register('agent_prompt', AgentPromptTask as any);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
tasks:
|
|
71
|
+
summarize:
|
|
72
|
+
class_path: agent_prompt
|
|
73
|
+
options:
|
|
74
|
+
system: You extract action items from meeting notes.
|
|
75
|
+
prompt: "Summarize:\n${steps.1.text}"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`result.data`: `text` (always), plus `parsed`, `usage`, `finishReason`, `model`,
|
|
79
|
+
and `truncated` when present.
|
|
80
|
+
|
|
81
|
+
### Structured output
|
|
82
|
+
|
|
83
|
+
Pass a JSON Schema. The response is validated against it; on a mismatch the
|
|
84
|
+
model is re-prompted with the concrete validation errors (the **repair loop**)
|
|
85
|
+
before the step fails.
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
tasks:
|
|
89
|
+
extract:
|
|
90
|
+
class_path: agent_prompt
|
|
91
|
+
options:
|
|
92
|
+
prompt: "Pull the ticket fields from:\n${steps.1.text}"
|
|
93
|
+
schema:
|
|
94
|
+
type: object
|
|
95
|
+
required: [title, priority]
|
|
96
|
+
properties:
|
|
97
|
+
title: { type: string }
|
|
98
|
+
priority: { type: string, enum: [low, medium, high] }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
On success `result.data.parsed` holds the validated object. If the model never
|
|
102
|
+
conforms, the step fails with a `StructuredOutputError` and `result.data.text`
|
|
103
|
+
carries the last raw output for debugging.
|
|
104
|
+
|
|
105
|
+
> **Reference paths are rooted at the step's `data`.** In `${steps.<id>.<path>}`,
|
|
106
|
+
> `<path>` is relative to that step's `result.data`, so a later step reads a
|
|
107
|
+
> structured field as `${steps.extract.parsed.title}` or the raw text as
|
|
108
|
+
> `${steps.1.text}` — **not** `${steps.1.data.text}` (that resolves to
|
|
109
|
+
> `data.data.text`, which is undefined). `<id>` is a step number or a task name.
|
|
110
|
+
|
|
111
|
+
The bundled validator covers the JSON Schema subset used for structured output
|
|
112
|
+
(`type`, `enum`, `const`, `required`, `properties`, `items`,
|
|
113
|
+
`additionalProperties`, length/number bounds, `anyOf`/`oneOf`/`allOf`/`not`,
|
|
114
|
+
and OpenAPI-style `nullable`). Unknown keywords are ignored rather than
|
|
115
|
+
rejected.
|
|
116
|
+
|
|
117
|
+
## Tool-calling agents — `AgentTask`
|
|
118
|
+
|
|
119
|
+
`class_path: agent`. A multi-turn loop: the model requests tool calls, the agent
|
|
120
|
+
runs them, feeds the results back, and repeats until the model gives a final
|
|
121
|
+
answer or `maxIterations` is hit.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
import { AgentTask } from '@db-lyon/flowkit';
|
|
125
|
+
registry.register('agent', AgentTask as any);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
A tool references an existing flowkit primitive — a `task:`, a `flow:`, or
|
|
129
|
+
another `agent:` — or a **context handler** (a function on `ctx.agentTools`,
|
|
130
|
+
matched by `name:`). Tool dispatch reuses the registry and options machinery, so
|
|
131
|
+
there is no separate tool concept to maintain. A task-backed tool inherits its
|
|
132
|
+
configured `class_path` and `options` defaults, with the model's arguments
|
|
133
|
+
layered on top, so a task behaves the same as a tool as it does as a flow step.
|
|
134
|
+
`flow:` and `agent:` tools require a `FlowRunner` context (see "Declarative
|
|
135
|
+
agents" below).
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
tasks:
|
|
139
|
+
research:
|
|
140
|
+
class_path: agent
|
|
141
|
+
options:
|
|
142
|
+
system: You answer questions using the available tools.
|
|
143
|
+
prompt: "How many open PRs touch the auth module?"
|
|
144
|
+
maxIterations: 6
|
|
145
|
+
tools:
|
|
146
|
+
- task: shell # a flowkit task, exposed to the model
|
|
147
|
+
name: run_command
|
|
148
|
+
description: Run a read-only shell command and return its output.
|
|
149
|
+
parameters:
|
|
150
|
+
type: object
|
|
151
|
+
required: [command]
|
|
152
|
+
properties:
|
|
153
|
+
command: { type: string }
|
|
154
|
+
- name: search_docs # a ctx.agentTools handler
|
|
155
|
+
description: Full-text search the internal docs.
|
|
156
|
+
parameters:
|
|
157
|
+
type: object
|
|
158
|
+
required: [query]
|
|
159
|
+
properties:
|
|
160
|
+
query: { type: string }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
const runner = new FlowRunner({
|
|
165
|
+
/* ... */
|
|
166
|
+
context: {
|
|
167
|
+
logger,
|
|
168
|
+
llm: provider,
|
|
169
|
+
agentTools: {
|
|
170
|
+
search_docs: async ({ query }) => docs.search(query as string),
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`result.data`: `text` (final answer), `iterations`, `toolCalls` (every call with
|
|
177
|
+
its name, arguments, `ok`, and truncated `result`), `usage` (aggregated), and
|
|
178
|
+
`finishReason`. Add a `schema` option to get a validated `parsed` final answer —
|
|
179
|
+
the agent reuses the final turn when it already conforms and only spends an
|
|
180
|
+
extra round-trip on the structured pass when it does not.
|
|
181
|
+
|
|
182
|
+
### Parallel tool calls
|
|
183
|
+
|
|
184
|
+
When the model requests several tools in one turn — including several sub-agents
|
|
185
|
+
— they execute concurrently, bounded by `maxConcurrency` (default 4), and their
|
|
186
|
+
results are reassembled in call order so the conversation stays deterministic.
|
|
187
|
+
This is the only concurrency mechanism: there is no parallel flow-step
|
|
188
|
+
construct. Two parallel agentic loops are modeled as two sub-agents of one
|
|
189
|
+
coordinating agent.
|
|
190
|
+
|
|
191
|
+
### Tool safety
|
|
192
|
+
|
|
193
|
+
- **Allowlist** — only declared tools are callable. An unknown tool name is
|
|
194
|
+
reported back to the model, never executed.
|
|
195
|
+
- **Argument validation** — the model's arguments are checked against each
|
|
196
|
+
tool's `parameters` schema before the tool runs; invalid arguments are fed
|
|
197
|
+
back for the model to correct.
|
|
198
|
+
- **Bounded results** — each tool result is truncated to `maxToolResultChars`
|
|
199
|
+
(default 8000). Sub-agent (`agent:`) results use a separate
|
|
200
|
+
`maxAgentResultChars` (default unbounded) so code, diffs, and stack traces are
|
|
201
|
+
not clipped mid-payload like opaque tool output.
|
|
202
|
+
- **Bounded loops** — `maxIterations` (default 8) caps model turns; exceeding it
|
|
203
|
+
fails the step.
|
|
204
|
+
- **Bounded spend** — `tokenBudget` is a true aggregate ceiling: a budgeted
|
|
205
|
+
agent and its entire sub-agent tree charge one shared ledger, so fan-out
|
|
206
|
+
cannot multiply spend past the cap. Reaching it fails the step. Any agent
|
|
207
|
+
whose toolset includes an `agent:` tool **must** set a `tokenBudget` (or run
|
|
208
|
+
under an ancestor that did) — the engine rejects an unbounded fan-out.
|
|
209
|
+
- **Bounded recursion** — `maxAgentDepth` (default 6) caps how deep
|
|
210
|
+
agents-calling-agents may nest.
|
|
211
|
+
|
|
212
|
+
## Declarative agents
|
|
213
|
+
|
|
214
|
+
Inline `agent` tasks are fine for one-offs, but agents you reuse across flows
|
|
215
|
+
(and that call each other) belong in the `agents:` root key of your config. It is
|
|
216
|
+
additive — CumulusCI never had it, so `tasks:` and `flows:` stay byte-identical.
|
|
217
|
+
|
|
218
|
+
```yaml
|
|
219
|
+
agents:
|
|
220
|
+
developer:
|
|
221
|
+
description: Researches and implements a change.
|
|
222
|
+
model: claude-opus-4-8
|
|
223
|
+
system: You implement the requested change using the available tools.
|
|
224
|
+
tools:
|
|
225
|
+
- task: shell
|
|
226
|
+
name: run_command
|
|
227
|
+
parameters:
|
|
228
|
+
type: object
|
|
229
|
+
required: [command]
|
|
230
|
+
properties: { command: { type: string } }
|
|
231
|
+
schema:
|
|
232
|
+
type: object
|
|
233
|
+
required: [summary]
|
|
234
|
+
properties: { summary: { type: string } }
|
|
235
|
+
budget:
|
|
236
|
+
maxIterations: 8
|
|
237
|
+
tokenBudget: 200000
|
|
238
|
+
maxConcurrency: 4
|
|
239
|
+
maxAgentDepth: 4
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Wire the config and a provider into the runner. The runner compiles each agent,
|
|
243
|
+
registers the `agent` class, and enables `flow:`/`agent:` tools:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
const runner = new FlowRunner({
|
|
247
|
+
tasks: config.tasks,
|
|
248
|
+
flows: config.flows,
|
|
249
|
+
agents: config.agents, // <- the AI-native layer
|
|
250
|
+
registry,
|
|
251
|
+
context: { logger, llm: provider },
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
A declared agent is usable two ways, both through machinery that already exists:
|
|
256
|
+
|
|
257
|
+
- **As a flow step** — reference it like any task; supply its prompt in the step:
|
|
258
|
+
|
|
259
|
+
```yaml
|
|
260
|
+
flows:
|
|
261
|
+
ship:
|
|
262
|
+
steps:
|
|
263
|
+
1: { flow: dev_org }
|
|
264
|
+
2: { task: developer, options: { prompt: "Implement ${steps.1.ticket}" } }
|
|
265
|
+
3: { task: submit_pr }
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
- **As another agent's tool** — list it under `tools:` with `agent:`. When the
|
|
269
|
+
parent calls it, the model's `prompt` argument becomes the sub-agent's input,
|
|
270
|
+
and the sub-agent's result is fed back. Recursion is bounded by `maxAgentDepth`.
|
|
271
|
+
|
|
272
|
+
### The shape this targets
|
|
273
|
+
|
|
274
|
+
A flow that builds a dev org, researches and develops (with parallel sub-agents),
|
|
275
|
+
deploys, iterates on failed deploys, runs tests, iterates on failed tests, and
|
|
276
|
+
opens a PR collapses to a sequential flow spine where every loop and fork lives
|
|
277
|
+
inside an agent:
|
|
278
|
+
|
|
279
|
+
```yaml
|
|
280
|
+
agents:
|
|
281
|
+
developer: { system: "...", tools: [ { agent: researcher }, { task: shell } ] }
|
|
282
|
+
researcher: { system: "..." } # fanned out as parallel sub-agents
|
|
283
|
+
deployer: { system: "...", tools: [ { task: deploy_scratch } ] } # edit/redeploy loop
|
|
284
|
+
tester: { system: "...", tools: [ { task: run_tests }, { task: shell } ] } # fix/retest loop
|
|
285
|
+
|
|
286
|
+
flows:
|
|
287
|
+
ship:
|
|
288
|
+
steps:
|
|
289
|
+
1: { flow: dev_org }
|
|
290
|
+
2: { task: developer, options: { prompt: "..." } }
|
|
291
|
+
3: { task: deployer, options: { prompt: "Deploy and fix failures." } }
|
|
292
|
+
4: { task: tester, options: { prompt: "Make the tests pass." } }
|
|
293
|
+
5: { task: submit_pr }
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Steps 3 and 4 are not flow loops. The fix-retest cycle is each agent's own
|
|
297
|
+
tool-use loop. Parallel research in step 2 is the developer emitting several
|
|
298
|
+
`researcher` sub-agent calls in one turn. No `loop:` and no parallel flow step
|
|
299
|
+
appear anywhere.
|
|
300
|
+
|
|
301
|
+
## Testing agents
|
|
302
|
+
|
|
303
|
+
Test the agent loop without a live model by passing a stub `LLMProvider` that
|
|
304
|
+
scripts the turns: each `complete()` call returns the next response, and a
|
|
305
|
+
`finishReason: 'tool_use'` response with `toolCalls` drives the loop into your
|
|
306
|
+
tools. Assert on the returned `data` (final `text`/`parsed`, the `toolCalls`
|
|
307
|
+
record, `usage`).
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
import { AgentTask } from '@db-lyon/flowkit';
|
|
311
|
+
import type { LLMProvider, LLMCompletionResponse } from '@db-lyon/flowkit';
|
|
312
|
+
|
|
313
|
+
// Scripted provider: returns the responses in order.
|
|
314
|
+
function scripted(responses: LLMCompletionResponse[]): LLMProvider {
|
|
315
|
+
let i = 0;
|
|
316
|
+
return { async complete() { return responses[Math.min(i++, responses.length - 1)]!; } };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const provider = scripted([
|
|
320
|
+
// turn 1: ask for a tool
|
|
321
|
+
{ text: '', finishReason: 'tool_use', toolCalls: [{ id: '1', name: 'add', arguments: { a: 2, b: 3 } }] },
|
|
322
|
+
// turn 2: final answer
|
|
323
|
+
{ text: 'the sum is 5', finishReason: 'stop' },
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
const task = new AgentTask(
|
|
327
|
+
{ llm: provider, agentTools: { add: ({ a, b }) => ({ sum: (a as number) + (b as number) }) } },
|
|
328
|
+
{ prompt: 'add 2 and 3', tools: [{ name: 'add', parameters: { type: 'object', required: ['a', 'b'] } }] },
|
|
329
|
+
);
|
|
330
|
+
const result = await task.run();
|
|
331
|
+
// result.data.text === 'the sum is 5'; result.data.toolCalls[0].ok === true
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
For sub-agent and flow tools, drive them through a `FlowRunner` configured with
|
|
335
|
+
`agents:` and a provider that branches on `req.system` so one stub can serve
|
|
336
|
+
several agents.
|
|
337
|
+
|
|
338
|
+
## Robustness controls
|
|
339
|
+
|
|
340
|
+
These option fields apply to both `agent_prompt` and `agent`:
|
|
341
|
+
|
|
342
|
+
| Option | Default | Effect |
|
|
343
|
+
| ----------------- | ------- | ------------------------------------------------------------- |
|
|
344
|
+
| `timeout` | 60000 | Per-call timeout in ms; the provider's `signal` is aborted. |
|
|
345
|
+
| `retries` | 2 | Transport retries on failure, with exponential backoff. |
|
|
346
|
+
| `retryDelay` | 500 | Base backoff in ms; doubles each retry. |
|
|
347
|
+
| `repairAttempts` | 1 | Structured-output re-prompts before failing. |
|
|
348
|
+
| `maxOutputChars` | 0 | Cap on response text length (0 = unlimited). |
|
|
349
|
+
|
|
350
|
+
Programmatic callers can use `runCompletion(provider, request, options, logger)`
|
|
351
|
+
directly — it is the shared core both tasks build on, and it accepts a
|
|
352
|
+
`retryOn(err)` predicate for fine-grained retry control.
|
|
353
|
+
|
|
354
|
+
## Security notes
|
|
355
|
+
|
|
356
|
+
- **Prompt injection.** Templating prior step output (`${steps...}`) or feeding
|
|
357
|
+
tool results (file contents, shell output) back to the model means untrusted
|
|
358
|
+
text reaches it and can carry instructions. A `schema` does **not** defend
|
|
359
|
+
against this — it validates the shape of *outbound* output, not the inbound
|
|
360
|
+
text that carries an injection. The real control is the toolbox: an `agent`
|
|
361
|
+
whose tools include `shell` can run whatever the model is talked into. Prefer
|
|
362
|
+
narrow, read-only tools and scope every tool tightly.
|
|
363
|
+
- **Secret hygiene.** The tasks log prompts only at `debug`, and previews are
|
|
364
|
+
whitespace-collapsed and length-capped. Use `redact()` before logging
|
|
365
|
+
provider config so API keys and tokens never reach your logs.
|
|
366
|
+
- **Resource bounds.** `timeout`, `maxOutputChars`, `maxToolResultChars`, and
|
|
367
|
+
`maxIterations` together bound how long a step runs, how much it can emit, and
|
|
368
|
+
how far an agent can wander.
|