@db-lyon/flowkit 0.11.1 → 0.11.2

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 CHANGED
@@ -186,7 +186,7 @@ tasks:
186
186
  extract:
187
187
  class_path: agent_prompt
188
188
  options:
189
- prompt: "Pull the ticket fields from:\n${steps.1.data.text}"
189
+ prompt: "Pull the ticket fields from:\n${steps.1.text}"
190
190
  schema:
191
191
  type: object
192
192
  required: [title, priority]
@@ -0,0 +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.