# Phase 2: 100x Features Implementation Plan

> **Status: COMPLETE** (2026-02-27 audit) — All 11 features implemented: BuilderBase mixin, operators, repr, validate, serialization, with\_, presets, structured output, map, retry/fallback, debug, decorator. Key files: `_base.py`, `presets.py`, `decorators.py`, `_helpers.py`.

**Goal:** Add 11 features (BuilderBase mixin, operators, repr, validate, serialization, with\_, presets, structured output, map, retry/fallback, debug, decorator) that elevate adk-fluent from "nice builder" to "expression language."

**Architecture:** Hand-written `BuilderBase` mixin that all generated builders inherit from. Generator emits `class X(BuilderBase):`. Runtime features extend `_helpers.py`. Decorator syntax in `decorators.py`. Presets in `presets.py`.

**Tech Stack:** Python 3.11+, existing codegen pipeline, Pydantic (structured output), PyYAML (optional serialization).

______________________________________________________________________

## Group 0: Pipeline Safety

### Task 0: Protect manual seed.toml and __init__.py from regeneration

**Problem:** Running `just seed` overwrites all manually-added extras (guardrail, clone, ask, stream, etc.) in `seeds/seed.toml`. Running `just generate` overwrites manual exports (Preset, agent) in `__init__.py`. Both destroy Phase 1 and Phase 2 work.

**Fix:** Split seed.toml into auto-generated + manual parts. Protect `__init__.py` with a manual append section.

**Files:**

- Rename: `seeds/seed.toml` → `seeds/seed.toml` (keep as merged output)
- Create: `seeds/seed.manual.toml` (hand-curated extras, aliases, renames)
- Modify: `scripts/seed_generator.py` (add `--merge` flag)
- Modify: `scripts/generator.py` (preserve manual `__init__.py` exports)
- Modify: `justfile` (update `seed` target to use merge)

**Step 1: Create `seeds/seed.manual.toml`**

Extract all hand-written extras from the current `seeds/seed.toml` into a separate file. This includes all `[[builders.Agent.extras]]` entries for: guardrail, clone, ask, ask_async, stream, test, session (and later: map, map_async, output, retry, fallback, debug). Also includes clone extras on Pipeline, FanOut, Loop.

Format:

```toml
# Hand-curated overrides merged INTO auto-generated seed.
# This file is never overwritten by `just seed`.

# Builder name renames (auto-seed uses ADK class names, we want ergonomic names)
[renames]
LlmAgent = "Agent"
SequentialAgent = "Pipeline"
ParallelAgent = "FanOut"
LoopAgent = "Loop"

# Manual extras for Agent
[[builders.Agent.extras]]
name = "guardrail"
# ... (all existing Phase 1 extras)

# Manual extras for Pipeline, FanOut, Loop
[[builders.Pipeline.extras]]
name = "clone"
# ...
```

**Step 2: Modify `scripts/seed_generator.py` — add merge support**

Add a `--merge` flag and a `merge_manual_overrides(auto_seed: dict, manual_path: str) -> dict` function that:

1. Loads the manual TOML
1. Applies renames from `[renames]` section
1. Appends manual extras to the corresponding builder's extras list
1. Preserves any manual aliases not generated by the alias engine

**Step 3: Modify `scripts/generator.py` — protect `__init__.py` manual exports**

In `generate_all`, after writing the auto-generated `__init__.py`, append a manual section:

```python
# After writing auto-generated imports, append manual exports
manual_exports = [
    ("._base", "BuilderBase"),
    (".presets", "Preset"),
    (".decorators", "agent"),
]
# Add these to init_lines and __all__
```

Alternatively, read a `[manual_exports]` section from seed.toml.

**Step 4: Update justfile `seed` target**

Change from:

```
@uv run python {{SEED_GEN}} {{MANIFEST}} -o {{SEED}}
```

To:

```
@uv run python {{SEED_GEN}} {{MANIFEST}} -o {{SEED}} --merge seeds/seed.manual.toml
```

**Step 5: Run full pipeline and verify**

Run: `just all && uv run pytest tests/ -v --tb=short`
Expected: All tests PASS, seed.toml retains all manual extras, `__init__.py` includes manual exports.

**Step 6: Commit**

```bash
git add seeds/seed.manual.toml scripts/seed_generator.py scripts/generator.py justfile
git commit -m "fix: protect manual seed.toml extras and __init__.py exports from regeneration"
```

______________________________________________________________________

## Group 1: Foundation

### Task 1: Create BuilderBase and modify generator

**Files:**

- Create: `src/adk_fluent/_base.py`
- Modify: `scripts/generator.py:180-202` (imports), `scripts/generator.py:563-585` (class declaration)
- Modify: `scripts/generator.py:706-715` (stub imports)
- Test: `tests/manual/test_builder_base.py`

**Step 1: Write the failing test**

Create `tests/manual/test_builder_base.py`:

```python
"""Tests for BuilderBase mixin inheritance."""

from adk_fluent.agent import Agent
from adk_fluent.workflow import Pipeline, FanOut, Loop
from adk_fluent._base import BuilderBase


class TestBuilderBaseInheritance:
    """All generated builders must inherit from BuilderBase."""

    def test_agent_is_builder_base(self):
        assert issubclass(Agent, BuilderBase)

    def test_pipeline_is_builder_base(self):
        assert issubclass(Pipeline, BuilderBase)

    def test_fanout_is_builder_base(self):
        assert issubclass(FanOut, BuilderBase)

    def test_loop_is_builder_base(self):
        assert issubclass(Loop, BuilderBase)

    def test_instance_check(self):
        agent = Agent("test")
        assert isinstance(agent, BuilderBase)

    def test_existing_functionality_preserved(self):
        """Existing builder mechanics still work after adding base class."""
        agent = Agent("test")
        result = agent.instruct("Do stuff.")
        assert result is agent
        assert agent._config["instruction"] == "Do stuff."
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_builder_base.py -v`
Expected: FAIL — `_base` module doesn't exist, Agent doesn't inherit from BuilderBase

**Step 3: Create BuilderBase skeleton**

Create `src/adk_fluent/_base.py`:

```python
"""BuilderBase mixin — shared capabilities for all generated fluent builders."""

from __future__ import annotations

from typing import Any, Self


class BuilderBase:
    """Mixin base class providing shared builder capabilities.

    All generated builders inherit from this class. It provides:
    - __repr__: Readable builder state
    - Operators: >>, |, * for composition
    - validate/explain: Pre-build validation
    - to_dict/from_dict/to_yaml: Serialization
    - with_: Immutable variants
    - use: Preset application
    - debug: Trace mode
    """

    # Subclasses define these (emitted by generator):
    _ALIASES: dict[str, str]
    _CALLBACK_ALIASES: dict[str, str]
    _ADDITIVE_FIELDS: set[str]
```

**Step 4: Modify generator to emit BuilderBase inheritance**

In `scripts/generator.py`, modify `gen_runtime_imports` (around line 180) — add the BuilderBase import at module level:

Change in `gen_runtime_imports`, after the `from collections import defaultdict` line:

```python
    lines = [
        '"""Auto-generated by adk-fluent generator. Manual edits will be overwritten."""',
        "",
        "from __future__ import annotations",
        "",
        "from collections import defaultdict",
        "from typing import Any, Callable, Self",
        "from adk_fluent._base import BuilderBase",
        "",
    ]
```

In `gen_runtime_class` (line 563-585), change the class declaration:

Replace:

```python
        f'\nclass {spec.name}:',
```

With:

```python
        f'\nclass {spec.name}(BuilderBase):',
```

In `gen_stub_class` (around line 626), change stub class declaration:

Replace:

```python
        f"class {spec.name}:",
```

With:

```python
        f"class {spec.name}(BuilderBase):",
```

In `gen_stub_module` (around line 707), add BuilderBase import to stubs:

After `"from typing import Any, AsyncGenerator, Callable, Self",`, add:

```python
        "from adk_fluent._base import BuilderBase",
```

**Step 5: Regenerate all modules**

Run: `uv run python scripts/generator.py seeds/seed.toml manifest.json --output-dir src/adk_fluent --test-dir tests/generated`

**Step 6: Run ALL tests to verify nothing broke**

Run: `uv run pytest tests/ -v --tb=short`
Expected: All 484+ tests PASS (plus new BuilderBase tests)

**Step 7: Commit**

```bash
git add src/adk_fluent/_base.py scripts/generator.py src/adk_fluent/ tests/manual/test_builder_base.py tests/generated/
git commit -m "feat: add BuilderBase mixin, all generated builders inherit from it"
```

______________________________________________________________________

## Group 2: Pure Builder Mechanics

### Task 2: `__repr__`

**Files:**

- Modify: `src/adk_fluent/_base.py`
- Test: `tests/manual/test_repr.py`

**Step 1: Write the failing test**

Create `tests/manual/test_repr.py`:

```python
"""Tests for BuilderBase.__repr__."""

from adk_fluent import Agent, Pipeline, FanOut, Loop


class TestRepr:

    def test_simple_agent_repr(self):
        agent = Agent("math")
        r = repr(agent)
        assert 'Agent("math")' in r

    def test_agent_with_config_repr(self):
        agent = Agent("math").model("gemini-2.5-flash").instruct("Solve math.")
        r = repr(agent)
        assert 'Agent("math")' in r
        assert ".instruct(" in r or ".instruction(" in r
        assert "Solve math." in r
        assert ".model(" in r
        assert "gemini-2.5-flash" in r

    def test_agent_with_callbacks_repr(self):
        def my_callback(ctx):
            pass
        agent = Agent("a").before_model(my_callback)
        r = repr(agent)
        assert "before_model" in r
        assert "my_callback" in r

    def test_agent_with_tools_repr(self):
        def calc():
            pass
        agent = Agent("a").tool(calc)
        r = repr(agent)
        assert "tools" in r

    def test_pipeline_repr(self):
        p = Pipeline("pipe")
        r = repr(p)
        assert 'Pipeline("pipe")' in r

    def test_repr_uses_aliases(self):
        """Should show .instruct() not .instruction() since that's the alias."""
        agent = Agent("a").instruct("Hello")
        r = repr(agent)
        assert ".instruct(" in r

    def test_repr_truncates_long_strings(self):
        long_text = "x" * 200
        agent = Agent("a").instruct(long_text)
        r = repr(agent)
        # Should truncate, not show all 200 chars
        assert len(r) < 300
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_repr.py -v`
Expected: FAIL — repr shows default `<adk_fluent.agent.Agent object ...>`

**Step 3: Implement `__repr__` in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    @staticmethod
    def _format_value(v: Any) -> str:
        """Format a value for repr, truncating long strings."""
        if isinstance(v, str):
            if len(v) > 80:
                return repr(v[:77] + "...")
            return repr(v)
        if callable(v):
            return getattr(v, "__name__", repr(v))
        return repr(v)

    def _reverse_alias(self, field_name: str) -> str:
        """Get the shortest alias for a field name."""
        aliases = getattr(self.__class__, "_ALIASES", {})
        reverse = {v: k for k, v in aliases.items()}
        return reverse.get(field_name, field_name)

    def __repr__(self) -> str:
        cls_name = self.__class__.__name__
        name = self._config.get("name", "?")
        lines = [f'{cls_name}("{name}")']

        for k, v in self._config.items():
            if k == "name" or k.startswith("_"):
                continue
            display_key = self._reverse_alias(k)
            lines.append(f"  .{display_key}({self._format_value(v)})")

        cb_aliases = getattr(self.__class__, "_CALLBACK_ALIASES", {})
        reverse_cb = {v: k for k, v in cb_aliases.items()}
        for field, fns in self._callbacks.items():
            alias = reverse_cb.get(field, field)
            for fn in fns:
                fn_name = getattr(fn, "__name__", repr(fn))
                lines.append(f"  .{alias}({fn_name})")

        for field, items in self._lists.items():
            for item in items:
                label = getattr(item, "name", repr(item))
                lines.append(f"  .{field}({label})")

        return "\n".join(lines)
```

**Step 4: Run tests**

Run: `uv run pytest tests/manual/test_repr.py tests/ -v --tb=short`
Expected: All PASS

**Step 5: Commit**

```bash
git add src/adk_fluent/_base.py tests/manual/test_repr.py
git commit -m "feat: add __repr__ to BuilderBase for readable builder state"
```

______________________________________________________________________

### Task 3: Operator Composition (`>>`, `|`, `*`)

**Files:**

- Modify: `src/adk_fluent/_base.py`
- Test: `tests/manual/test_operators.py`

**Step 1: Write the failing test**

Create `tests/manual/test_operators.py`:

```python
"""Tests for operator composition: >>, |, *."""

from adk_fluent import Agent, Pipeline, FanOut, Loop


class TestRshiftOperator:
    """>> creates Pipeline."""

    def test_two_agents_create_pipeline(self):
        a = Agent("a").model("gemini-2.5-flash")
        b = Agent("b").model("gemini-2.5-flash")
        result = a >> b
        assert isinstance(result, Pipeline)

    def test_pipeline_has_both_steps(self):
        a = Agent("a").model("gemini-2.5-flash")
        b = Agent("b").model("gemini-2.5-flash")
        result = a >> b
        built = result.build()
        assert len(built.sub_agents) == 2

    def test_pipeline_name_derived_from_children(self):
        a = Agent("researcher").model("gemini-2.5-flash")
        b = Agent("writer").model("gemini-2.5-flash")
        result = a >> b
        assert result._config["name"] == "researcher_then_writer"

    def test_chaining_three_agents(self):
        a = Agent("a").model("gemini-2.5-flash")
        b = Agent("b").model("gemini-2.5-flash")
        c = Agent("c").model("gemini-2.5-flash")
        result = a >> b >> c
        assert isinstance(result, Pipeline)
        built = result.build()
        assert len(built.sub_agents) == 3

    def test_rshift_with_pipeline_appends(self):
        """If left side is already a Pipeline, append to it."""
        a = Agent("a").model("gemini-2.5-flash")
        b = Agent("b").model("gemini-2.5-flash")
        c = Agent("c").model("gemini-2.5-flash")
        pipe = a >> b  # Pipeline with 2
        result = pipe >> c  # Should append, not nest
        assert isinstance(result, Pipeline)
        built = result.build()
        assert len(built.sub_agents) == 3


class TestOrOperator:
    """| creates FanOut."""

    def test_two_agents_create_fanout(self):
        a = Agent("web").model("gemini-2.5-flash")
        b = Agent("db").model("gemini-2.5-flash")
        result = a | b
        assert isinstance(result, FanOut)

    def test_fanout_has_both_branches(self):
        a = Agent("web").model("gemini-2.5-flash")
        b = Agent("db").model("gemini-2.5-flash")
        result = a | b
        built = result.build()
        assert len(built.sub_agents) == 2

    def test_fanout_name_derived(self):
        a = Agent("web").model("gemini-2.5-flash")
        b = Agent("db").model("gemini-2.5-flash")
        result = a | b
        assert result._config["name"] == "web_and_db"

    def test_chaining_three_branches(self):
        a = Agent("a").model("gemini-2.5-flash")
        b = Agent("b").model("gemini-2.5-flash")
        c = Agent("c").model("gemini-2.5-flash")
        result = a | b | c
        assert isinstance(result, FanOut)
        built = result.build()
        assert len(built.sub_agents) == 3


class TestMulOperator:
    """* creates Loop."""

    def test_agent_times_int_creates_loop(self):
        a = Agent("critic").model("gemini-2.5-flash")
        result = a * 3
        assert isinstance(result, Loop)

    def test_loop_has_max_iterations(self):
        a = Agent("critic").model("gemini-2.5-flash")
        result = a * 5
        assert result._config["max_iterations"] == 5

    def test_loop_name_derived(self):
        a = Agent("refiner").model("gemini-2.5-flash")
        result = a * 3
        assert result._config["name"] == "refiner_x3"

    def test_rmul_int_times_agent(self):
        a = Agent("critic").model("gemini-2.5-flash")
        result = 3 * a
        assert isinstance(result, Loop)
        assert result._config["max_iterations"] == 3

    def test_pipeline_times_int(self):
        """(a >> b) * 3 should create a Loop with both steps."""
        a = Agent("critic").model("gemini-2.5-flash")
        b = Agent("reviser").model("gemini-2.5-flash")
        pipe = a >> b
        result = pipe * 3
        assert isinstance(result, Loop)
        assert result._config["max_iterations"] == 3


class TestComposedOperators:
    """Complex compositions."""

    def test_fanout_then_pipeline(self):
        """(a | b) >> c should work."""
        a = Agent("web").model("gemini-2.5-flash")
        b = Agent("db").model("gemini-2.5-flash")
        c = Agent("writer").model("gemini-2.5-flash")
        result = (a | b) >> c
        assert isinstance(result, Pipeline)

    def test_pipeline_with_loop(self):
        """a >> (b >> c) * 3 >> d should work."""
        a = Agent("a").model("gemini-2.5-flash")
        b = Agent("b").model("gemini-2.5-flash")
        c = Agent("c").model("gemini-2.5-flash")
        d = Agent("d").model("gemini-2.5-flash")
        result = a >> (b >> c) * 3 >> d
        assert isinstance(result, Pipeline)
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_operators.py -v`
Expected: FAIL — `__rshift__` not defined

**Step 3: Implement operators in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    def __rshift__(self, other: BuilderBase) -> BuilderBase:
        """self >> other creates a Pipeline (sequential composition)."""
        from adk_fluent.workflow import Pipeline
        if isinstance(self, Pipeline):
            return self.step(other)
        my_name = self._config.get("name", "a")
        other_name = other._config.get("name", "b") if hasattr(other, "_config") else "b"
        return Pipeline(f"{my_name}_then_{other_name}").step(self).step(other)

    def __or__(self, other: BuilderBase) -> BuilderBase:
        """self | other creates a FanOut (parallel composition)."""
        from adk_fluent.workflow import FanOut
        if isinstance(self, FanOut):
            return self.branch(other)
        my_name = self._config.get("name", "a")
        other_name = other._config.get("name", "b") if hasattr(other, "_config") else "b"
        return FanOut(f"{my_name}_and_{other_name}").branch(self).branch(other)

    def __mul__(self, iterations: int) -> BuilderBase:
        """self * N creates a Loop with max_iterations=N."""
        from adk_fluent.workflow import Pipeline, Loop
        name = self._config.get("name", "loop")
        loop = Loop(f"{name}_x{iterations}").max_iterations(iterations)
        if isinstance(self, Pipeline):
            for agent in self._lists.get("sub_agents", []):
                loop._lists["sub_agents"].append(agent)
        else:
            loop.step(self)
        return loop

    def __rmul__(self, iterations: int) -> BuilderBase:
        """N * self also creates a Loop."""
        return self.__mul__(iterations)
```

**Step 4: Run tests**

Run: `uv run pytest tests/manual/test_operators.py tests/ -v --tb=short`
Expected: All PASS

**Step 5: Commit**

```bash
git add src/adk_fluent/_base.py tests/manual/test_operators.py
git commit -m "feat: add >>, |, * operators for pipeline/fanout/loop composition"
```

______________________________________________________________________

### Task 4: `validate()` and `explain()`

**Files:**

- Modify: `src/adk_fluent/_base.py`
- Test: `tests/manual/test_validate.py`

**Step 1: Write the failing test**

Create `tests/manual/test_validate.py`:

```python
"""Tests for validate() and explain()."""

from adk_fluent import Agent, Pipeline


class TestValidate:

    def test_validate_returns_self(self):
        agent = Agent("test").model("gemini-2.5-flash")
        result = agent.validate()
        assert result is agent

    def test_validate_raises_on_invalid(self):
        """Agent without model should fail validation."""
        import pytest
        agent = Agent("test")
        with pytest.raises(ValueError, match="validation failed"):
            agent.validate()

    def test_validate_chainable(self):
        agent = Agent("test").model("gemini-2.5-flash").validate().instruct("Hello")
        assert agent._config["instruction"] == "Hello"


class TestExplain:

    def test_explain_returns_string(self):
        agent = Agent("test").model("gemini-2.5-flash").instruct("Be helpful.")
        result = agent.explain()
        assert isinstance(result, str)

    def test_explain_shows_name(self):
        agent = Agent("math").model("gemini-2.5-flash")
        result = agent.explain()
        assert "math" in result

    def test_explain_shows_fields(self):
        agent = Agent("math").model("gemini-2.5-flash").instruct("Solve math.")
        result = agent.explain()
        assert "model" in result or "gemini" in result
        assert "instruction" in result or "instruct" in result

    def test_explain_shows_callback_count(self):
        fn = lambda ctx: None
        agent = Agent("a").model("gemini-2.5-flash").before_model(fn).before_model(fn)
        result = agent.explain()
        assert "2" in result  # 2 callbacks

    def test_explain_shows_list_count(self):
        fn = lambda: None
        agent = Agent("a").model("gemini-2.5-flash").tool(fn).tool(fn).tool(fn)
        result = agent.explain()
        assert "3" in result  # 3 tools
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_validate.py -v`
Expected: FAIL — `validate` not defined on BuilderBase

**Step 3: Implement validate/explain in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    def validate(self) -> Self:
        """Validate builder config by attempting a trial build.

        Returns self for chaining. Raises ValueError with a clear message
        if the configuration is invalid.
        """
        try:
            self.build()
        except Exception as e:
            name = self._config.get("name", "?")
            raise ValueError(
                f"Builder '{name}' validation failed: {e}"
            ) from e
        return self

    def explain(self) -> str:
        """Return a human-readable summary of builder state."""
        cls_name = self.__class__.__name__
        name = self._config.get("name", "?")
        lines = [f"{cls_name} '{name}':"]
        for k, v in self._config.items():
            if k == "name" or k.startswith("_"):
                continue
            lines.append(f"  {k}: {self._format_value(v)}")
        for field, fns in self._callbacks.items():
            lines.append(f"  {field}: {len(fns)} callback(s)")
        for field, items in self._lists.items():
            lines.append(f"  {field}: {len(items)} item(s)")
        return "\n".join(lines)
```

**Step 4: Run tests**

Run: `uv run pytest tests/manual/test_validate.py tests/ -v --tb=short`
Expected: All PASS

**Step 5: Commit**

```bash
git add src/adk_fluent/_base.py tests/manual/test_validate.py
git commit -m "feat: add validate() and explain() to BuilderBase"
```

______________________________________________________________________

### Task 5: Serialization (`to_dict`, `from_dict`, `to_yaml`)

**Files:**

- Modify: `src/adk_fluent/_base.py`
- Test: `tests/manual/test_serialization.py`

**Step 1: Write the failing test**

Create `tests/manual/test_serialization.py`:

```python
"""Tests for to_dict, from_dict, to_yaml, from_yaml."""

from adk_fluent import Agent, Pipeline


class TestToDict:

    def test_returns_dict(self):
        agent = Agent("math").model("gemini-2.5-flash")
        result = agent.to_dict()
        assert isinstance(result, dict)

    def test_includes_type(self):
        agent = Agent("math")
        result = agent.to_dict()
        assert result["_type"] == "Agent"

    def test_includes_config(self):
        agent = Agent("math").model("gemini-2.5-flash").instruct("Solve math.")
        result = agent.to_dict()
        assert result["config"]["name"] == "math"
        assert result["config"]["model"] == "gemini-2.5-flash"
        assert result["config"]["instruction"] == "Solve math."

    def test_includes_callback_qualnames(self):
        def my_func(ctx):
            pass
        agent = Agent("a").before_model(my_func)
        result = agent.to_dict()
        assert "before_model_callback" in result["callbacks"]
        assert "my_func" in result["callbacks"]["before_model_callback"][0]

    def test_excludes_internal_fields(self):
        """Fields starting with _ should not appear in config."""
        agent = Agent("a")
        agent._config["_debug"] = True
        result = agent.to_dict()
        assert "_debug" not in result["config"]


class TestFromDict:

    def test_round_trip_config(self):
        agent = Agent("math").model("gemini-2.5-flash").instruct("Solve math.")
        data = agent.to_dict()
        restored = Agent.from_dict(data)
        assert restored._config["name"] == "math"
        assert restored._config["model"] == "gemini-2.5-flash"
        assert restored._config["instruction"] == "Solve math."

    def test_from_dict_type_matches(self):
        agent = Agent("test")
        data = agent.to_dict()
        restored = Agent.from_dict(data)
        assert type(restored) is Agent

    def test_pipeline_round_trip(self):
        pipe = Pipeline("my_pipe").describe("A pipeline")
        data = pipe.to_dict()
        restored = Pipeline.from_dict(data)
        assert restored._config["name"] == "my_pipe"
        assert restored._config["description"] == "A pipeline"


class TestYaml:

    def test_to_yaml_returns_string(self):
        agent = Agent("math").model("gemini-2.5-flash")
        result = agent.to_yaml()
        assert isinstance(result, str)
        assert "math" in result
        assert "gemini-2.5-flash" in result

    def test_yaml_round_trip(self):
        agent = Agent("math").model("gemini-2.5-flash").instruct("Solve.")
        yaml_str = agent.to_yaml()
        restored = Agent.from_yaml(yaml_str)
        assert restored._config["name"] == "math"
        assert restored._config["model"] == "gemini-2.5-flash"
        assert restored._config["instruction"] == "Solve."
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_serialization.py -v`
Expected: FAIL — `to_dict` not defined

**Step 3: Implement serialization in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    @staticmethod
    def _serialize_value(v: Any) -> Any:
        """Serialize a value for to_dict. Callables become qualnames."""
        if callable(v):
            return f"<callable:{getattr(v, '__qualname__', repr(v))}>"
        if hasattr(v, "to_dict"):
            return v.to_dict()
        if hasattr(v, "name"):  # Built ADK agents
            return f"<agent:{v.name}>"
        return v

    def to_dict(self) -> dict:
        """Serialize builder state to a plain dict.

        Callable values are stored as informational qualname strings.
        Use from_dict() to reconstruct config (without callables).
        """
        config = {}
        for k, v in self._config.items():
            if k.startswith("_"):
                continue
            config[k] = self._serialize_value(v)

        callbacks = {}
        for k, fns in self._callbacks.items():
            if fns:
                callbacks[k] = [
                    getattr(fn, "__qualname__", repr(fn)) for fn in fns
                ]

        lists = {}
        for k, items in self._lists.items():
            if items:
                lists[k] = [self._serialize_value(item) for item in items]

        return {
            "_type": self.__class__.__name__,
            "config": config,
            "callbacks": callbacks,
            "lists": lists,
        }

    @classmethod
    def from_dict(cls, data: dict) -> Self:
        """Reconstruct a builder from a dict.

        Only restores serializable config fields (not callables or list items).
        """
        config = data.get("config", {})
        name = config.get("name", "unnamed")

        # Determine constructor args — inspect __init__ signature
        import inspect
        sig = inspect.signature(cls.__init__)
        params = [
            p.name for p in sig.parameters.values()
            if p.name != "self" and p.default is inspect.Parameter.empty
        ]

        # Build constructor kwargs
        ctor_kwargs = {}
        for p in params:
            if p in config:
                ctor_kwargs[p] = config[p]

        builder = cls(**ctor_kwargs)

        # Set remaining config fields
        for k, v in config.items():
            if k not in ctor_kwargs:
                builder._config[k] = v

        return builder

    def to_yaml(self) -> str:
        """Serialize to YAML string. Requires PyYAML."""
        import yaml
        return yaml.dump(self.to_dict(), default_flow_style=False, sort_keys=False)

    @classmethod
    def from_yaml(cls, yaml_str: str) -> Self:
        """Reconstruct a builder from a YAML string."""
        import yaml
        return cls.from_dict(yaml.safe_load(yaml_str))
```

**Step 4: Run tests**

Run: `uv run pytest tests/manual/test_serialization.py tests/ -v --tb=short`
Expected: All PASS

**Step 5: Commit**

```bash
git add src/adk_fluent/_base.py tests/manual/test_serialization.py
git commit -m "feat: add to_dict/from_dict/to_yaml/from_yaml serialization"
```

______________________________________________________________________

### Task 6: `with_()` — Immutable Variants

**Files:**

- Modify: `src/adk_fluent/_base.py`
- Test: `tests/manual/test_with.py`

**Step 1: Write the failing test**

Create `tests/manual/test_with.py`:

```python
"""Tests for .with_() immutable variants."""

from adk_fluent import Agent


class TestWith:

    def test_with_returns_new_builder(self):
        original = Agent("test").model("gemini-2.5-flash")
        variant = original.with_(model="gemini-2.5-pro")
        assert variant is not original

    def test_with_overrides_field(self):
        original = Agent("test").model("gemini-2.5-flash")
        variant = original.with_(model="gemini-2.5-pro")
        assert variant._config["model"] == "gemini-2.5-pro"

    def test_original_unchanged(self):
        original = Agent("test").model("gemini-2.5-flash")
        _ = original.with_(model="gemini-2.5-pro")
        assert original._config["model"] == "gemini-2.5-flash"

    def test_with_name_override(self):
        original = Agent("dev").model("gemini-2.5-flash")
        variant = original.with_(name="prod", model="gemini-2.5-pro")
        assert variant._config["name"] == "prod"
        assert original._config["name"] == "dev"

    def test_with_preserves_existing_config(self):
        original = Agent("test").model("gemini-2.5-flash").instruct("Be helpful.")
        variant = original.with_(model="gemini-2.5-pro")
        assert variant._config["instruction"] == "Be helpful."

    def test_with_resolves_aliases(self):
        original = Agent("test").model("gemini-2.5-flash")
        variant = original.with_(instruct="New instruction")
        assert variant._config["instruction"] == "New instruction"
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_with.py -v`
Expected: FAIL — `with_` not defined

**Step 3: Implement with\_() in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    def with_(self, **overrides) -> Self:
        """Create a modified copy of this builder. Original is unchanged.

        Accepts any field name (including aliases). Returns a new builder
        with the specified fields overridden.
        """
        new_name = overrides.pop("name", self._config.get("name", "unnamed"))
        clone = self.clone(new_name)
        aliases = getattr(self.__class__, "_ALIASES", {})
        additive = getattr(self.__class__, "_ADDITIVE_FIELDS", set())
        for key, value in overrides.items():
            resolved = aliases.get(key, key)
            if resolved in additive:
                clone._callbacks[resolved].append(value)
            else:
                clone._config[resolved] = value
        return clone
```

**Step 4: Run tests**

Run: `uv run pytest tests/manual/test_with.py tests/ -v --tb=short`
Expected: All PASS

**Step 5: Commit**

```bash
git add src/adk_fluent/_base.py tests/manual/test_with.py
git commit -m "feat: add .with_() for immutable builder variants"
```

______________________________________________________________________

### Task 7: Presets (`.use()`)

**Files:**

- Create: `src/adk_fluent/presets.py`
- Modify: `src/adk_fluent/_base.py`
- Modify: `src/adk_fluent/__init__.py` (add Preset import — manual edit, not regenerated)
- Test: `tests/manual/test_presets.py`

**Step 1: Write the failing test**

Create `tests/manual/test_presets.py`:

```python
"""Tests for Preset and .use()."""

from adk_fluent import Agent
from adk_fluent.presets import Preset


class TestPreset:

    def test_preset_creation(self):
        p = Preset(model="gemini-2.5-flash")
        assert p._fields["model"] == "gemini-2.5-flash"

    def test_preset_with_callbacks(self):
        fn = lambda ctx: None
        p = Preset(before_model=[fn])
        assert "before_model" in p._callbacks
        assert p._callbacks["before_model"] == [fn]


class TestUse:

    def test_use_applies_fields(self):
        preset = Preset(model="gemini-2.5-flash")
        agent = Agent("test").use(preset)
        assert agent._config["model"] == "gemini-2.5-flash"

    def test_use_returns_self(self):
        preset = Preset(model="gemini-2.5-flash")
        agent = Agent("test")
        result = agent.use(preset)
        assert result is agent

    def test_use_applies_callbacks(self):
        fn = lambda ctx: None
        preset = Preset(before_model=[fn])
        agent = Agent("test").use(preset)
        assert fn in agent._callbacks["before_model_callback"]

    def test_use_chainable(self):
        fast = Preset(model="gemini-2.5-flash")
        agent = Agent("test").use(fast).instruct("Do stuff.")
        assert agent._config["model"] == "gemini-2.5-flash"
        assert agent._config["instruction"] == "Do stuff."

    def test_multiple_presets(self):
        model_preset = Preset(model="gemini-2.5-flash")
        fn = lambda ctx: None
        logging_preset = Preset(before_model=[fn])
        agent = Agent("test").use(model_preset).use(logging_preset)
        assert agent._config["model"] == "gemini-2.5-flash"
        assert fn in agent._callbacks["before_model_callback"]

    def test_preset_doesnt_treat_model_string_as_callable(self):
        """'model' is a string field, not a callback."""
        preset = Preset(model="gemini-2.5-flash")
        assert "model" in preset._fields
        assert "model" not in preset._callbacks
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_presets.py -v`
Expected: FAIL — `presets` module doesn't exist

**Step 3: Create Preset class**

Create `src/adk_fluent/presets.py`:

```python
"""Reusable configuration bundles for adk-fluent builders."""

from __future__ import annotations

from typing import Any, Callable


# Fields known to be plain values (not callbacks) even though
# they could look callable in some contexts.
_KNOWN_VALUE_FIELDS = frozenset({
    "model", "name", "description", "instruction", "global_instruction",
    "max_iterations", "output_key", "input_key",
})


class Preset:
    """A reusable bundle of builder configuration.

    Fields are automatically classified as config values or callbacks:
    - Lists of callables → callbacks
    - Known value fields (model, instruction, etc.) → config values
    - Single callables for unknown fields → callbacks
    - Everything else → config values

    Usage:
        production = Preset(model="gemini-2.5-pro", before_model=[rate_limiter])
        agent = Agent("math").use(production).instruct("Solve math.")
    """

    def __init__(self, **kwargs: Any) -> None:
        self._fields: dict[str, Any] = {}
        self._callbacks: dict[str, list[Callable]] = {}

        for k, v in kwargs.items():
            if k in _KNOWN_VALUE_FIELDS:
                self._fields[k] = v
            elif isinstance(v, list) and v and callable(v[0]):
                self._callbacks[k] = list(v)
            elif callable(v) and not isinstance(v, str):
                self._callbacks[k] = [v]
            else:
                self._fields[k] = v

    def __repr__(self) -> str:
        parts = []
        for k, v in self._fields.items():
            parts.append(f"{k}={v!r}")
        for k, fns in self._callbacks.items():
            names = [getattr(fn, "__name__", repr(fn)) for fn in fns]
            parts.append(f"{k}=[{', '.join(names)}]")
        return f"Preset({', '.join(parts)})"
```

**Step 4: Implement `.use()` in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    def use(self, preset) -> Self:
        """Apply a Preset configuration bundle to this builder.

        Fields from the preset are merged into the builder's config.
        Callbacks are appended to the builder's callback lists.
        """
        aliases = getattr(self.__class__, "_ALIASES", {})
        cb_aliases = getattr(self.__class__, "_CALLBACK_ALIASES", {})

        for k, v in preset._fields.items():
            resolved = aliases.get(k, k)
            self._config[resolved] = v

        for k, fns in preset._callbacks.items():
            resolved = cb_aliases.get(k, k)
            for fn in fns:
                self._callbacks[resolved].append(fn)

        return self
```

**Step 5: Add Preset to `__init__.py`**

Add these lines to the top of `src/adk_fluent/__init__.py` (after the auto-generated imports):

```python
from .presets import Preset
```

And add `"Preset"` to the `__all__` list.

**Step 6: Run tests**

Run: `uv run pytest tests/manual/test_presets.py tests/ -v --tb=short`
Expected: All PASS

**Step 7: Commit**

```bash
git add src/adk_fluent/presets.py src/adk_fluent/_base.py src/adk_fluent/__init__.py tests/manual/test_presets.py
git commit -m "feat: add Preset class and .use() for reusable config bundles"
```

______________________________________________________________________

## Group 3: Runtime Features

### Task 8: Structured Output (`.output()`)

**Files:**

- Modify: `src/adk_fluent/_base.py` (add .output() method)
- Modify: `src/adk_fluent/_helpers.py` (modify run_one_shot_async to handle schemas)
- Test: `tests/manual/test_structured_output.py`

**Step 1: Write the failing test**

Create `tests/manual/test_structured_output.py`:

```python
"""Tests for .output() structured output (builder mechanics only)."""

from pydantic import BaseModel
from adk_fluent import Agent


class MathResult(BaseModel):
    answer: float
    steps: list[str]


class TestOutputBuilder:

    def test_output_stores_schema(self):
        agent = Agent("math").model("gemini-2.5-flash").output(MathResult)
        assert agent._config["_output_schema"] is MathResult

    def test_output_returns_self(self):
        agent = Agent("math")
        result = agent.output(MathResult)
        assert result is agent

    def test_output_chainable(self):
        agent = (
            Agent("math")
            .model("gemini-2.5-flash")
            .output(MathResult)
            .instruct("Solve math.")
        )
        assert agent._config["_output_schema"] is MathResult
        assert agent._config["instruction"] == "Solve math."

    def test_output_excluded_from_to_dict(self):
        """Internal _output_schema field should not appear in to_dict."""
        agent = Agent("math").model("gemini-2.5-flash").output(MathResult)
        data = agent.to_dict()
        assert "_output_schema" not in data["config"]

    def test_output_excluded_from_repr(self):
        """Internal fields should not show in repr."""
        agent = Agent("math").model("gemini-2.5-flash").output(MathResult)
        r = repr(agent)
        assert "_output_schema" not in r
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_structured_output.py -v`
Expected: FAIL — `output` not defined

**Step 3: Implement .output() in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    def output(self, schema: type) -> Self:
        """Set a Pydantic model as the output schema.

        When set, .ask() returns a parsed instance of the schema
        instead of a raw string.
        """
        self._config["_output_schema"] = schema
        return self
```

**Step 4: Modify `_helpers.py` to use output schema**

In `src/adk_fluent/_helpers.py`, modify `run_one_shot_async` to check for schema and parse:

At the end of `run_one_shot_async`, before `return last_text`, add:

```python
    schema = builder._config.get("_output_schema")
    if schema is not None:
        import json as _json
        try:
            return schema.model_validate_json(last_text)
        except Exception:
            # Try parsing as dict first in case response is wrapped
            try:
                data = _json.loads(last_text)
                return schema.model_validate(data)
            except Exception:
                pass
            # Return raw text if parsing fails
            return last_text

    return last_text
```

Also add output schema to the build config as `generate_content_config.response_schema` if present — modify the `run_one_shot_async` function to configure the agent with schema awareness.

**Step 5: Run tests**

Run: `uv run pytest tests/manual/test_structured_output.py tests/ -v --tb=short`
Expected: All PASS

**Step 6: Commit**

```bash
git add src/adk_fluent/_base.py src/adk_fluent/_helpers.py tests/manual/test_structured_output.py
git commit -m "feat: add .output() for structured output with Pydantic models"
```

______________________________________________________________________

### Task 9: `.map()` — Batch Execution

**Files:**

- Modify: `src/adk_fluent/_helpers.py` (add run_map, run_map_async)
- Modify: `seeds/seed.toml` (add map + map_async extras to Agent)
- Regenerate: `src/adk_fluent/agent.py`
- Test: `tests/manual/test_map.py`

**Step 1: Write the failing test**

Create `tests/manual/test_map.py`:

```python
"""Tests for .map() batch execution (builder mechanics only — verifies method exists)."""

from adk_fluent import Agent


class TestMapBuilder:

    def test_map_method_exists(self):
        agent = Agent("test").model("gemini-2.5-flash")
        assert hasattr(agent, "map")
        assert callable(agent.map)

    def test_map_async_method_exists(self):
        agent = Agent("test").model("gemini-2.5-flash")
        assert hasattr(agent, "map_async")
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_map.py -v`
Expected: FAIL — `map` not a method on Agent

**Step 3: Add map helpers to `_helpers.py`**

Add to `src/adk_fluent/_helpers.py`:

```python
async def run_map_async(builder, prompts, *, concurrency=5):
    """Run the same agent against multiple prompts with bounded concurrency.

    Returns a list of responses in the same order as prompts.
    """
    semaphore = asyncio.Semaphore(concurrency)

    async def _one(prompt):
        async with semaphore:
            return await run_one_shot_async(builder, prompt)

    return await asyncio.gather(*[_one(p) for p in prompts])


def run_map(builder, prompts, *, concurrency=5):
    """Synchronous wrapper around run_map_async."""
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        loop = None

    if loop and loop.is_running():
        import concurrent.futures
        with concurrent.futures.ThreadPoolExecutor() as pool:
            return pool.submit(
                asyncio.run,
                run_map_async(builder, prompts, concurrency=concurrency)
            ).result()
    else:
        return asyncio.run(
            run_map_async(builder, prompts, concurrency=concurrency)
        )
```

**Step 4: Add extras to seed.toml**

Add to the `[[builders.Agent.extras]]` section in `seeds/seed.toml`:

```toml
[[builders.Agent.extras]]
name = "map"
signature = "(self, prompts: list[str], *, concurrency: int = 5) -> list[str]"
doc = "Run agent against multiple prompts with bounded concurrency. Returns list of responses."
behavior = "runtime_helper"
helper_func = "run_map"

[[builders.Agent.extras]]
name = "map_async"
signature = "(self, prompts: list[str], *, concurrency: int = 5) -> list[str]"
doc = "Async batch execution against multiple prompts."
behavior = "runtime_helper_async"
helper_func = "run_map_async"
```

**Step 5: Regenerate**

Run: `uv run python scripts/generator.py seeds/seed.toml manifest.json --output-dir src/adk_fluent --test-dir tests/generated`

**Step 6: Run tests**

Run: `uv run pytest tests/manual/test_map.py tests/ -v --tb=short`
Expected: All PASS

**Step 7: Commit**

```bash
git add src/adk_fluent/_helpers.py seeds/seed.toml src/adk_fluent/agent.py src/adk_fluent/agent.pyi src/adk_fluent/__init__.py tests/generated/ tests/manual/test_map.py
git commit -m "feat: add .map() and .map_async() for batch prompt execution"
```

______________________________________________________________________

### Task 10: `.retry()` and `.fallback()`

**Files:**

- Modify: `src/adk_fluent/_base.py` (add retry/fallback methods)
- Modify: `src/adk_fluent/_helpers.py` (wrap execution with retry/fallback logic)
- Test: `tests/manual/test_retry.py`

**Step 1: Write the failing test**

Create `tests/manual/test_retry.py`:

```python
"""Tests for .retry() and .fallback() (builder mechanics only)."""

from adk_fluent import Agent


class TestRetryBuilder:

    def test_retry_stores_config(self):
        agent = Agent("test").retry(max_attempts=3, backoff=2.0)
        assert agent._config["_retry"]["max_attempts"] == 3
        assert agent._config["_retry"]["backoff"] == 2.0

    def test_retry_returns_self(self):
        agent = Agent("test")
        result = agent.retry()
        assert result is agent

    def test_retry_defaults(self):
        agent = Agent("test").retry()
        assert agent._config["_retry"]["max_attempts"] == 3
        assert agent._config["_retry"]["backoff"] == 1.0

    def test_retry_chainable(self):
        agent = Agent("test").model("gemini-2.5-flash").retry().instruct("Hello")
        assert "_retry" in agent._config
        assert agent._config["instruction"] == "Hello"


class TestFallbackBuilder:

    def test_fallback_stores_model(self):
        agent = Agent("test").fallback("gemini-2.5-flash")
        assert agent._config["_fallbacks"] == ["gemini-2.5-flash"]

    def test_fallback_returns_self(self):
        agent = Agent("test")
        result = agent.fallback("gemini-2.5-flash")
        assert result is agent

    def test_multiple_fallbacks_accumulate(self):
        agent = (
            Agent("test")
            .fallback("gemini-2.5-flash")
            .fallback("gemini-2.0-flash")
        )
        assert agent._config["_fallbacks"] == ["gemini-2.5-flash", "gemini-2.0-flash"]

    def test_retry_and_fallback_combined(self):
        agent = (
            Agent("test")
            .model("gemini-2.5-pro")
            .retry(max_attempts=2)
            .fallback("gemini-2.5-flash")
        )
        assert agent._config["_retry"]["max_attempts"] == 2
        assert agent._config["_fallbacks"] == ["gemini-2.5-flash"]

    def test_internal_fields_excluded_from_repr(self):
        agent = Agent("test").retry().fallback("gemini-2.5-flash")
        r = repr(agent)
        assert "_retry" not in r
        assert "_fallbacks" not in r
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_retry.py -v`
Expected: FAIL — `retry` not defined

**Step 3: Implement retry/fallback in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    def retry(self, max_attempts: int = 3, backoff: float = 1.0) -> Self:
        """Configure retry behavior for runtime execution.

        Args:
            max_attempts: Maximum number of attempts before failing.
            backoff: Base delay in seconds between retries (exponential).
        """
        self._config["_retry"] = {
            "max_attempts": max_attempts,
            "backoff": backoff,
        }
        return self

    def fallback(self, model: str) -> Self:
        """Add a fallback model to try if the primary model fails.

        Multiple calls accumulate fallback models in order.
        """
        self._config.setdefault("_fallbacks", []).append(model)
        return self
```

**Step 4: Implement retry/fallback logic in `_helpers.py`**

Modify `run_one_shot_async` in `src/adk_fluent/_helpers.py` to wrap execution with retry and fallback:

```python
async def _execute_with_resilience(builder, prompt):
    """Execute with retry and fallback support."""
    retry_config = builder._config.get("_retry")
    fallbacks = builder._config.get("_fallbacks", [])

    max_attempts = retry_config["max_attempts"] if retry_config else 1
    backoff = retry_config["backoff"] if retry_config else 1.0

    last_error = None
    models_to_try = [builder._config.get("model")] + fallbacks

    for model in models_to_try:
        for attempt in range(max_attempts):
            try:
                # Temporarily set model if using fallback
                original_model = builder._config.get("model")
                if model and model != original_model:
                    builder._config["model"] = model
                try:
                    return await _run_one_shot_core(builder, prompt)
                finally:
                    if model and model != original_model:
                        builder._config["model"] = original_model
            except Exception as e:
                last_error = e
                if attempt < max_attempts - 1:
                    await asyncio.sleep(backoff * (2 ** attempt))

    raise last_error
```

Then refactor `run_one_shot_async` to call `_execute_with_resilience` when retry/fallback config is present.

**Step 5: Run tests**

Run: `uv run pytest tests/manual/test_retry.py tests/ -v --tb=short`
Expected: All PASS

**Step 6: Commit**

```bash
git add src/adk_fluent/_base.py src/adk_fluent/_helpers.py tests/manual/test_retry.py
git commit -m "feat: add .retry() and .fallback() for resilient execution"
```

______________________________________________________________________

### Task 11: `.debug()` — Trace Mode

**Files:**

- Modify: `src/adk_fluent/_base.py` (add .debug() method)
- Modify: `src/adk_fluent/_helpers.py` (add debug tracing to execution)
- Test: `tests/manual/test_debug.py`

**Step 1: Write the failing test**

Create `tests/manual/test_debug.py`:

```python
"""Tests for .debug() trace mode (builder mechanics only)."""

from adk_fluent import Agent


class TestDebugBuilder:

    def test_debug_stores_flag(self):
        agent = Agent("test").debug()
        assert agent._config["_debug"] is True

    def test_debug_returns_self(self):
        agent = Agent("test")
        result = agent.debug()
        assert result is agent

    def test_debug_false(self):
        agent = Agent("test").debug(False)
        assert agent._config["_debug"] is False

    def test_debug_chainable(self):
        agent = Agent("test").model("gemini-2.5-flash").debug().instruct("Hello")
        assert agent._config["_debug"] is True
        assert agent._config["instruction"] == "Hello"

    def test_debug_excluded_from_repr(self):
        agent = Agent("test").debug()
        r = repr(agent)
        assert "_debug" not in r

    def test_debug_excluded_from_to_dict(self):
        agent = Agent("test").debug()
        data = agent.to_dict()
        assert "_debug" not in data["config"]
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_debug.py -v`
Expected: FAIL — `debug` not defined

**Step 3: Implement .debug() in BuilderBase**

Add to `src/adk_fluent/_base.py`:

```python
    def debug(self, enabled: bool = True) -> Self:
        """Enable debug trace mode.

        When enabled, runtime execution (.ask, .stream, .map) emits
        structured trace output to stderr showing prompts, responses,
        timing, and callback execution.
        """
        self._config["_debug"] = enabled
        return self
```

**Step 4: Add debug tracing to `_helpers.py`**

In `src/adk_fluent/_helpers.py`, add a debug trace helper and integrate it into `run_one_shot_async`:

```python
import sys
import time

def _debug_log(agent_name: str, msg: str):
    """Emit a debug trace line to stderr."""
    print(f"[{agent_name}] {msg}", file=sys.stderr)


# In run_one_shot_async, wrap key sections with timing and debug output:
# if builder._config.get("_debug"):
#     _debug_log(name, f"-> prompt: {prompt!r}")
#     start = time.perf_counter()
# ... execute ...
# if builder._config.get("_debug"):
#     elapsed = (time.perf_counter() - start) * 1000
#     _debug_log(name, f"<- response: {last_text[:80]!r} ({elapsed:.0f}ms)")
```

**Step 5: Run tests**

Run: `uv run pytest tests/manual/test_debug.py tests/ -v --tb=short`
Expected: All PASS

**Step 6: Commit**

```bash
git add src/adk_fluent/_base.py src/adk_fluent/_helpers.py tests/manual/test_debug.py
git commit -m "feat: add .debug() trace mode for runtime execution"
```

______________________________________________________________________

## Group 4: Decorator Syntax

### Task 12: `@agent` Decorator

**Files:**

- Create: `src/adk_fluent/decorators.py`
- Modify: `src/adk_fluent/__init__.py` (add `agent` import)
- Test: `tests/manual/test_decorator.py`

**Step 1: Write the failing test**

Create `tests/manual/test_decorator.py`:

```python
"""Tests for @agent decorator syntax."""

from adk_fluent.decorators import agent


class TestAgentDecorator:

    def test_decorator_returns_builder(self):
        from adk_fluent.agent import Agent

        @agent("math", model="gemini-2.5-flash")
        def math_solver():
            """Solve math problems step by step."""

        assert isinstance(math_solver, Agent)

    def test_docstring_becomes_instruction(self):
        @agent("math", model="gemini-2.5-flash")
        def math_solver():
            """Solve math problems step by step."""

        assert math_solver._config["instruction"] == "Solve math problems step by step."

    def test_kwargs_applied(self):
        @agent("math", model="gemini-2.5-pro")
        def solver():
            """Do math."""

        assert solver._config["model"] == "gemini-2.5-pro"

    def test_tool_decorator(self):
        @agent("math", model="gemini-2.5-flash")
        def solver():
            """Solve math."""

        @solver.tool
        def calculator(x: int, y: int) -> int:
            """Add two numbers."""
            return x + y

        assert len(solver._lists["tools"]) == 1

    def test_tool_decorator_preserves_function(self):
        @agent("math", model="gemini-2.5-flash")
        def solver():
            """Solve."""

        @solver.tool
        def calculator(x: int) -> int:
            return x * 2

        # The decorated function should still be callable
        assert calculator(5) == 10

    def test_on_decorator(self):
        @agent("math", model="gemini-2.5-flash")
        def solver():
            """Solve."""

        @solver.on("before_model")
        def log_it(ctx):
            pass

        assert log_it in solver._callbacks["before_model_callback"]

    def test_on_preserves_function(self):
        @agent("math", model="gemini-2.5-flash")
        def solver():
            """Solve."""

        @solver.on("before_model")
        def log_it(ctx):
            return "logged"

        assert log_it(None) == "logged"

    def test_no_docstring(self):
        @agent("math", model="gemini-2.5-flash")
        def solver():
            pass

        assert "instruction" not in solver._config

    def test_multiple_tools(self):
        @agent("math", model="gemini-2.5-flash")
        def solver():
            """Solve."""

        @solver.tool
        def add(a: int, b: int) -> int:
            return a + b

        @solver.tool
        def multiply(a: int, b: int) -> int:
            return a * b

        assert len(solver._lists["tools"]) == 2
```

**Step 2: Run test to verify it fails**

Run: `uv run pytest tests/manual/test_decorator.py -v`
Expected: FAIL — `decorators` module doesn't exist

**Step 3: Create decorator module**

Create `src/adk_fluent/decorators.py`:

```python
"""Decorator syntax for defining agents — inspired by FastAPI's @app.route."""

from __future__ import annotations

from typing import Any, Callable


def agent(name: str, **kwargs: Any) -> Callable:
    """Decorator that creates an Agent builder from a function.

    The decorated function's docstring becomes the agent's instruction.
    The function itself is replaced with the Agent builder instance.

    Usage:
        @agent("math", model="gemini-2.5-flash")
        def math_solver():
            \"""Solve math problems step by step.\"""

        @math_solver.tool
        def calculator(expr: str) -> float:
            return eval(expr)

        @math_solver.on("before_model")
        def log_request(ctx):
            print("Calling model...")

        result = math_solver.ask("What is 2+2?")
    """
    def decorator(fn: Callable) -> Any:
        from adk_fluent.agent import Agent

        builder = Agent(name)

        # Docstring → instruction
        if fn.__doc__:
            builder.instruct(fn.__doc__.strip())

        # Apply kwargs as builder method calls
        for k, v in kwargs.items():
            method = getattr(builder, k, None)
            if method and callable(method):
                method(v)
            else:
                builder._config[k] = v

        # Override .tool to work as a decorator (returns the function, not self)
        original_tool_append = builder._lists["tools"].append

        def tool_decorator(tool_fn: Callable) -> Callable:
            original_tool_append(tool_fn)
            return tool_fn

        builder.tool = tool_decorator

        # Add .on(event_name) decorator factory
        def on(event_name: str) -> Callable:
            cb_field = builder._CALLBACK_ALIASES.get(event_name, event_name)
            def event_decorator(callback_fn: Callable) -> Callable:
                builder._callbacks[cb_field].append(callback_fn)
                return callback_fn
            return event_decorator

        builder.on = on

        return builder

    return decorator
```

**Step 4: Add to `__init__.py`**

Add to `src/adk_fluent/__init__.py`:

```python
from .decorators import agent
```

And add `"agent"` to the `__all__` list.

**Step 5: Run tests**

Run: `uv run pytest tests/manual/test_decorator.py tests/ -v --tb=short`
Expected: All PASS

**Step 6: Commit**

```bash
git add src/adk_fluent/decorators.py src/adk_fluent/__init__.py tests/manual/test_decorator.py
git commit -m "feat: add @agent decorator syntax for FastAPI-style agent definitions"
```

______________________________________________________________________

## Group 5: Integration

### Task 13: Update `__init__.py` exports and regenerate stubs

**Files:**

- Modify: `src/adk_fluent/__init__.py`
- Regenerate: stubs (`.pyi` files)

**Step 1: Ensure `__init__.py` has all new exports**

The generator auto-generates `__init__.py` from seed.toml, so manual additions (Preset, agent decorator) need to be appended AFTER regeneration. Modify the generator's `__init__.py` generation in `generate_all` to append hand-written imports.

Actually, the simpler approach: after regeneration, manually append the imports. Do this:

After running the generator, edit `src/adk_fluent/__init__.py` to add:

```python
# --- Hand-written exports (not generated) ---
from .presets import Preset
from .decorators import agent

__all__ += ["Preset", "agent"]
```

**Step 2: Regenerate stubs**

Run: `uv run python scripts/generator.py seeds/seed.toml manifest.json --output-dir src/adk_fluent --test-dir tests/generated`

Then re-add the manual imports to `__init__.py` (they get overwritten by regeneration).

**Step 3: Run ALL tests**

Run: `uv run pytest tests/ -v --tb=short`
Expected: All PASS

**Step 4: Commit**

```bash
git add src/adk_fluent/ tests/generated/
git commit -m "chore: regenerate stubs and update exports with Preset and agent"
```

______________________________________________________________________

### Task 14: Cookbook Examples (16-26)

**Files:**

- Create: `examples/cookbook/16_repr.py` through `examples/cookbook/26_decorator.py`

Create these cookbook examples following the existing `# --- NATIVE ---` / `# --- FLUENT ---` / `# --- ASSERT ---` marker format:

**16_repr.py** — `__repr__` readable output
**17_pipeline_operator.py** — `>>` operator
**18_fanout_operator.py** — `|` operator
**19_loop_operator.py** — `*` operator
**20_composed_operators.py** — `(a | b) >> c >> (d >> e) * 3`
**21_validate_explain.py** — `.validate()` and `.explain()`
**22_serialization.py** — `.to_dict()` / `.from_dict()` / `.to_yaml()`
**23_with_variants.py** — `.with_()` immutable variants
**24_presets.py** — `Preset` + `.use()`
**25_structured_output.py** — `.output(Model)` (builder mechanics only)
**26_decorator.py** — `@agent` decorator

Each example follows the pattern in existing cookbooks (e.g., `10_cloning.py`): show native ADK way, show fluent way, assert equivalence.

For operator examples, there's no native ADK equivalent (that's the point), so the `# --- NATIVE ---` section shows the verbose version and `# --- FLUENT ---` shows the operator version.

**Step 1: Create all 11 cookbook files**

(See examples below for the first few — the implementer should create all of them following the same pattern.)

Example `examples/cookbook/17_pipeline_operator.py`:

```python
# Pipeline Composition with >> Operator
#
# --- NATIVE ---
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.sequential_agent import SequentialAgent

pipeline_native = SequentialAgent(
    name="researcher_then_writer",
    sub_agents=[
        LlmAgent(name="researcher", model="gemini-2.5-flash", instruction="Research."),
        LlmAgent(name="writer", model="gemini-2.5-flash", instruction="Write."),
    ],
)
# --- FLUENT ---
from adk_fluent import Agent

pipeline_fluent = (
    Agent("researcher").model("gemini-2.5-flash").instruct("Research.")
    >> Agent("writer").model("gemini-2.5-flash").instruct("Write.")
).build()
# --- ASSERT ---
assert type(pipeline_native) == type(pipeline_fluent)
assert len(pipeline_fluent.sub_agents) == 2
assert pipeline_fluent.sub_agents[0].name == "researcher"
assert pipeline_fluent.sub_agents[1].name == "writer"
```

**Step 2: Run cookbook tests**

Run: `uv run pytest examples/cookbook/ -v`
Expected: All 26 examples PASS

**Step 3: Commit**

```bash
git add examples/cookbook/
git commit -m "feat(cookbook): add examples 16-26 for Phase 2 features"
```

______________________________________________________________________

### Task 15: Regenerate Documentation

**Files:**

- Regenerate: `docs/generated/`

**Step 1: Run doc generator**

Run: `uv run python scripts/doc_generator.py seeds/seed.toml manifest.json --output-dir docs/generated --cookbook-dir examples/cookbook`

**Step 2: Verify docs generated**

Run: `ls docs/generated/cookbook/ | wc -l`
Expected: 26+ files

Run: `ls docs/generated/api/ | wc -l`
Expected: 9 files

**Step 3: Commit**

```bash
git add docs/generated/
git commit -m "docs: regenerate documentation with Phase 2 features"
```

______________________________________________________________________

### Task 16: Final Verification

**Step 1: Run ALL tests**

Run: `uv run pytest tests/ examples/cookbook/ -v --tb=short`
Expected: All tests PASS (500+ tests expected)

**Step 2: Type check**

Run: `uv run pyright src/adk_fluent/ --pythonversion 3.12 2>&1 | tail -5`

**Step 3: Verify key features work in a REPL-style test**

Create a quick smoke test script and run it:

```python
# Quick smoke test
from adk_fluent import Agent, Pipeline, FanOut, Loop, Preset
from adk_fluent.decorators import agent

# 1. repr
a = Agent("math").model("gemini-2.5-flash").instruct("Solve math.")
print(repr(a))
assert 'Agent("math")' in repr(a)

# 2. operators
b = Agent("writer").model("gemini-2.5-flash")
pipe = a >> b
assert isinstance(pipe, Pipeline)

par = a | b
assert isinstance(par, FanOut)

loop = a * 3
assert isinstance(loop, Loop)

# 3. validate
a.validate()  # should not raise

# 4. serialization
d = a.to_dict()
restored = Agent.from_dict(d)
assert restored._config["model"] == "gemini-2.5-flash"

# 5. with_
variant = a.with_(model="gemini-2.5-pro")
assert variant._config["model"] == "gemini-2.5-pro"
assert a._config["model"] == "gemini-2.5-flash"

# 6. presets
fast = Preset(model="gemini-2.5-flash")
agent2 = Agent("x").use(fast)
assert agent2._config["model"] == "gemini-2.5-flash"

# 7. decorator
@agent("test_dec", model="gemini-2.5-flash")
def my_agent():
    """Test instruction."""

assert my_agent._config["instruction"] == "Test instruction."

print("All smoke tests passed!")
```

Run: `uv run python /tmp/smoke_test.py`
Expected: "All smoke tests passed!"

**Step 4: Summary commit**

```bash
git add -A
git commit -m "feat: Phase 2 complete — 11 features: operators, repr, validate, serialization, presets, decorator, structured output, map, retry, debug"
```
