File size: 40,123 Bytes
116524e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
# Pipeline Architecture Design

Design decisions for the generalized pipeline system. Trying to keep is as generic as possible.
---

## Core Primitives

Everything in the framework composes from three primitives:

```

Sequential:  A β†’ B β†’ C

Branch:      A β†’ (B βˆ₯ C) β†’ D    (fork + implicit join)

Pipeline:    a step that is itself a pipeline (nesting / reuse)

```

---

## Step

A `Step` is the smallest unit of work. It receives a `StepContext`, does one focused thing, and returns the context.

```python

class MyStep:

    requires = {"agent_output"}   # fields it reads

    provides = {"reflections"}    # fields it writes



    def __call__(self, ctx: StepContext) -> StepContext:

        ...

        return ctx

```

Rules:
- Always synchronous within its own execution
- Must declare `requires` and `provides` β€” the pipeline validates ordering at construction time
- Steps declare their own parallelism constraints (see below)

### Step protocol

For static type checking, the framework exposes a generic `typing.Protocol`:

```python

from typing import Protocol, TypeVar, runtime_checkable



Ctx = TypeVar("Ctx", bound=StepContext)



@runtime_checkable

class StepProtocol(Protocol[Ctx]):

    requires: frozenset[str]

    provides: frozenset[str]



    def __call__(self, ctx: Ctx) -> Ctx: ...

```

`StepProtocol` is generic over the context type. The base `StepProtocol` (or `StepProtocol[StepContext]`) is satisfied by `Pipeline` and `Branch`, so they can be nested wherever a step is expected. Domain-specific steps use the parameterized form β€” e.g. `StepProtocol[ACEStepContext]` β€” so that mypy validates the `__call__` signature against the concrete context subclass without needing `# type: ignore` comments.

`@runtime_checkable` lets the pipeline validator use `isinstance(step, StepProtocol)` at construction time to give a clear error if a step is missing required attributes, rather than failing at call time. The type parameter is erased at runtime, so `isinstance` checks work the same as with a non-generic protocol.

### StepContext β€” immutability contract

`StepContext` is a frozen dataclass. Steps never mutate the incoming context β€” they return a new one via `.replace()`.

The pipeline engine defines a minimal base with only two fields:

```python

from types import MappingProxyType



@dataclass(frozen=True)

class StepContext:

    sample: Any

    metadata: MappingProxyType = field(default_factory=lambda: MappingProxyType({}))



    def __post_init__(self):

        # Ensures mutation is a hard runtime error even if caller passes a plain dict

        if not isinstance(self.metadata, MappingProxyType):

            object.__setattr__(self, "metadata", MappingProxyType(self.metadata))



    def replace(self, **changes) -> "StepContext":

        return dataclasses.replace(self, **changes)

```

The engine never reads anything beyond `sample` and `metadata`. All domain-specific fields are added by subclassing.

#### Subclassing for domain fields

Consuming applications subclass `StepContext` to add named fields for concepts shared across their pipelines:

```python

@dataclass(frozen=True)

class ACEContext(StepContext):

    # Shared across all ACE pipelines

    skillbook: Skillbook | None = None

    environment: TaskEnvironment | None = None



    # Produced by steps (None until the providing step runs)

    agent_output: AgentOutput | None = None

    environment_result: EnvironmentResult | None = None

    reflections: tuple[ReflectorOutput, ...] = ()

    skill_manager_output: UpdateBatch | None = None



    # Runner bookkeeping

    epoch: int = 1

    total_epochs: int = 1

    step_index: int = 0

    total_steps: int = 0

```

The `requires`/`provides` validation works on attribute names (strings) β€” it checks that the field exists on the context object at runtime, so it is subclass-agnostic. A step that declares `requires = {"skillbook"}` works whether the context is `ACEContext` or any other subclass that has a `skillbook` attribute.

Data that is specific to a single integration or step goes in `metadata` to prevent field accumulation on the subclass. For example, `metadata["browser_history"]` for browser-use or `metadata["transcript_path"]` for Claude Code.

#### Immutable update patterns

Updating metadata follows the same immutable pattern as any other field:

```python

return ctx.replace(metadata=MappingProxyType({**ctx.metadata, "key": value}))

```

Steps follow this pattern:

```python

def __call__(self, ctx: StepContext) -> StepContext:

    result = do_work(ctx.sample)

    return ctx.replace(result=result)

```

`frozen=True` makes mutation a hard error at runtime rather than a subtle bug. It also makes `Branch` safe by default β€” since `StepContext` is immutable, all branches can receive the same object without risk; no deep copy is needed.

---

## Pipeline

A `Pipeline` is an ordered list of steps that runs sequentially for a single input. It also satisfies the `Step` protocol, so it can be embedded inside another pipeline.

```python

pipe = Pipeline([

    AgentStep(),

    EvaluateStep(),

    ReflectStep(),

    UpdateStep(),

])

```

**Fluent builder API (preferred):**

```python

pipe = (

    Pipeline()

    .then(AgentStep())

    .then(EvaluateStep())

    .then(ReflectStep())

    .then(UpdateStep())

)

```

**Fan-out across contexts:**

```python

pipe.run(contexts, workers=4)   # same pipeline, N contexts in parallel

```

### Inner pipeline as a fan-out step

A `Pipeline`-as-`Step` receives one context and must return one context β€” but nothing prevents it from internally expanding to multiple sub-inputs. This is the **map-reduce step** pattern:

```python

class MultiSearchStep:

    """Generates N queries from one context, runs them in parallel, merges."""

    def __call__(self, ctx: StepContext) -> StepContext:

        queries = generate_queries(ctx.sample)                          # 1 β†’ N

        sub_ctxs = [StepContext(sample=q) for q in queries]

        sub_pipe = Pipeline().then(FetchStep())

        results = sub_pipe.run(sub_ctxs, workers=len(queries))         # parallel

        return ctx.replace(agent_output=merge(results))                 # N β†’ 1

```

`sub_pipe.run()` is a top-level runner call, so `async_boundary` and `workers` on its inner steps fire normally. From the outer pipeline's perspective, `MultiSearchStep` is a black box that takes one context and returns one context β€” the fan-out is an internal implementation detail.

### requires/provides for nested pipelines

When a `Pipeline` is used as a `Step` inside another pipeline, its `requires` and `provides` are computed automatically at construction time from its inner steps β€” no manual annotation needed.

```python

class Pipeline:

    def __init__(self, steps):

        self.steps = steps

        self.requires, self.provides = self._infer_contracts(steps)



    @staticmethod

    def _infer_contracts(steps):

        provided_so_far = set()

        external_requires = set()

        for step in steps:

            external_requires |= step.requires - provided_so_far

            provided_so_far |= step.provides

        return frozenset(external_requires), frozenset(provided_so_far)

```

- `requires` = everything the pipeline needs from the outside (what its first steps need that no earlier inner step provides)
- `provides` = union of everything any inner step writes

The outer pipeline validates against these aggregated values at construction time, so nesting never breaks the contract.

**Deliberate constraint:** `_infer_contracts` assumes all `Branch` children always run. It has no concept of conditional branches where only some children execute. If one branch provided a field that a later step required but other branches did not, static validation would pass while the pipeline could fail at runtime. Conditional branching β€” where a branch may or may not run depending on context β€” is out of scope; all branches in a `Branch` are always executed.

---

## Branch

A `Branch` is a step that runs multiple pipelines in parallel and joins before returning. It is just a `Step` β€” no special pipeline mode needed.

```python

pipe = (

    Pipeline()

    .then(AgentStep())

    .then(EvaluateStep())

    .branch(

        Pipeline().then(ReflectStep()),

        Pipeline().then(LogStep()),

    )

    .then(UpdateStep())   # only runs after both branches complete

)

```

`wait` is implicit β€” any step after a `Branch` waits for all branches to finish.

### Context merging

Each branch receives the same context reference. Since `StepContext` is frozen, no copy is needed β€” branches cannot mutate what they receive. When all branches complete, their output contexts are merged back into one before the next step runs.

The merge function receives the list of output contexts and returns a single context:

```python

Branch(

    Pipeline().then(ReflectStep()),

    Pipeline().then(LogStep()),

    merge=lambda ctxs: dataclasses.replace(

        ctxs[0],

        metadata={**ctxs[0].metadata, **ctxs[1].metadata}

    )

)

```

**Built-in merge strategies:**

| Strategy | Behaviour |
|---|---|
| `raise_on_conflict` | raises if two branches write the same field β€” safe default, no silent data loss |
| `last_write_wins` | last branch's value wins on conflict β€” simple but lossy |
| `namespaced` | branches write to `ctx.metadata["branch_0"]` etc., no conflict possible |
| custom `merge=fn` | `fn(ctxs: list[StepContext]) -> StepContext` β€” full control |

The actual default when no `merge=` argument is passed is `raise_on_conflict`. The constructor signature makes this explicit:

```python

def __init__(self, *pipelines, merge=MergeStrategy.RAISE_ON_CONFLICT):

    ...

```

In practice, branches that write disjoint fields (e.g. Reflect writes `reflection`, Log writes `metadata["log"]`) never conflict and the merge is a no-op β€” `raise_on_conflict` passes through without raising.

---

## Async Behavior

"Async" means three different things in this framework, operating at different levels. It is important to keep them separate β€” they solve different problems.

| Type | Level | Problem it solves |
|---|---|---|
| Async step | single step | don't block the thread during I/O |
| `async_boundary` | across samples | start the next sample before the current one finishes |
| Branch parallelism | within one sample | run independent work simultaneously on the same data |

---

### 1. Async steps β€” non-blocking I/O

**Problem:** A step makes a network call (LLM API, HTTP, subprocess). It should not block the thread while waiting for a response.

**Solution:** Define the step as a coroutine. The pipeline detects this automatically and awaits it. Sync steps get wrapped with `asyncio.to_thread()` so they are safe in an async context too.

```python

# Sync step β€” no changes needed

class AgentStep:

    def __call__(self, ctx: StepContext) -> StepContext: ...



# Async step β€” native coroutine, awaited by the pipeline

class BrowserExecuteStep:

    async def __call__(self, ctx: StepContext) -> StepContext: ...

```

```python

# Pipeline runner β€” handles both transparently

for step in self.steps:

    if asyncio.iscoroutinefunction(step.__call__):

        ctx = await step(ctx)

    else:

        ctx = await asyncio.to_thread(step, ctx)

```

Pipeline entry points: `pipe.run(contexts)` for sync callers, `await pipe.run_async(contexts)` for async callers (e.g. inside browser-use).

This type is about **not blocking**. Nothing runs in parallel β€” the pipeline is still sequential, it just yields the thread during waits.

---

### 2. async_boundary β€” pipeline across samples



**Problem:** Reflect and Update are slow (LLM calls). If we wait for them before starting the next sample, throughput is poor. We want to fire them off and immediately move to sample N+1.



**Solution:** A step declares `async_boundary = True`. Everything from that step onwards runs in a background executor. The pipeline loop does not wait β€” it moves straight to the next sample.

```python

class ReflectStep:

    async_boundary = True   # hand off to background from here

    max_workers = 3         # up to 3 reflections running in parallel



class UpdateStep:

    max_workers = 1         # must serialize β€” writes to shared skillbook

```

```

sample 1:  [Agent] [Evaluate] ──fire──► [Reflect] [Update]  (background)

sample 2:  [Agent] [Evaluate] ──fire──► [Reflect] [Update]  (background)

sample 3:  [Agent] [Evaluate] ...

                              ↑

                        async_boundary

```

This type is about **throughput**. Multiple samples are in-flight simultaneously, at different stages of the pipeline. The caller only waits for steps before the boundary.

Note: `max_workers` controls how many background instances of a step run concurrently. Steps that write shared state (like `UpdateStep`) must use `max_workers = 1` to avoid races.

**Background pool is per step class, shared across pipeline instances.** `ReflectStep.max_workers = 3` means a single pool of 3 threads for all `ReflectStep` instances. This avoids pool proliferation and makes `max_workers` a straightforward capacity knob independent of how many pipelines are running.

**Pool lifecycle:** The `ThreadPoolExecutor` for each step class is created lazily at first use (not at class definition or pipeline construction) and persists for the process lifetime. Callers that need explicit cleanup can call `StepClass._executor.shutdown(wait=True)`. If two users of the same step class need different concurrency limits (e.g. different LLM backends behind the same step type), they should subclass rather than share the class attribute.

**Boundary rules:**
- The **first** step with `async_boundary = True` is the handoff point. Only one boundary per pipeline.
- If multiple steps in the same pipeline declare `async_boundary = True`, the pipeline raises `PipelineConfigError` at construction time. A duplicate boundary is almost always a copy-paste mistake, not a deliberate choice.
- `async_boundary` inside a `Branch` child pipeline raises `PipelineConfigError` at construction time. Branch children always block until joined; detaching mid-branch is incoherent and there is no valid interpretation.
- `async_boundary` inside a `Pipeline`-as-`Step` raises a **warning** at construction time (not an error). When a pipeline is used as a step inside another pipeline, there is no "next sample" to move to β€” the outer pipeline is blocked waiting for the inner one to return a context. The boundary is ignored and the inner pipeline runs fully synchronously. The warning surfaces this declared intent being ignored so callers can investigate. The same pipeline definition works both as a top-level runner (where `async_boundary` fires) and as a nested step (where it warns and is ignored) β€” no reconfiguration needed.

---

### 3. Branch parallelism β€” concurrent work on the same sample

**Problem:** Two independent steps could run at the same time on the same sample (e.g. reflect and log), but a linear pipeline forces them to be sequential.

**Solution:** `Branch` forks the context, runs each sub-pipeline in parallel, then joins before the next step. In sync mode it uses `ThreadPoolExecutor`; in async mode it uses `asyncio.gather()`.

```python

pipe = (

    Pipeline()

    .then(EvaluateStep())

    .branch(

        Pipeline().then(ReflectStep()),   # runs in parallel

        Pipeline().then(LogStep()),       # runs in parallel

    )

    .then(UpdateStep())   # waits for both branches

)

```

```python

# Branch internals (async mode)

async def __call__(self, ctx: StepContext) -> StepContext:

    results = await asyncio.gather(

        *[p(ctx) for p in self.pipelines],

        return_exceptions=True,   # all branches run to completion even if one fails

    )

    failures = [r for r in results if isinstance(r, BaseException)]

    if failures:

        raise BranchError(failures)   # caller sees all branch failures, not just the first

    return self.merge(results)

```

`return_exceptions=True` is required for consistent error handling: without it, the first branch failure cancels all remaining branches and the `SampleResult` would silently drop their work. With it, all branches complete and the runner captures the full failure set.

This type is about **latency within a single sample**. Nothing moves to the next sample β€” the pipeline waits for the join before continuing.

---

### Rule of thumb

| Question | Answer |
|---|---|
| Does the step wait on I/O? | `async def __call__` |
| Do I want to process more samples while previous ones are still learning? | `async_boundary` on the step where the handoff happens |
| Can two steps on the same sample run simultaneously? | `Branch` |
| Do I want N samples going through the pipeline at the same time? | `workers=N` on `run()` |

Each mechanism is independent. They compose freely β€” you can have async steps inside branches, behind an `async_boundary`, run with multiple workers.

---

## Concurrency Model

Parallelism is declared on the **step**, not the pipeline. The pipeline executor reads these at runtime:

```python

class ReflectStep:

    async_boundary = True   # hand off to background threads from here

    max_workers = 3         # up to 3 running in parallel



class UpdateStep:

    max_workers = 1         # must serialize (writes to shared skillbook)

```

**Fan-out (same step, different samples):**
Controlled by `max_workers` on the step. Each step class has a single shared `ThreadPoolExecutor` β€” `ReflectStep.max_workers = 3` means one pool of 3 threads regardless of how many pipeline instances are running.

**Pipeline split (pipelining across samples):**
`async_boundary = True` on a step tells the runner to hand off everything from that step onwards to background threads, freeing the caller to start the next sample immediately.

```

sample 1:  [AgentStep] [EvaluateStep] ──► [ReflectStep] [UpdateStep]

sample 2:  [AgentStep] [EvaluateStep] ──► ...             (background)

                                      ↑

                               async_boundary

```

This replaces the hardcoded `steps[:2]` / `steps[2:]` split that existed in the old `AsyncLearningPipeline`.

### workers vs max_workers β€” independent pools



These two knobs control different thread pools and do not interact:



| Knob | Pool | Controls |

|---|---|---|

| `pipe.run(contexts, workers=N)` | foreground pool | how many contexts run through pre-boundary steps simultaneously |

| `step.max_workers = K` | background pool per step class | how many instances of that step run in the background simultaneously |

A sample leaves the foreground pool when it crosses the `async_boundary` point and enters the background step's pool. With `workers=4` and `ReflectStep.max_workers=3`, you can have 4 samples in Agent/Evaluate and 3 reflections running concurrently β€” two separate pools, no multiplication.

Mental model: `workers` controls throughput *into* the pipeline; `max_workers` controls throughput *through* each slow background step.

**LLM rate limits:** `workers` and `max_workers` are independent pools, but total concurrent outbound LLM calls = foreground calls + background calls. With `workers=4` and `ReflectStep.max_workers=3`, up to 7 LLM requests may be in-flight simultaneously. Account for this when configuring per-provider rate limits.

---

## Error Handling

Failure semantics differ depending on which side of the `async_boundary` a step is on.

**Foreground steps** (before the boundary): the runner catches exceptions per sample and records them in a `SampleResult`. The pipeline then moves to the next sample.

```python

# Pipeline runner (foreground loop)

for ctx in contexts:

    try:

        for step in self.foreground_steps:

            ctx = step(ctx)

        self._submit_to_background(ctx)

        results.append(SampleResult(sample=ctx.sample, output=ctx, error=None, failed_at=None))

    except Exception as e:

        results.append(SampleResult(sample=ctx.sample, output=None, error=e, failed_at=type(step).__name__))

```

**Background steps** (after the boundary): the caller has already moved on, so exceptions cannot propagate. Background failures are captured and attached to the `SampleResult` β€” nothing is dropped silently.

```python

@dataclass

class SampleResult:

    sample: Any

    output: StepContext | None     # None if a step failed

    error: Exception | None        # set if any step failed

    failed_at: str | None          # name of the step class that failed

    cause: Exception | None = None # for BranchError: the inner step exception

```

Every sample produces a result β€” either successful with `output` set, or failed with `error` and `failed_at` set. After `run()` completes (or after `wait_for_learning()`), callers can inspect results for failures.

When a `Branch` step fails, `failed_at` is `"Branch"` and `error` is a `BranchError`. `cause` carries the inner exception from the failing branch so callers can see which inner step actually failed, not just the outer wrapper.

Retry logic is the responsibility of individual steps, not the pipeline.

**Shutdown:** `wait_for_background(timeout=N)` raises `TimeoutError` if background steps have not drained within `N` seconds. Individual step implementations are responsible for their own per-call timeouts (e.g. LLM API call timeouts).

**Monitoring:** `background_stats()` returns a `dict` with `active` and `completed` counts for background threads. Thread-safe β€” can be called from any thread while the pipeline is running. This is the public API for monitoring background progress; callers should not access `_bg_lock` or `_bg_threads` directly.

**Foreground progress:** `run()` and `run_async()` accept an optional `on_sample_done` callback (`Callable[[SampleResult], None] | None`). It fires once per context after foreground steps complete (or fail), before background steps start. The callback must not block the event loop β€” lightweight operations like `tqdm.update()` are fine. Defaults to `None` (no-op). This is the foreground-side complement to `background_stats()`.

---

## Pipeline Hooks

Hooks let external code observe pipeline execution without modifying data flow. They solve a different problem than steps: steps transform data (`StepContext` in, `StepContext` out), hooks observe transitions (step started, step finished).

The motivating use case is hosted/web deployments that need operational concerns β€” progress streaming, metrics, logging, billing β€” wired into the pipeline without modifying the step chain or the pipeline engine for each new concern.

### Separation of concerns

The pipeline has three distinct concerns, each with its own mechanism:

| Concern | Mechanism | Who owns it |
|---|---|---|
| Data flow | Steps (`requires`/`provides`, `__call__`) | Step author |
| Observation | Hooks (`before_step`/`after_step`) | Deployment environment |
| Lifecycle control | `cancel_token` (see Cancellation below) | Caller |

Steps own data. Hooks observe execution. Cancellation controls lifecycle. These three never overlap β€” a hook cannot modify context, and cancellation is not a hook.

### Hook protocol

```python

@runtime_checkable

class PipelineHook(Protocol):

    def before_step(self, step_name: str, ctx: StepContext) -> None: ...

    def after_step(self, step_name: str, ctx: StepContext) -> None: ...

```

**Design constraints:**

- **`-> None`, not `-> StepContext`** β€” hooks observe, they do not transform. Context flow stays exclusively in the step chain via `requires`/`provides`. This eliminates the "second communication channel" problem β€” hooks cannot inject data that a later step silently depends on.
- **`step_name: str`**, not the step object β€” hooks know what ran, but cannot call, inspect, or mutate the step instance. This prevents hooks from becoming an implicit dependency of step behavior.

- **Non-blocking** β€” hooks must not block the event loop. They are in the hot path between steps. Heavy work (HTTP POST, disk write) should be dispatched to a background task or queue, not done inline. Same constraint as `on_sample_done`.

- **No ordering guarantees between hooks** β€” hooks in the list are called sequentially in insertion order, but a hook must not depend on side effects of another hook. If ordering matters, combine them into one hook.

- **Exception isolation** β€” if a hook raises, the pipeline logs the error and continues. A broken metrics hook must not kill the pipeline. Hook exceptions are never surfaced in `SampleResult`.



### Pipeline integration



Hooks are set at construction time β€” they are structural, like steps. A pipeline's observation behavior is fixed for its lifetime.



```python

class Pipeline:

    def __init__(self, steps=None, hooks=None):

        self._hooks = list(hooks or [])

        ...

```



The step execution loop calls hooks around each foreground step:



```python

for step in foreground_steps:

    step_name = type(step).__name__

    for hook in self._hooks:

        hook.before_step(step_name, ctx)

    ctx = await step(ctx)

    for hook in self._hooks:

        hook.after_step(step_name, ctx)

```



Hooks fire for **foreground steps only**. Background steps (after `async_boundary`) do not trigger hooks β€” the caller has already moved on, and hook callbacks from background threads would violate the non-blocking contract. Background observability is handled via `background_stats()`.



### Branch and nesting behavior



- **Branch:** hooks fire once for the `Branch` step as a whole (`step_name = "Branch"`), not for each inner step of each child pipeline. Branch children are an internal implementation detail β€” hooks observe the outer pipeline's step sequence only. This keeps hook output predictable regardless of how many branches exist or how deep they nest.
- **Nested Pipeline-as-Step:** same rule. The outer pipeline fires hooks for the nested pipeline step (`step_name = "MySubPipeline"`), not for its inner steps. If the nested pipeline has its own hooks, those fire independently within its own execution.

### Example: progress streaming for a web app

```python

class ProgressHook:

    """Pushes step events to an async queue for SSE streaming."""



    def __init__(self, queue: asyncio.Queue):

        self._queue = queue



    def before_step(self, step_name: str, ctx: StepContext) -> None:

        self._queue.put_nowait({"type": "step_started", "step": step_name})



    def after_step(self, step_name: str, ctx: StepContext) -> None:

        self._queue.put_nowait({"type": "step_done", "step": step_name})

```

```python

# Web endpoint wiring (not part of pipeline/)

queue = asyncio.Queue()

pipe = Pipeline(steps, hooks=[ProgressHook(queue)])

asyncio.create_task(pipe.run_async(contexts))

# SSE endpoint reads from queue

```

The hook implementation lives in the hosted deployment code, not in `pipeline/`. The pipeline engine provides the protocol and the call sites β€” nothing more.

---

## Cancellation

`cancel_token` lets a caller stop a running pipeline between steps. The motivating use case is a web app where the user clicks "Stop" and the server needs to halt processing without waiting for the remaining steps or samples to complete.

### CancellationToken

```python

class CancellationToken:

    """Thread-safe cancellation signal."""



    def __init__(self) -> None:

        self._cancelled = threading.Event()



    def cancel(self) -> None:

        """Signal cancellation. Thread-safe, idempotent."""

        self._cancelled.set()



    @property

    def is_cancelled(self) -> bool:

        return self._cancelled.is_set()

```

`threading.Event` rather than `asyncio.Event` because the token must be cancellable from any thread β€” a web endpoint handler, a background task, a signal handler. The pipeline checks it synchronously between steps, so no async machinery is needed.

### Pipeline integration

`cancel_token` is passed per-invocation on `run()` and `run_async()`, not on `__init__`. A token is scoped to a single execution β€” each web request creates a fresh token. The pipeline object stays reusable across runs.

```python

pipe.run(contexts, cancel_token=token)

await pipe.run_async(contexts, cancel_token=token)

```

The runner checks the token at two points:

1. **Before each foreground step** β€” if cancelled, the current sample gets `error=PipelineCancelled()` and `failed_at` set to the step that would have run next.
2. **Before each new sample** β€” if cancelled, remaining samples are not started. Samples already in-flight (via `workers > 1`) complete their current step but are cancelled before the next one.

```python

for step in foreground_steps:

    if cancel_token is not None and cancel_token.is_cancelled:

        result.error = PipelineCancelled()

        result.failed_at = type(step).__name__

        return result

    ctx = await step(ctx)

```

### Contextvar bridge β€” making the token visible inside steps

The pipeline checks the token between steps.  Code *inside* a step (e.g. an LLM client making a streaming API call) may also want to check it β€” but steps, roles, and LLM clients do not receive the token as a parameter.

The pipeline bridges this gap with a `contextvars.ContextVar`.  Before running foreground steps, `run_async()` sets the current cancel token in the contextvar:

```python

from contextvars import ContextVar



cancel_token_var: ContextVar[CancellationToken | None] = ContextVar(

    "cancel_token_var", default=None

)

```

```python

# Inside Pipeline.run_async()

_reset = cancel_token_var.set(cancel_token)

try:

    # ... process samples, run steps

finally:

    cancel_token_var.reset(_reset)

```

Any code in the call stack β€” a step, a role, an LLM client β€” can read the token without any signature changes:

```python

# Inside LLM client code or any code inside a step β€” no parameter changes

token = cancel_token_var.get(None)

if token is not None and token.is_cancelled:

    raise PipelineCancelled("Cancelled during LLM call")

```

`asyncio.to_thread()` (used by the pipeline for sync steps) automatically copies context variables to the worker thread, so the token is visible in sync steps too.

**Why a contextvar and not a parameter:**  The call chain from pipeline to LLM client crosses four layers (pipeline β†’ step β†’ role β†’ client).  Threading a parameter through every layer would require changing every method signature in between β€” steps and roles that have no business knowing about cancellation.  A contextvar is the standard Python mechanism for request-scoped data that crosses layers without explicit plumbing.

### What cancellation does NOT do

- **It does not interrupt a running step by default.** Cancellation is checked *between* steps by the pipeline.  Code inside a step can opt in to intra-step cancellation by reading `cancel_token_var` (see above) β€” but this is a step/client-level concern, not a pipeline-level one.
- **It does not cancel background steps.** Background work (after `async_boundary`) runs in separate threads and is not interrupted. `wait_for_background()` still works normally. If you need to cancel background work, shut down the step-class executors directly.
- **It does not affect hooks.** Hooks still fire for the step that was executing when cancellation was detected β€” `after_step` is called, then the cancellation check runs before the *next* step.

### PipelineCancelled

```python

class PipelineCancelled(Exception):

    """Raised (internally) when a cancel_token is triggered between steps.



    Surfaces in ``SampleResult.error`` β€” never propagated to the caller

    of ``run()`` / ``run_async()``.  Callers check for this type to

    distinguish cancellation from step failures.

    """

```

`PipelineCancelled` follows the same error-handling pattern as step exceptions: it is caught per-sample and recorded in `SampleResult`, not propagated. The runner continues to the next sample (which will also be cancelled if the token is still set). This means `run()` always returns a complete list of `SampleResult` β€” some successful, some failed, some cancelled.

### Example: web app cancel endpoint

```python

# Start a run

token = CancellationToken()

active_runs[run_id] = token

task = asyncio.create_task(pipe.run_async(contexts, cancel_token=token))



# Cancel endpoint

@app.post("/runs/{run_id}/cancel")

async def cancel_run(run_id: str):

    active_runs[run_id].cancel()

    return {"status": "cancelling"}

```

---

## Summary Table

| Concept | Unit | Threading | Communication |
|---|---|---|---|
| `Step` | single unit of work | always sync | via `StepContext` |
| `Pipeline` | ordered step list for one input | `workers=N` across inputs | via `StepContext` |
| `Branch` | parallel pipeline list | always parallel internally | copy + merge of `StepContext` |
| `Pipeline` as a `Step` | reuse / nesting | inherits parent context | via `StepContext` |
| `PipelineHook` | observation point | runs in caller thread | `-> None` (read-only) |
| `CancellationToken` | lifecycle signal | thread-safe (`threading.Event`) | checked between steps |

---

## What Was Rejected and Why

**`PipelineProcess` (external wrapper):**
Adding a separate class to wrap pipelines with executor/queue machinery was considered. Rejected β€” it adds an indirection layer without benefit for this project's use case. Concurrency is declared on steps instead.

**Special async pipeline subclass:**
Having an `AsyncPipeline` type was considered. Rejected β€” it mixes sequential logic with concurrency concerns in the same class. The `async_boundary` marker on steps is data-driven and doesn't require subclassing.

**Full DAG executor (auto-inferred parallelism):**
The `requires`/`provides` graph already contains enough information to infer which steps can run in parallel. Deferred β€” `Branch` covers the explicit fork/join case; automatic DAG inference can be added later if needed.

**Alternative `requires`/`provides` declaration styles:**
Four alternatives to plain set class attributes were considered:

- `__init_subclass__` keyword args (`class MyStep(Step, requires={"agent_output"})`): moves the declaration to the class header but requires inheriting from a base `Step` class, eliminating the structural Protocol advantage β€” any object with the right attributes is a step without needing to inherit anything.
- `ClassVar` annotations (`requires: ClassVar[frozenset[str]] = ...`): more type-checker friendly but adds verbosity with no semantic change.
- Function decorator wrapping `__call__`: removes class boilerplate for stateless steps but introduces two styles (decorated functions vs classes with collaborators like `self.reflector`), inconsistency not worth the reduction.
- Decomposed signature / Hamilton-style (steps receive named fields as parameters instead of `StepContext`): elegant zero-annotation contracts β€” `requires` and `provides` are inferred from function signature at zero cost. Rejected because it loses explicit ordering control (order is inferred from data dependencies, not declared; independent steps have undefined order), collapses the two-tier `StepContext`/`metadata` structure into a flat dict (integration-specific data collides with shared fields), and makes side-effect steps with no consumed output impossible to anchor in the sequence.

Plain set class attributes with pipeline normalization to `frozenset` at construction time is the right balance: explicit, readable, no inheritance required, and the ordering and context model stay intact.

**Alternative hook/cancellation designs:**
Three alternatives to the observation-only `PipelineHook` + separate `cancel_token` design were considered:

- Context-modifying hooks (`before_step` returns `StepContext`): hooks could transform context between steps β€” powerful but creates a second data-flow channel invisible to `requires`/`provides` validation. A hook could inject a field that a later step silently depends on, and the pipeline validator would not catch the dependency. Rejected to preserve the invariant that all data flow goes through the step chain.
- Cancellation as a hook (`CancellationHook` that raises in `before_step`): keeps everything in one mechanism, but mixes observation and control. If hooks are supposed to be safe to fail (exception isolation), a cancellation hook that *must* propagate its exception breaks that contract. Rejected β€” cancellation is a lifecycle concern, not an observation concern, so it gets its own parameter.
- Cancellation via `metadata` on `StepContext`: put a `CancellationToken` in `metadata` and have each step check it. Follows "behavior on the step" but couples every step to a cancellation concept, and steps that forget to check it silently ignore cancellation. Rejected β€” cancellation should be guaranteed by the pipeline, not opt-in per step.
- Additional `run_async` callback parameters (no hook protocol): add `on_step_done` and `cancel_token` as parameters on `run()`/`run_async()`, following the `on_sample_done` precedent. Minimal and consistent, but each new operational concern (metrics, billing, auth context) requires adding another parameter to the pipeline's public API, which accumulates over time. The hook protocol pays a small upfront design cost to avoid this parameter growth.

---

## External Libraries Considered

This pattern is known as **Pipes and Filters**. Several open source libraries implement variants of it. None were adopted β€” reasons below.

**[Kedro](https://kedro.org/)** β€” closest to the `requires`/`provides` model. Nodes declare explicit named inputs and outputs; pipelines are composable. The gap: requires a "data catalog" abstraction for named datasets, has no `async_boundary` concept, and is oriented toward ML/ETL rather than agentic loops. Fighting the data catalog to pass a `StepContext` would cost more than writing the primitives cleanly.

**[Hamilton](https://github.com/dagworks-inc/hamilton)** β€” lightest-weight equivalent. Functions declare inputs as parameters and outputs as return types; the framework infers the DAG. No server, no UI. The gap: no built-in async boundary, no fork/join `Branch`, no per-step `max_workers`. Gets contract validation for free but requires building all concurrency from scratch anyway.

**[Pypeln](https://github.com/cgarciae/pypeln)** β€” designed for exactly the "process N samples through concurrent stages" problem. Has sync, thread, and async modes. The gap: no typed contracts, no `Branch`, no nested pipelines. Gets the `async_boundary`-style throughput but not the structural guarantees.

**[Dagster](https://dagster.io/)** β€” closest overall feature set. Ops (β‰ˆ Steps) with typed inputs/outputs, jobs (β‰ˆ Pipelines), graph-based branching. The gap: it is a platform, not a library. Brings a scheduler, UI, asset catalog, and significant operational overhead. Too heavy to embed inside ACE.

**Conclusion:** The specific combination of `async_boundary`, per-step `max_workers`, `Pipeline`-as-`Step` nesting, and `SampleResult` error wrapping is not provided by any of the above out of the box. Adapting any of them would cost as much as writing the ~300-line core cleanly.

**What is borrowed rather than written:** `concurrent.futures.ThreadPoolExecutor` for the background step pools, and `asyncio.gather` (or `anyio` task groups) for `Branch` internals.