Skip to content

Commit 15fc2cb

Browse files
authored
Merge pull request #104 from kioku/52/dependent-batch-workflows
feat: dependent batch workflows with variable capture and chaining
2 parents 61d87c3 + e168182 commit 15fc2cb

File tree

10 files changed

+2976
-11
lines changed

10 files changed

+2976
-11
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# ADR 010: Dependent Batch Workflows
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
The batch processing system (§9.1 of the architecture doc) executes multiple API
10+
operations concurrently from a single batch file. However, real-world agent workflows
11+
are frequently sequential with data dependencies between steps — for example,
12+
"Create User → capture the returned ID → Get User by ID → Add User to Group."
13+
14+
Without dependency support, agents must issue individual `aperture` invocations for
15+
each step, parse intermediate results, and construct subsequent calls. This
16+
introduces per-step latency from the agent ↔ tool roundtrip and pushes orchestration
17+
complexity into the agent.
18+
19+
The design space included three broad approaches:
20+
21+
1. **External orchestration** — keep the batch system independent; let the agent or a
22+
shell script handle sequencing. Simplest for Aperture, but shifts all complexity to
23+
the caller and multiplies invocation overhead.
24+
2. **In-batch dependency DSL** — extend the batch file format with dependency
25+
declarations, variable capture, and interpolation so that multi-step workflows
26+
execute in a single invocation.
27+
3. **Workflow engine** — build a full DAG executor with conditional branches,
28+
loops, and retry-per-step. Powerful, but significantly more complex than the
29+
problem requires.
30+
31+
## Decision
32+
33+
We chose option 2: an in-batch dependency DSL. Three optional fields are added to
34+
`BatchOperation`:
35+
36+
- **`capture`** (`HashMap<String, String>`): scalar value extraction via JQ queries.
37+
- **`capture_append`** (`HashMap<String, String>`): list accumulation via JQ queries
38+
for fan-out/aggregate patterns.
39+
- **`depends_on`** (`Vec<String>`): explicit dependency declaration on other
40+
operations by `id`.
41+
42+
The batch processor auto-detects whether any operation uses these fields and switches
43+
from concurrent to sequential execution. Existing batch files are unaffected.
44+
45+
### Key design choices
46+
47+
**Automatic execution path selection.** Rather than requiring a flag or field to opt
48+
into dependent mode, the processor inspects the operations and chooses the path. This
49+
preserves full backward compatibility — a batch file that doesn't use `capture`,
50+
`capture_append`, or `depends_on` runs exactly as before.
51+
52+
**Implicit dependency inference.** Operations that reference `{{variable}}` in their
53+
args automatically depend on the operation(s) that capture that variable. This reduces
54+
boilerplate: for simple linear chains, `depends_on` can be omitted entirely. For
55+
`capture_append` variables with multiple providers, the consumer implicitly depends on
56+
all of them.
57+
58+
**Atomic execution semantics.** In dependent mode, execution halts on the first
59+
failure. Subsequent operations are marked as "Skipped due to prior failure" with no
60+
HTTP requests made. This prevents cascading errors and gives agents a clear signal
61+
about where the workflow broke. The alternative — continue-on-error — was considered
62+
but rejected for the dependent path because downstream operations would fail anyway
63+
due to missing captured variables.
64+
65+
**JQ for extraction.** Capture queries reuse the existing `apply_jq_filter` function
66+
from the execution engine rather than introducing a new extraction syntax. JQ is
67+
already a dependency and is well-understood by agents.
68+
69+
**Scalar/list variable separation.** `capture` produces scalar string values;
70+
`capture_append` accumulates into lists that interpolate as JSON array literals.
71+
Scalars take precedence when both exist for the same name. This keeps the common case
72+
(scalar capture) simple while supporting fan-out/aggregate patterns without requiring
73+
the user to manage array construction.
74+
75+
**Topological sort with original-order preservation.** Kahn's algorithm is used for
76+
topological sorting. Among operations with equal topological rank (no ordering
77+
constraint between them), the original file order is preserved. This makes execution
78+
order predictable and debuggable.
79+
80+
## Consequences
81+
82+
### Positive
83+
84+
- Multi-step agent workflows execute in a single `aperture` invocation, eliminating
85+
per-step roundtrip latency.
86+
- The batch file becomes a self-contained workflow specification that can be validated
87+
(cycle detection, missing references) before any HTTP request is made.
88+
- Full backward compatibility — no changes to existing batch files or concurrent
89+
execution behavior.
90+
- Structured error reporting for all dependency-related failures (cycles, missing
91+
references, undefined variables, capture failures) with `--json-errors` support.
92+
93+
### Negative
94+
95+
- Dependent execution is strictly sequential. There is no support for executing
96+
independent sub-graphs in parallel within a dependent batch. This is acceptable for
97+
the current use case but may become a limitation for complex workflows.
98+
- `--dry-run` combined with dependent batches is of limited utility: the first
99+
operation's capture will fail (dry-run output doesn't match the real response
100+
schema), causing all subsequent operations to be skipped.
101+
- The `{{variable}}` syntax uses double-braces which could conflict with literal
102+
`{{` in JSON bodies. This is mitigated by only scanning for variables when the
103+
batch actually uses dependency features (`has_dependencies()` check).
104+
105+
### Future considerations
106+
107+
- **Parallel sub-graph execution**: operations at the same topological level with no
108+
mutual dependencies could be executed concurrently. This would require tracking
109+
in-degree per level and coordinating capture writes.
110+
- **Conditional execution**: skip or branch based on captured values (e.g., only add
111+
to group if user creation returned a specific status).
112+
- **Per-operation retry in dependent mode**: currently, a failed operation halts the
113+
entire batch. Integrating the retry system could allow transient failures to be
114+
retried before declaring the operation failed.

docs/agent-integration.md

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,29 @@ aperture api my-api --describe-json
6868
"type": "http",
6969
"scheme": "bearer"
7070
}
71+
},
72+
"batch": {
73+
"file_formats": ["json", "yaml"],
74+
"operation_schema": { "fields": ["..."] },
75+
"dependent_workflows": {
76+
"interpolation_syntax": "{{variable_name}}",
77+
"execution_modes": { "concurrent": "...", "dependent": "..." },
78+
"dependent_execution": { "ordering": "...", "failure_mode": "...", "implicit_dependencies": true, "variable_types": { "scalar": "...", "list": "..." } }
79+
}
7180
}
7281
}
7382
```
7483

84+
The `batch` section describes the batch file schema and dependent workflow capabilities. Agents can query it directly:
85+
86+
```bash
87+
# Discover batch operation fields
88+
aperture api my-api --describe-json --jq '.batch.operation_schema'
89+
90+
# Discover dependent workflow semantics
91+
aperture api my-api --describe-json --jq '.batch.dependent_workflows'
92+
```
93+
7594
**Command mapping fields in the manifest:**
7695

7796
| Field | Type | Description |
@@ -246,6 +265,159 @@ aperture api my-api --batch-file operations.json --json-errors
246265
}
247266
```
248267

268+
### Dependent Batch Workflows
269+
270+
When operations depend on each other's results, Aperture supports **variable capture and interpolation** within batch files. This enables multi-step workflows like "Create User → capture ID → Get User by ID → Add to Group" in a single batch invocation.
271+
272+
Aperture automatically detects when a batch uses dependency features and switches from concurrent to sequential execution. Existing batch files without dependency features continue to run concurrently with no changes required.
273+
274+
#### Batch File Format
275+
276+
Three optional fields on each operation enable dependent workflows:
277+
278+
| Field | Type | Description |
279+
|-------|------|-------------|
280+
| `capture` | `map<string, string>` | Extract scalar values from the response via JQ queries. Maps variable name → JQ query. |
281+
| `capture_append` | `map<string, string>` | Append extracted values to a named list via JQ queries. Enables fan-out/aggregate patterns. |
282+
| `depends_on` | `string[]` | Explicit dependency on other operations by `id`. |
283+
284+
Operations that use `capture`, `capture_append`, or `depends_on` **must** have an `id`.
285+
286+
**Linear chain example (YAML):**
287+
288+
```yaml
289+
operations:
290+
- id: create-user
291+
args: [users, create-user, --body, '{"name": "Alice"}']
292+
capture:
293+
user_id: ".id"
294+
295+
- id: get-user
296+
args: [users, get-user-by-id, --id, "{{user_id}}"]
297+
depends_on: [create-user]
298+
```
299+
300+
1. `create-user` executes `POST /users`, receives `{"id": "abc-123", ...}`.
301+
2. The JQ query `.id` extracts `"abc-123"` and stores it as `user_id`.
302+
3. `get-user` waits for `create-user` to complete, then replaces `{{user_id}}` with `abc-123` in its args before executing `GET /users/abc-123`.
303+
304+
**Fan-out/aggregate example:**
305+
306+
```yaml
307+
operations:
308+
- id: beat-1
309+
args: [events, add, --body, '{"type": "start"}']
310+
capture_append:
311+
event_ids: ".id"
312+
313+
- id: beat-2
314+
args: [events, add, --body, '{"type": "end"}']
315+
capture_append:
316+
event_ids: ".id"
317+
318+
- id: set-order
319+
depends_on: [beat-1, beat-2]
320+
args: [narrative, set, --body, '{"eventIds": {{event_ids}}}']
321+
```
322+
323+
Both `beat-1` and `beat-2` append their extracted IDs to the `event_ids` list. `set-order` waits for both, then `{{event_ids}}` interpolates as a JSON array literal: `["id-1","id-2"]`.
324+
325+
#### Variable Interpolation
326+
327+
Captured values are interpolated into operation `args` using `{{variable}}` syntax:
328+
329+
- **Scalar variables** (from `capture`): `{{name}}` is replaced with the captured string value.
330+
- **List variables** (from `capture_append`): `{{name}}` is replaced with a JSON array literal (e.g., `["a","b","c"]`).
331+
332+
If a scalar and a list share the same name, the scalar takes precedence.
333+
334+
#### Implicit Dependencies
335+
336+
Operations that reference `{{variable}}` in their args automatically depend on the operation that captures that variable, even without an explicit `depends_on`. This means the following two batch files are equivalent:
337+
338+
```yaml
339+
# Explicit dependency
340+
operations:
341+
- id: create
342+
args: [users, create-user, --body, '{"name": "Alice"}']
343+
capture:
344+
user_id: ".id"
345+
- id: get
346+
args: [users, get-user-by-id, --id, "{{user_id}}"]
347+
depends_on: [create]
348+
```
349+
350+
```yaml
351+
# Implicit dependency (inferred from {{user_id}})
352+
operations:
353+
- id: create
354+
args: [users, create-user, --body, '{"name": "Alice"}']
355+
capture:
356+
user_id: ".id"
357+
- id: get
358+
args: [users, get-user-by-id, --id, "{{user_id}}"]
359+
```
360+
361+
For `capture_append` variables with multiple providers, the consumer implicitly depends on **all** providers.
362+
363+
#### Execution Strategy
364+
365+
Aperture automatically selects the execution path based on the batch content:
366+
367+
| Condition | Execution Path | Behavior |
368+
|-----------|---------------|----------|
369+
| No operation uses `capture`, `capture_append`, or `depends_on` | **Concurrent** (original) | Parallel execution with concurrency/rate-limit controls |
370+
| Any operation uses `capture`, `capture_append`, or `depends_on` | **Dependent** (new) | Sequential in topological order with variable interpolation |
371+
372+
The dependent path:
373+
1. Validates the dependency graph (cycle detection, missing references, required IDs, duplicate IDs).
374+
2. Topologically sorts operations (Kahn's algorithm). Operations without dependencies preserve their original relative order.
375+
3. Executes operations one at a time in sorted order.
376+
4. Before each operation: interpolates `{{variables}}` in args.
377+
5. After each operation: extracts captures into the variable store.
378+
379+
#### Atomic Execution
380+
381+
In dependent mode, execution halts immediately on the first failure. Subsequent operations are marked as **"Skipped due to prior failure"** and no further HTTP requests are made. This prevents cascading errors and ensures the agent receives a clear signal about which step failed.
382+
383+
```
384+
Starting dependent batch execution: 3 operations
385+
Operation 'create' completed
386+
Operation 'get-user' failed: HttpError: HTTP 404 error for 'myapi': (empty response)
387+
Dependent batch completed: 1/3 operations successful in 0.11s
388+
```
389+
390+
#### Dependency Errors
391+
392+
All dependency-related errors produce structured output with `--json-errors`:
393+
394+
| Error | Cause | Example |
395+
|-------|-------|---------|
396+
| Cycle detected | Circular `depends_on` references | `a → b → a` |
397+
| Missing dependency | `depends_on` references a non-existent `id` | `depends_on: [nonexistent]` |
398+
| Missing ID | Operation uses `capture`/`depends_on` but has no `id` | `capture` without `id` |
399+
| Undefined variable | `{{var}}` references a variable not captured by any operation | `{{typo}}` |
400+
| Capture failed | JQ query returned null/empty or failed | `.missing_field` on `{"id": 1}` |
401+
402+
```bash
403+
aperture api my-api --json-errors --batch-file cycle.yaml
404+
```
405+
406+
```json
407+
{
408+
"error_type": "Validation",
409+
"message": "Dependency cycle detected in batch operations: a → b",
410+
"context": "Remove circular dependencies between batch operations.",
411+
"details": {
412+
"cycle": ["a", "b"]
413+
}
414+
}
415+
```
416+
417+
#### Dry-Run Behavior
418+
419+
In `--dry-run` mode, the dependent execution path runs but receives dry-run output (request details) instead of real API responses. JQ capture queries will typically fail because the dry-run output does not match the expected response schema. The first operation is marked as a capture failure, and subsequent operations are skipped. This is expected behavior — dry-run validates request construction, not response processing.
420+
249421
## Automatic Retry with Exponential Backoff
250422

251423
Aperture supports automatic retries for transient failures, with exponential backoff and jitter. This is essential for reliable agent workflows interacting with rate-limited or occasionally unavailable APIs.

0 commit comments

Comments
 (0)