From 8f7e6c5281a9611d426abd55ea9ee57dd7aea4b4 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 16:45:15 +0000 Subject: [PATCH 01/13] feat: add GitHunter request models Add Pydantic request models for GitHunter toolset: - BlameLineRequest for blame_line tool - FindPRDiscussionRequest for find_pr_discussion tool - GetExpertsRequest for get_file_experts tool - Error model for union return types All models use Annotated[..., Field(...)] pattern with validators. Co-Authored-By: Claude Opus 4.5 --- .flow/epics/fn-2.json | 13 +++ .flow/epics/fn-3.json | 13 +++ .flow/specs/fn-2.md | 87 ++++++++++++++ .flow/specs/fn-3.md | 174 ++++++++++++++++++++++++++++ .flow/tasks/fn-2.1.json | 21 ++++ .flow/tasks/fn-2.1.md | 55 +++++++++ .flow/tasks/fn-2.2.json | 16 +++ .flow/tasks/fn-2.2.md | 70 +++++++++++ .flow/tasks/fn-2.3.json | 16 +++ .flow/tasks/fn-2.3.md | 60 ++++++++++ .flow/tasks/fn-2.4.json | 16 +++ .flow/tasks/fn-2.4.md | 63 ++++++++++ .flow/tasks/fn-3.1.json | 14 +++ .flow/tasks/fn-3.1.md | 77 ++++++++++++ .flow/tasks/fn-3.2.json | 16 +++ .flow/tasks/fn-3.2.md | 80 +++++++++++++ .flow/tasks/fn-3.3.json | 16 +++ .flow/tasks/fn-3.3.md | 94 +++++++++++++++ .flow/tasks/fn-3.4.json | 16 +++ .flow/tasks/fn-3.4.md | 92 +++++++++++++++ .flow/tasks/fn-3.5.json | 17 +++ .flow/tasks/fn-3.5.md | 87 ++++++++++++++ src/bond/tools/githunter/_models.py | 89 ++++++++++++++ 23 files changed, 1202 insertions(+) create mode 100644 .flow/epics/fn-2.json create mode 100644 .flow/epics/fn-3.json create mode 100644 .flow/specs/fn-2.md create mode 100644 .flow/specs/fn-3.md create mode 100644 .flow/tasks/fn-2.1.json create mode 100644 .flow/tasks/fn-2.1.md create mode 100644 .flow/tasks/fn-2.2.json create mode 100644 .flow/tasks/fn-2.2.md create mode 100644 .flow/tasks/fn-2.3.json create mode 100644 .flow/tasks/fn-2.3.md create mode 100644 .flow/tasks/fn-2.4.json create mode 100644 .flow/tasks/fn-2.4.md create mode 100644 .flow/tasks/fn-3.1.json create mode 100644 .flow/tasks/fn-3.1.md create mode 100644 .flow/tasks/fn-3.2.json create mode 100644 .flow/tasks/fn-3.2.md create mode 100644 .flow/tasks/fn-3.3.json create mode 100644 .flow/tasks/fn-3.3.md create mode 100644 .flow/tasks/fn-3.4.json create mode 100644 .flow/tasks/fn-3.4.md create mode 100644 .flow/tasks/fn-3.5.json create mode 100644 .flow/tasks/fn-3.5.md create mode 100644 src/bond/tools/githunter/_models.py diff --git a/.flow/epics/fn-2.json b/.flow/epics/fn-2.json new file mode 100644 index 0000000..9f327ea --- /dev/null +++ b/.flow/epics/fn-2.json @@ -0,0 +1,13 @@ +{ + "branch_name": "fn-2", + "created_at": "2026-01-24T16:19:15.152423Z", + "depends_on_epics": [], + "id": "fn-2", + "next_task": 1, + "plan_review_status": "unknown", + "plan_reviewed_at": null, + "spec_path": ".flow/specs/fn-2.md", + "status": "open", + "title": "GitHunter Toolset Completion", + "updated_at": "2026-01-24T16:19:49.637145Z" +} diff --git a/.flow/epics/fn-3.json b/.flow/epics/fn-3.json new file mode 100644 index 0000000..da5938b --- /dev/null +++ b/.flow/epics/fn-3.json @@ -0,0 +1,13 @@ +{ + "branch_name": "fn-3", + "created_at": "2026-01-24T16:21:18.066966Z", + "depends_on_epics": [], + "id": "fn-3", + "next_task": 1, + "plan_review_status": "unknown", + "plan_reviewed_at": null, + "spec_path": ".flow/specs/fn-3.md", + "status": "open", + "title": "Forensic Features: Trace Persistence and Replay", + "updated_at": "2026-01-24T16:22:03.691848Z" +} diff --git a/.flow/specs/fn-2.md b/.flow/specs/fn-2.md new file mode 100644 index 0000000..be73fad --- /dev/null +++ b/.flow/specs/fn-2.md @@ -0,0 +1,87 @@ +# GitHunter Toolset Completion + +## Overview + +Complete the GitHunter toolset by adding `tools.py` that wraps `GitHunterAdapter` methods as PydanticAI `Tool` objects. This follows the existing patterns established in `memory/tools.py` and `schema/tools.py`. + +## Scope + +### In Scope +- Create request/response Pydantic models for tool inputs +- Wrap 3 adapter methods as PydanticAI tools: `blame_line`, `find_pr_discussion`, `get_expert_for_file` +- Export toolset as `githunter_toolset: list[Tool[GitHunterProtocol]]` +- Add comprehensive tests following existing test patterns +- Update `__init__.py` exports +- Add documentation to API reference + +### Out of Scope +- GitLab/Bitbucket support (GitHub only) +- `enrich_author` as a separate tool (internal use only) +- Caching layer for repeated calls +- New adapter functionality + +## Approach + +Follow the established toolset pattern: + +1. **Request Models** (`_models.py`): Create Pydantic models for tool inputs + - `BlameLineRequest(repo_path: str, file_path: str, line_no: int)` + - `FindPRDiscussionRequest(repo_path: str, commit_hash: str)` + - `GetExpertsRequest(repo_path: str, file_path: str, window_days: int = 90, limit: int = 3)` + +2. **Tool Functions** (`tools.py`): Async functions with `RunContext[GitHunterProtocol]` + - Convert string repo_path to Path internally + - Catch adapter exceptions and convert to Error model returns + - Include "Agent Usage" docstrings + +3. **Toolset Export**: `githunter_toolset: list[Tool[GitHunterProtocol]]` + +## Key Files + +| File | Purpose | +|------|---------| +| `src/bond/tools/githunter/_models.py` | NEW - Request models | +| `src/bond/tools/githunter/tools.py` | NEW - Tool functions + toolset export | +| `src/bond/tools/githunter/__init__.py` | Update exports | +| `tests/unit/tools/githunter/test_tools.py` | NEW - Tool tests | +| `docs/api/tools.md` | Update API docs | + +## Reuse Points + +- **Pattern**: `src/bond/tools/memory/tools.py` (lines 45-144) - Tool function structure +- **Pattern**: `src/bond/tools/schema/tools.py` - Simpler toolset example +- **Error model**: `src/bond/tools/memory/_models.py:177-187` - Error return type +- **Test pattern**: `tests/unit/tools/schema/test_tools.py` - Mock protocol pattern +- **Protocol**: `src/bond/tools/githunter/_protocols.py` - Already complete +- **Adapter**: `src/bond/tools/githunter/_adapter.py` - Already complete + +## Quick Commands + +```bash +# Run GitHunter tests +uv run pytest tests/unit/tools/githunter/ -v + +# Type check +uv run mypy src/bond/tools/githunter/ + +# Lint +uv run ruff check src/bond/tools/githunter/ +``` + +## Acceptance + +- [ ] `_models.py` contains 3 request models with validation +- [ ] `tools.py` exports `githunter_toolset` with 3 tools +- [ ] All tools handle adapter exceptions gracefully (return Error, don't raise) +- [ ] Tests pass with MockGitHunter protocol implementation +- [ ] `mypy` and `ruff` pass without errors +- [ ] API docs updated with GitHunter toolset section +- [ ] Exports available: `from bond.tools.githunter import githunter_toolset` + +## References + +- Memory toolset pattern: `src/bond/tools/memory/tools.py` +- Schema toolset pattern: `src/bond/tools/schema/tools.py` +- GitHunter protocol: `src/bond/tools/githunter/_protocols.py:14-91` +- GitHunter adapter: `src/bond/tools/githunter/_adapter.py` +- PydanticAI Tool docs: https://ai.pydantic.dev/tools/ diff --git a/.flow/specs/fn-3.md b/.flow/specs/fn-3.md new file mode 100644 index 0000000..64e6e30 --- /dev/null +++ b/.flow/specs/fn-3.md @@ -0,0 +1,174 @@ +# Forensic Features: Trace Persistence and Replay + +## Overview + +Extend Bond's "Forensic Runtime" capabilities beyond real-time streaming to include trace persistence and replay. This enables: +- **Audit**: Review what an agent did hours/days ago +- **Debug**: Replay failed runs step-by-step +- **Compare**: Analyze different executions side-by-side + +## Scope + +### In Scope +- **Trace Capture**: Record all 8 StreamHandlers callback events with metadata +- **Storage Backend**: Pluggable backend interface with JSON file implementation +- **Replay API**: SDK method to iterate through stored events +- **Handler Factory**: `create_capture_handlers()` for easy capture setup + +### Out of Scope (Future) +- Protobuf serialization (start with JSON for debugging) +- Remote storage backends (S3, database) +- Cross-trace querying and analytics +- Real-time trace streaming to external systems +- Automatic cleanup/retention policies +- UI replay interface (API only in this phase) + +## Approach + +### Phase 1: Event Model + +Define a unified event structure that normalizes all 8 callback types: + +```python +@dataclass(frozen=True) +class TraceEvent: + trace_id: str # UUID for this trace + sequence: int # Ordering within trace + timestamp: float # time.monotonic() for ordering + wall_time: datetime # Human-readable timestamp + event_type: str # "block_start", "text_delta", etc. + payload: dict[str, Any] # Event-specific data +``` + +Event types map to StreamHandlers: +| Callback | event_type | payload keys | +|----------|------------|--------------| +| on_block_start | "block_start" | kind, index | +| on_block_end | "block_end" | kind, index | +| on_text_delta | "text_delta" | text | +| on_thinking_delta | "thinking_delta" | text | +| on_tool_call_delta | "tool_call_delta" | name, args | +| on_tool_execute | "tool_execute" | id, name, args | +| on_tool_result | "tool_result" | id, name, result | +| on_complete | "complete" | data | + +### Phase 2: Storage Backend Protocol + +```python +@runtime_checkable +class TraceStorageProtocol(Protocol): + async def save_event(self, event: TraceEvent) -> None: + """Append event to trace.""" + ... + + async def finalize_trace(self, trace_id: str) -> None: + """Mark trace as complete.""" + ... + + async def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: + """Load events for replay.""" + ... + + async def list_traces(self, limit: int = 100) -> list[TraceMeta]: + """List available traces.""" + ... +``` + +Initial implementation: `JSONFileTraceStore` writing to `.bond/traces/{trace_id}.json` + +### Phase 3: Capture Handler Factory + +```python +def create_capture_handlers( + storage: TraceStorageProtocol, + trace_id: str | None = None, # Auto-generate if None +) -> tuple[StreamHandlers, str]: + """Create handlers that capture events to storage. + + Returns: + (handlers, trace_id) - handlers for agent.ask(), and trace ID for later replay + """ +``` + +### Phase 4: Replay API + +```python +class TraceReplayer: + def __init__(self, storage: TraceStorageProtocol, trace_id: str): + ... + + async def __aiter__(self) -> AsyncIterator[TraceEvent]: + """Iterate through all events.""" + ... + + async def step(self) -> TraceEvent | None: + """Get next event (for manual stepping).""" + ... + + @property + def current_position(self) -> int: + """Current event index.""" + ... +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `src/bond/trace/__init__.py` | NEW - Module exports | +| `src/bond/trace/_models.py` | NEW - TraceEvent, TraceMeta models | +| `src/bond/trace/_protocols.py` | NEW - TraceStorageProtocol | +| `src/bond/trace/backends/json_file.py` | NEW - JSON file storage | +| `src/bond/trace/capture.py` | NEW - create_capture_handlers | +| `src/bond/trace/replay.py` | NEW - TraceReplayer class | +| `src/bond/utils.py` | UPDATE - Add capture handler factory | +| `tests/unit/trace/` | NEW - Test directory | + +## Reuse Points + +- **Event structure**: Inspired by `create_websocket_handlers()` JSON format (`src/bond/utils.py:34-86`) +- **Protocol pattern**: Follow `src/bond/tools/memory/_protocols.py` style +- **Storage pattern**: Similar to `AgentMemoryProtocol` but for events + +## Quick Commands + +```bash +# Run trace tests +uv run pytest tests/unit/trace/ -v + +# Type check +uv run mypy src/bond/trace/ + +# Example usage (after implementation) +python -c " +from bond.trace import JSONFileTraceStore, create_capture_handlers, TraceReplayer +store = JSONFileTraceStore() +handlers, trace_id = create_capture_handlers(store) +print(f'Trace ID: {trace_id}') +" +``` + +## Acceptance + +- [ ] `TraceEvent` model captures all 8 callback types +- [ ] `TraceStorageProtocol` defines storage interface +- [ ] `JSONFileTraceStore` implements protocol with file-based storage +- [ ] `create_capture_handlers()` returns working StreamHandlers +- [ ] `TraceReplayer` can iterate through stored traces +- [ ] All tests pass with >80% coverage on trace module +- [ ] `mypy` and `ruff` pass +- [ ] Documentation added to architecture page + +## Open Questions + +1. **Trace directory**: Use `.bond/traces/` or configurable path? +2. **Large tool results**: Truncate at what size? 1MB? 10MB? +3. **Crash handling**: How to mark incomplete traces? Separate "status" field? +4. **Event ordering**: Use monotonic clock + sequence number for guaranteed order? + +## References + +- WebSocket handler pattern: `src/bond/utils.py:20-118` +- StreamHandlers dataclass: `src/bond/agent.py:28-73` +- Event sourcing StoredEvent: https://eventsourcing.readthedocs.io/ +- OTel trace format: https://opentelemetry.io/docs/specs/semconv/gen-ai/ diff --git a/.flow/tasks/fn-2.1.json b/.flow/tasks/fn-2.1.json new file mode 100644 index 0000000..9d0783b --- /dev/null +++ b/.flow/tasks/fn-2.1.json @@ -0,0 +1,21 @@ +{ + "assignee": "bordumbb@gmail.com", + "claim_note": "", + "claimed_at": "2026-01-24T16:43:07.860701Z", + "created_at": "2026-01-24T16:19:55.586842Z", + "depends_on": [], + "epic": "fn-2", + "evidence": { + "commits": [ + "cdc4a2f" + ], + "prs": [], + "tests": [] + }, + "id": "fn-2.1", + "priority": null, + "spec_path": ".flow/tasks/fn-2.1.md", + "status": "done", + "title": "Create GitHunter request models", + "updated_at": "2026-01-24T16:45:23.392733Z" +} diff --git a/.flow/tasks/fn-2.1.md b/.flow/tasks/fn-2.1.md new file mode 100644 index 0000000..b98548c --- /dev/null +++ b/.flow/tasks/fn-2.1.md @@ -0,0 +1,55 @@ +# fn-2.1 Create GitHunter request models + +## Description +Create Pydantic request models for GitHunter tools in `src/bond/tools/githunter/_models.py`. + +### Models to Create + +```python +class BlameLineRequest(BaseModel): + """Request for blame_line tool.""" + repo_path: str # String, converted to Path in tool + file_path: str + line_no: int = Field(ge=1, description="Line number (1-indexed)") + +class FindPRDiscussionRequest(BaseModel): + """Request for find_pr_discussion tool.""" + repo_path: str + commit_hash: str = Field(min_length=7, description="Full or abbreviated SHA") + +class GetExpertsRequest(BaseModel): + """Request for get_expert_for_file tool.""" + repo_path: str + file_path: str + window_days: int = Field(default=90, ge=0, description="Days of history (0=all time)") + limit: int = Field(default=3, ge=1, le=10, description="Max experts to return") +``` + +### Also Add + +- `Error` model following `memory/_models.py:177-187` pattern +- Union types for return values: `BlameResult | Error`, etc. + +### Reference Files + +- Pattern: `src/bond/tools/memory/_models.py` +- Types: `src/bond/tools/githunter/_types.py` (BlameResult, PRDiscussion, FileExpert) +## Acceptance +- [ ] `_models.py` exists with BlameLineRequest, FindPRDiscussionRequest, GetExpertsRequest +- [ ] All models have Field validators (ge, min_length, etc.) +- [ ] Error model exists for union return types +- [ ] `mypy src/bond/tools/githunter/_models.py` passes +- [ ] `ruff check src/bond/tools/githunter/_models.py` passes +## Done summary +Created _models.py with GitHunter request models: +- BlameLineRequest (repo_path, file_path, line_no with ge=1 validator) +- FindPRDiscussionRequest (repo_path, commit_hash with min_length=7 validator) +- GetExpertsRequest (repo_path, file_path, window_days=90 default, limit=3 default) +- Error model for union return types in tool responses + +All models follow the Annotated[..., Field(...)] pattern from memory/_models.py. +Passed mypy and ruff checks. +## Evidence +- Commits: cdc4a2f +- Tests: +- PRs: \ No newline at end of file diff --git a/.flow/tasks/fn-2.2.json b/.flow/tasks/fn-2.2.json new file mode 100644 index 0000000..6334803 --- /dev/null +++ b/.flow/tasks/fn-2.2.json @@ -0,0 +1,16 @@ +{ + "assignee": null, + "claim_note": "", + "claimed_at": null, + "created_at": "2026-01-24T16:20:03.882583Z", + "depends_on": [ + "fn-2.1" + ], + "epic": "fn-2", + "id": "fn-2.2", + "priority": null, + "spec_path": ".flow/tasks/fn-2.2.md", + "status": "todo", + "title": "Implement GitHunter tool functions", + "updated_at": "2026-01-24T16:21:09.575956Z" +} diff --git a/.flow/tasks/fn-2.2.md b/.flow/tasks/fn-2.2.md new file mode 100644 index 0000000..a9200c9 --- /dev/null +++ b/.flow/tasks/fn-2.2.md @@ -0,0 +1,70 @@ +# fn-2.2 Implement GitHunter tool functions + +## Description +Create tool functions in `src/bond/tools/githunter/tools.py` wrapping adapter methods. + +### Tools to Implement + +```python +async def blame_line( + ctx: RunContext[GitHunterProtocol], + request: BlameLineRequest, +) -> BlameResult | Error: + """Get blame information for a specific line. + + Agent Usage: Call when you need to know who last modified a line of code, + what commit changed it, and when. + """ + try: + return await ctx.deps.blame_line( + repo_path=Path(request.repo_path), + file_path=request.file_path, + line_no=request.line_no, + ) + except GitHunterError as e: + return Error(message=str(e)) + +async def find_pr_discussion( + ctx: RunContext[GitHunterProtocol], + request: FindPRDiscussionRequest, +) -> PRDiscussion | None | Error: + """Find the PR discussion for a commit.""" + ... + +async def get_file_experts( + ctx: RunContext[GitHunterProtocol], + request: GetExpertsRequest, +) -> list[FileExpert] | Error: + """Get experts for a file based on commit frequency.""" + ... +``` + +### Export + +```python +githunter_toolset: list[Tool[GitHunterProtocol]] = [ + Tool(blame_line), + Tool(find_pr_discussion), + Tool(get_file_experts), +] +``` + +### Reference Files + +- Pattern: `src/bond/tools/memory/tools.py:45-144` +- Protocol: `src/bond/tools/githunter/_protocols.py` +- Exceptions: `src/bond/tools/githunter/_exceptions.py` +## Acceptance +- [ ] `tools.py` contains 3 async tool functions +- [ ] All tools use `RunContext[GitHunterProtocol]` dependency injection +- [ ] All tools catch `GitHunterError` and return `Error` model +- [ ] All tools have "Agent Usage" in docstrings +- [ ] `githunter_toolset` list is exported +- [ ] `mypy` and `ruff` pass on `tools.py` +## Done summary +TBD + +## Evidence +- Commits: +- Tests: +- PRs: diff --git a/.flow/tasks/fn-2.3.json b/.flow/tasks/fn-2.3.json new file mode 100644 index 0000000..05e8043 --- /dev/null +++ b/.flow/tasks/fn-2.3.json @@ -0,0 +1,16 @@ +{ + "assignee": null, + "claim_note": "", + "claimed_at": null, + "created_at": "2026-01-24T16:20:04.087789Z", + "depends_on": [ + "fn-2.2" + ], + "epic": "fn-2", + "id": "fn-2.3", + "priority": null, + "spec_path": ".flow/tasks/fn-2.3.md", + "status": "todo", + "title": "Add GitHunter toolset tests", + "updated_at": "2026-01-24T16:21:09.983177Z" +} diff --git a/.flow/tasks/fn-2.3.md b/.flow/tasks/fn-2.3.md new file mode 100644 index 0000000..e5f4cda --- /dev/null +++ b/.flow/tasks/fn-2.3.md @@ -0,0 +1,60 @@ +# fn-2.3 Add GitHunter toolset tests + +## Description +Add tests for GitHunter tools in `tests/unit/tools/githunter/test_tools.py`. + +### Test Structure + +```python +class MockGitHunter: + """Mock implementation of GitHunterProtocol for testing.""" + + async def blame_line(self, repo_path, file_path, line_no): + return BlameResult(...) + + async def find_pr_discussion(self, repo_path, commit_hash): + return PRDiscussion(...) or None + + async def get_expert_for_file(self, repo_path, file_path, window_days, limit): + return [FileExpert(...)] + +@pytest.fixture +def mock_hunter(): + return MockGitHunter() + +@pytest.fixture +def run_context(mock_hunter): + # Create RunContext with mock deps + ... + +class TestBlameLine: + async def test_returns_blame_result(self, run_context): + ... + + async def test_handles_error(self, run_context): + ... +``` + +### Test Cases + +1. **blame_line**: Success case, FileNotFoundError, LineOutOfRangeError +2. **find_pr_discussion**: Success case, None case (no PR), RateLimitedError +3. **get_file_experts**: Success case, empty list, RepoNotFoundError + +### Reference Files + +- Pattern: `tests/unit/tools/schema/test_tools.py` +- Mock pattern: `tests/unit/tools/memory/test_backends.py` +## Acceptance +- [ ] `test_tools.py` exists with MockGitHunter class +- [ ] Tests cover success cases for all 3 tools +- [ ] Tests cover error handling (GitHunterError → Error) +- [ ] `uv run pytest tests/unit/tools/githunter/test_tools.py -v` passes +- [ ] All existing GitHunter tests still pass +## Done summary +TBD + +## Evidence +- Commits: +- Tests: +- PRs: diff --git a/.flow/tasks/fn-2.4.json b/.flow/tasks/fn-2.4.json new file mode 100644 index 0000000..d3086fd --- /dev/null +++ b/.flow/tasks/fn-2.4.json @@ -0,0 +1,16 @@ +{ + "assignee": null, + "claim_note": "", + "claimed_at": null, + "created_at": "2026-01-24T16:20:04.284409Z", + "depends_on": [ + "fn-2.3" + ], + "epic": "fn-2", + "id": "fn-2.4", + "priority": null, + "spec_path": ".flow/tasks/fn-2.4.md", + "status": "todo", + "title": "Update exports and documentation", + "updated_at": "2026-01-24T16:21:10.379793Z" +} diff --git a/.flow/tasks/fn-2.4.md b/.flow/tasks/fn-2.4.md new file mode 100644 index 0000000..f8c2f95 --- /dev/null +++ b/.flow/tasks/fn-2.4.md @@ -0,0 +1,63 @@ +# fn-2.4 Update exports and documentation + +## Description +Update exports and API documentation. + +### Update `__init__.py` + +Add to `src/bond/tools/githunter/__init__.py`: + +```python +from .tools import githunter_toolset +from ._models import BlameLineRequest, FindPRDiscussionRequest, GetExpertsRequest, Error + +__all__ = [ + # Existing exports... + # Add: + "githunter_toolset", + "BlameLineRequest", + "FindPRDiscussionRequest", + "GetExpertsRequest", + "Error", +] +``` + +### Update API Docs + +Add GitHunter section to `docs/api/tools.md`: + +```markdown +## GitHunter Toolset + +::: bond.tools.githunter.githunter_toolset + options: + show_source: true + +### Request Models + +::: bond.tools.githunter.BlameLineRequest + +::: bond.tools.githunter.FindPRDiscussionRequest + +::: bond.tools.githunter.GetExpertsRequest +``` + +### Verify Import + +Test that this works: +```python +from bond.tools.githunter import githunter_toolset, BlameLineRequest +``` +## Acceptance +- [ ] `__init__.py` exports `githunter_toolset` and request models +- [ ] `from bond.tools.githunter import githunter_toolset` works +- [ ] `docs/api/tools.md` has GitHunter section +- [ ] `mkdocs build --strict` passes +- [ ] All tests pass: `uv run pytest tests/unit/tools/githunter/ -v` +## Done summary +TBD + +## Evidence +- Commits: +- Tests: +- PRs: diff --git a/.flow/tasks/fn-3.1.json b/.flow/tasks/fn-3.1.json new file mode 100644 index 0000000..95ce12e --- /dev/null +++ b/.flow/tasks/fn-3.1.json @@ -0,0 +1,14 @@ +{ + "assignee": null, + "claim_note": "", + "claimed_at": null, + "created_at": "2026-01-24T16:22:12.408953Z", + "depends_on": [], + "epic": "fn-3", + "id": "fn-3.1", + "priority": null, + "spec_path": ".flow/tasks/fn-3.1.md", + "status": "todo", + "title": "Create trace event models and protocol", + "updated_at": "2026-01-24T16:23:34.680004Z" +} diff --git a/.flow/tasks/fn-3.1.md b/.flow/tasks/fn-3.1.md new file mode 100644 index 0000000..f7cdc49 --- /dev/null +++ b/.flow/tasks/fn-3.1.md @@ -0,0 +1,77 @@ +# fn-3.1 Create trace event models and protocol + +## Description +Create trace event models and storage protocol in `src/bond/trace/`. + +### Create Module Structure + +``` +src/bond/trace/ +├── __init__.py +├── _models.py # TraceEvent, TraceMeta +└── _protocols.py # TraceStorageProtocol +``` + +### TraceEvent Model + +```python +@dataclass(frozen=True) +class TraceEvent: + trace_id: str # UUID + sequence: int # Order within trace (0-indexed) + timestamp: float # time.monotonic() for relative ordering + wall_time: datetime # ISO timestamp for display + event_type: str # One of the 8 callback types + payload: dict[str, Any] # Event-specific data + + def to_dict(self) -> dict[str, Any]: + """Serialize for JSON storage.""" + ... + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TraceEvent: + """Deserialize from JSON.""" + ... +``` + +### TraceMeta Model + +```python +@dataclass(frozen=True) +class TraceMeta: + trace_id: str + created_at: datetime + event_count: int + status: str # "in_progress", "complete", "failed" +``` + +### TraceStorageProtocol + +```python +@runtime_checkable +class TraceStorageProtocol(Protocol): + async def save_event(self, event: TraceEvent) -> None: ... + async def finalize_trace(self, trace_id: str, status: str = "complete") -> None: ... + async def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: ... + async def list_traces(self, limit: int = 100) -> list[TraceMeta]: ... + async def delete_trace(self, trace_id: str) -> None: ... +``` + +### Reference + +- StreamHandlers callbacks: `src/bond/agent.py:28-73` +- Memory protocol pattern: `src/bond/tools/memory/_protocols.py` +## Acceptance +- [ ] `src/bond/trace/_models.py` contains TraceEvent and TraceMeta +- [ ] TraceEvent has to_dict/from_dict for JSON serialization +- [ ] event_type covers all 8 StreamHandler callbacks +- [ ] `src/bond/trace/_protocols.py` defines TraceStorageProtocol +- [ ] Protocol is @runtime_checkable +- [ ] `mypy src/bond/trace/` passes +## Done summary +TBD + +## Evidence +- Commits: +- Tests: +- PRs: diff --git a/.flow/tasks/fn-3.2.json b/.flow/tasks/fn-3.2.json new file mode 100644 index 0000000..d5e984b --- /dev/null +++ b/.flow/tasks/fn-3.2.json @@ -0,0 +1,16 @@ +{ + "assignee": null, + "claim_note": "", + "claimed_at": null, + "created_at": "2026-01-24T16:22:12.603302Z", + "depends_on": [ + "fn-3.1" + ], + "epic": "fn-3", + "id": "fn-3.2", + "priority": null, + "spec_path": ".flow/tasks/fn-3.2.md", + "status": "todo", + "title": "Implement JSON file storage backend", + "updated_at": "2026-01-24T16:23:35.074202Z" +} diff --git a/.flow/tasks/fn-3.2.md b/.flow/tasks/fn-3.2.md new file mode 100644 index 0000000..8970570 --- /dev/null +++ b/.flow/tasks/fn-3.2.md @@ -0,0 +1,80 @@ +# fn-3.2 Implement JSON file storage backend + +## Description +Implement JSON file storage backend in `src/bond/trace/backends/`. + +### File Structure + +``` +src/bond/trace/backends/ +├── __init__.py +└── json_file.py +``` + +### JSONFileTraceStore + +```python +class JSONFileTraceStore: + """Store traces as JSON files in a directory. + + Structure: + {base_path}/ + ├── {trace_id}.json # Events (one JSON object per line) + └── {trace_id}.meta.json # TraceMeta + """ + + def __init__(self, base_path: Path | str = ".bond/traces"): + self.base_path = Path(base_path) + self.base_path.mkdir(parents=True, exist_ok=True) + + async def save_event(self, event: TraceEvent) -> None: + """Append event to trace file (newline-delimited JSON).""" + path = self.base_path / f"{event.trace_id}.json" + async with aiofiles.open(path, "a") as f: + await f.write(json.dumps(event.to_dict()) + "\n") + + async def finalize_trace(self, trace_id: str, status: str = "complete") -> None: + """Write meta file marking trace complete.""" + ... + + async def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: + """Read events from trace file.""" + path = self.base_path / f"{trace_id}.json" + async with aiofiles.open(path, "r") as f: + async for line in f: + yield TraceEvent.from_dict(json.loads(line)) + + async def list_traces(self, limit: int = 100) -> list[TraceMeta]: + """List traces sorted by creation time (newest first).""" + ... + + async def delete_trace(self, trace_id: str) -> None: + """Delete trace and meta files.""" + ... +``` + +### Dependencies + +Add to pyproject.toml: +```toml +"aiofiles>=24.0.0", +``` + +### Reference + +- Memory backend pattern: `src/bond/tools/memory/backends/` +## Acceptance +- [ ] `JSONFileTraceStore` implements `TraceStorageProtocol` +- [ ] Events stored as newline-delimited JSON +- [ ] Meta files track trace status and event count +- [ ] `list_traces` returns TraceMeta sorted by time +- [ ] `delete_trace` cleans up both files +- [ ] aiofiles added to dependencies +- [ ] `mypy` passes +## Done summary +TBD + +## Evidence +- Commits: +- Tests: +- PRs: diff --git a/.flow/tasks/fn-3.3.json b/.flow/tasks/fn-3.3.json new file mode 100644 index 0000000..86090cf --- /dev/null +++ b/.flow/tasks/fn-3.3.json @@ -0,0 +1,16 @@ +{ + "assignee": null, + "claim_note": "", + "claimed_at": null, + "created_at": "2026-01-24T16:22:12.788887Z", + "depends_on": [ + "fn-3.2" + ], + "epic": "fn-3", + "id": "fn-3.3", + "priority": null, + "spec_path": ".flow/tasks/fn-3.3.md", + "status": "todo", + "title": "Create capture handler factory", + "updated_at": "2026-01-24T16:23:35.463066Z" +} diff --git a/.flow/tasks/fn-3.3.md b/.flow/tasks/fn-3.3.md new file mode 100644 index 0000000..0acf4e6 --- /dev/null +++ b/.flow/tasks/fn-3.3.md @@ -0,0 +1,94 @@ +# fn-3.3 Create capture handler factory + +## Description +Create capture handler factory in `src/bond/trace/capture.py`. + +### Implementation + +```python +import time +import uuid +from datetime import datetime, UTC + +def create_capture_handlers( + storage: TraceStorageProtocol, + trace_id: str | None = None, +) -> tuple[StreamHandlers, str]: + """Create handlers that capture events to storage. + + Args: + storage: Backend to store events + trace_id: Optional trace ID (auto-generated if None) + + Returns: + (handlers, trace_id) - Use handlers with agent.ask(), keep trace_id for replay + + Example: + store = JSONFileTraceStore() + handlers, trace_id = create_capture_handlers(store) + await agent.ask("query", handlers=handlers) + # Later: replay with trace_id + """ + if trace_id is None: + trace_id = str(uuid.uuid4()) + + sequence = 0 + start_time = time.monotonic() + + def _record(event_type: str, payload: dict) -> None: + nonlocal sequence + event = TraceEvent( + trace_id=trace_id, + sequence=sequence, + timestamp=time.monotonic() - start_time, + wall_time=datetime.now(UTC), + event_type=event_type, + payload=payload, + ) + sequence += 1 + # Schedule async save from sync callback + asyncio.get_event_loop().create_task(storage.save_event(event)) + + return StreamHandlers( + on_block_start=lambda k, i: _record("block_start", {"kind": k, "index": i}), + on_block_end=lambda k, i: _record("block_end", {"kind": k, "index": i}), + on_text_delta=lambda t: _record("text_delta", {"text": t}), + on_thinking_delta=lambda t: _record("thinking_delta", {"text": t}), + on_tool_call_delta=lambda n, a: _record("tool_call_delta", {"name": n, "args": a}), + on_tool_execute=lambda i, n, a: _record("tool_execute", {"id": i, "name": n, "args": a}), + on_tool_result=lambda i, n, r: _record("tool_result", {"id": i, "name": n, "result": r}), + on_complete=lambda d: _record("complete", {"data": d}), + ), trace_id +``` + +### Also Add + +Helper to finalize trace after agent completes: + +```python +async def finalize_capture( + storage: TraceStorageProtocol, + trace_id: str, + status: str = "complete", +) -> None: + """Mark trace as complete after agent.ask() returns.""" + await storage.finalize_trace(trace_id, status) +``` + +### Reference + +- WebSocket handler pattern: `src/bond/utils.py:20-118` +## Acceptance +- [ ] `create_capture_handlers` returns (StreamHandlers, trace_id) +- [ ] All 8 callbacks are wired to record events +- [ ] Events include sequence number for ordering +- [ ] `finalize_capture` helper exists +- [ ] Works with async agent.ask() (sync callbacks schedule async saves) +- [ ] Integration test: capture → verify file exists with events +## Done summary +TBD + +## Evidence +- Commits: +- Tests: +- PRs: diff --git a/.flow/tasks/fn-3.4.json b/.flow/tasks/fn-3.4.json new file mode 100644 index 0000000..c2509c2 --- /dev/null +++ b/.flow/tasks/fn-3.4.json @@ -0,0 +1,16 @@ +{ + "assignee": null, + "claim_note": "", + "claimed_at": null, + "created_at": "2026-01-24T16:22:12.988299Z", + "depends_on": [ + "fn-3.2" + ], + "epic": "fn-3", + "id": "fn-3.4", + "priority": null, + "spec_path": ".flow/tasks/fn-3.4.md", + "status": "todo", + "title": "Implement trace replayer", + "updated_at": "2026-01-24T16:23:35.858593Z" +} diff --git a/.flow/tasks/fn-3.4.md b/.flow/tasks/fn-3.4.md new file mode 100644 index 0000000..0906ec8 --- /dev/null +++ b/.flow/tasks/fn-3.4.md @@ -0,0 +1,92 @@ +# fn-3.4 Implement trace replayer + +## Description +Implement trace replayer in `src/bond/trace/replay.py`. + +### Implementation + +```python +class TraceReplayer: + """Replay a stored trace event by event. + + Usage: + replayer = TraceReplayer(storage, trace_id) + + # Iterate all events + async for event in replayer: + print(f"{event.event_type}: {event.payload}") + + # Or step manually + replayer = TraceReplayer(storage, trace_id) + while event := await replayer.step(): + print(event) + await asyncio.sleep(0.1) # Simulate timing + """ + + def __init__(self, storage: TraceStorageProtocol, trace_id: str): + self.storage = storage + self.trace_id = trace_id + self._events: list[TraceEvent] | None = None + self._position: int = 0 + + async def _load(self) -> None: + """Load all events into memory for stepping.""" + if self._events is None: + self._events = [e async for e in self.storage.load_trace(self.trace_id)] + + async def __aiter__(self) -> AsyncIterator[TraceEvent]: + """Iterate through all events.""" + async for event in self.storage.load_trace(self.trace_id): + yield event + + async def step(self) -> TraceEvent | None: + """Get next event, or None if complete.""" + await self._load() + if self._position >= len(self._events): + return None + event = self._events[self._position] + self._position += 1 + return event + + async def step_back(self) -> TraceEvent | None: + """Go back one event.""" + await self._load() + if self._position <= 0: + return None + self._position -= 1 + return self._events[self._position] + + @property + def position(self) -> int: + """Current position in trace.""" + return self._position + + @property + def total_events(self) -> int | None: + """Total events (None if not loaded).""" + return len(self._events) if self._events else None + + async def seek(self, position: int) -> TraceEvent | None: + """Jump to specific position.""" + await self._load() + self._position = max(0, min(position, len(self._events))) + return self._events[self._position] if self._position < len(self._events) else None +``` + +### Reference + +- AsyncIterator pattern: standard library typing +## Acceptance +- [ ] `TraceReplayer` supports async iteration +- [ ] `step()` returns next event or None +- [ ] `step_back()` returns previous event +- [ ] `seek(position)` jumps to specific event +- [ ] `position` and `total_events` properties work +- [ ] Integration test: capture → replay → verify events match +## Done summary +TBD + +## Evidence +- Commits: +- Tests: +- PRs: diff --git a/.flow/tasks/fn-3.5.json b/.flow/tasks/fn-3.5.json new file mode 100644 index 0000000..43b95a3 --- /dev/null +++ b/.flow/tasks/fn-3.5.json @@ -0,0 +1,17 @@ +{ + "assignee": null, + "claim_note": "", + "claimed_at": null, + "created_at": "2026-01-24T16:22:13.179296Z", + "depends_on": [ + "fn-3.3", + "fn-3.4" + ], + "epic": "fn-3", + "id": "fn-3.5", + "priority": null, + "spec_path": ".flow/tasks/fn-3.5.md", + "status": "todo", + "title": "Add tests and documentation", + "updated_at": "2026-01-24T16:23:36.253790Z" +} diff --git a/.flow/tasks/fn-3.5.md b/.flow/tasks/fn-3.5.md new file mode 100644 index 0000000..4e34e8c --- /dev/null +++ b/.flow/tasks/fn-3.5.md @@ -0,0 +1,87 @@ +# fn-3.5 Add tests and documentation + +## Description +Add comprehensive tests and update documentation. + +### Tests to Add + +``` +tests/unit/trace/ +├── __init__.py +├── test_models.py # TraceEvent serialization +├── test_json_store.py # JSONFileTraceStore +├── test_capture.py # create_capture_handlers +└── test_replay.py # TraceReplayer +``` + +### Test Cases + +**test_models.py**: +- TraceEvent to_dict/from_dict roundtrip +- All 8 event types serialize correctly +- TraceMeta creation + +**test_json_store.py**: +- save_event appends to file +- load_trace returns events in order +- finalize_trace creates meta file +- list_traces returns sorted results +- delete_trace removes files + +**test_capture.py**: +- create_capture_handlers returns handlers + trace_id +- Captured events have correct sequence numbers +- Events persisted to storage + +**test_replay.py**: +- Iteration yields all events +- step() advances position +- step_back() moves backward +- seek() jumps to position + +### Documentation Updates + +1. Add to `docs/architecture.md`: + - New "Trace Persistence" section + - "Replay" section with examples + +2. Update `docs/api/index.md`: + - Add trace module link + +3. Create `docs/api/trace.md`: + - Module overview + - TraceEvent reference + - TraceStorageProtocol reference + - JSONFileTraceStore reference + - create_capture_handlers reference + - TraceReplayer reference + +### Exports + +Update `src/bond/__init__.py`: +```python +from bond.trace import ( + TraceEvent, + TraceMeta, + TraceStorageProtocol, + JSONFileTraceStore, + create_capture_handlers, + finalize_capture, + TraceReplayer, +) +``` +## Acceptance +- [ ] All test files created with >80% coverage +- [ ] `uv run pytest tests/unit/trace/ -v` passes +- [ ] `docs/api/trace.md` documents all public APIs +- [ ] `docs/architecture.md` has Trace Persistence section +- [ ] Main `__init__.py` exports trace module classes +- [ ] `from bond import create_capture_handlers, TraceReplayer` works +- [ ] `mkdocs build --strict` passes +## Done summary +TBD + +## Evidence +- Commits: +- Tests: +- PRs: diff --git a/src/bond/tools/githunter/_models.py b/src/bond/tools/githunter/_models.py new file mode 100644 index 0000000..43652ef --- /dev/null +++ b/src/bond/tools/githunter/_models.py @@ -0,0 +1,89 @@ +"""GitHunter request and error models. + +Pydantic models for GitHunter tool inputs and error responses. +""" + +from typing import Annotated + +from pydantic import BaseModel, Field + + +class BlameLineRequest(BaseModel): + """Request to get blame information for a specific line. + + Agent Usage: Use this when you need to know who last modified a specific + line of code, what commit changed it, and when. + """ + + repo_path: Annotated[ + str, + Field(description="Path to the git repository root"), + ] + + file_path: Annotated[ + str, + Field(description="Path to file relative to repo root"), + ] + + line_no: Annotated[ + int, + Field(ge=1, description="Line number to blame (1-indexed)"), + ] + + +class FindPRDiscussionRequest(BaseModel): + """Request to find PR discussion for a commit. + + Agent Usage: Use this when you have a commit hash and want to find + the pull request discussion that introduced it. + """ + + repo_path: Annotated[ + str, + Field(description="Path to the git repository root"), + ] + + commit_hash: Annotated[ + str, + Field(min_length=7, description="Full or abbreviated commit SHA"), + ] + + +class GetExpertsRequest(BaseModel): + """Request to get file experts based on commit frequency. + + Agent Usage: Use this when you need to identify who has the most + knowledge about a file based on their commit history. + """ + + repo_path: Annotated[ + str, + Field(description="Path to the git repository root"), + ] + + file_path: Annotated[ + str, + Field(description="Path to file relative to repo root"), + ] + + window_days: Annotated[ + int, + Field(default=90, ge=0, description="Days of history to consider (0=all time)"), + ] + + limit: Annotated[ + int, + Field(default=3, ge=1, le=10, description="Maximum number of experts to return"), + ] + + +class Error(BaseModel): + """Error response from GitHunter operations. + + Used as union return type: `BlameResult | Error`, `PRDiscussion | None | Error`, etc. + """ + + description: Annotated[ + str, + Field(description="Error message explaining what went wrong"), + ] From 2454ccf379998e8524065a55a5170cf574bb3dac Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 16:46:28 +0000 Subject: [PATCH 02/13] feat: implement GitHunter tool functions Add tools.py with PydanticAI tool wrappers for GitHunter: - blame_line: Get blame info for a specific line - find_pr_discussion: Find PR discussion for a commit - get_file_experts: Get file experts by commit frequency All tools use RunContext[GitHunterProtocol] for dependency injection and catch GitHunterError to return Error model. Exports githunter_toolset for BondAgent integration. Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-2.2.json | 15 +++- .flow/tasks/fn-2.2.md | 16 +++- src/bond/tools/githunter/tools.py | 132 ++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 src/bond/tools/githunter/tools.py diff --git a/.flow/tasks/fn-2.2.json b/.flow/tasks/fn-2.2.json index 6334803..62e4219 100644 --- a/.flow/tasks/fn-2.2.json +++ b/.flow/tasks/fn-2.2.json @@ -1,16 +1,23 @@ { - "assignee": null, + "assignee": "bordumbb@gmail.com", "claim_note": "", - "claimed_at": null, + "claimed_at": "2026-01-24T16:45:49.269695Z", "created_at": "2026-01-24T16:20:03.882583Z", "depends_on": [ "fn-2.1" ], "epic": "fn-2", + "evidence": { + "commits": [ + "2d87f6a" + ], + "prs": [], + "tests": [] + }, "id": "fn-2.2", "priority": null, "spec_path": ".flow/tasks/fn-2.2.md", - "status": "todo", + "status": "done", "title": "Implement GitHunter tool functions", - "updated_at": "2026-01-24T16:21:09.575956Z" + "updated_at": "2026-01-24T16:46:36.650352Z" } diff --git a/.flow/tasks/fn-2.2.md b/.flow/tasks/fn-2.2.md index a9200c9..d913b75 100644 --- a/.flow/tasks/fn-2.2.md +++ b/.flow/tasks/fn-2.2.md @@ -62,9 +62,19 @@ githunter_toolset: list[Tool[GitHunterProtocol]] = [ - [ ] `githunter_toolset` list is exported - [ ] `mypy` and `ruff` pass on `tools.py` ## Done summary -TBD +Created tools.py with 3 PydanticAI tool functions: +- blame_line: Gets blame info (author, commit, date) for a line +- find_pr_discussion: Finds PR discussion for a commit hash +- get_file_experts: Gets file experts by commit frequency +All tools: +- Use RunContext[GitHunterProtocol] for dependency injection +- Catch GitHunterError and return Error model +- Have detailed "Agent Usage" docstrings with examples + +Exports githunter_toolset list for BondAgent integration. +Passed mypy and ruff checks. ## Evidence -- Commits: +- Commits: 2d87f6a - Tests: -- PRs: +- PRs: \ No newline at end of file diff --git a/src/bond/tools/githunter/tools.py b/src/bond/tools/githunter/tools.py new file mode 100644 index 0000000..fdea7c4 --- /dev/null +++ b/src/bond/tools/githunter/tools.py @@ -0,0 +1,132 @@ +"""GitHunter tools for PydanticAI agents. + +This module provides the agent-facing tool functions that use +RunContext to access the GitHunter adapter via dependency injection. +""" + +from pathlib import Path + +from pydantic_ai import RunContext +from pydantic_ai.tools import Tool + +from bond.tools.githunter._exceptions import GitHunterError +from bond.tools.githunter._models import ( + BlameLineRequest, + Error, + FindPRDiscussionRequest, + GetExpertsRequest, +) +from bond.tools.githunter._protocols import GitHunterProtocol +from bond.tools.githunter._types import BlameResult, FileExpert, PRDiscussion + + +async def blame_line( + ctx: RunContext[GitHunterProtocol], + request: BlameLineRequest, +) -> BlameResult | Error: + """Get blame information for a specific line. + + Agent Usage: + Call this tool when you need to know who last modified a specific + line of code, what commit changed it, and when: + - "Who wrote this line?" → blame_line with the file and line number + - "When was this changed?" → check commit_date in result + - "What was the commit message?" → check commit_message in result + + Example: + blame_line({ + "repo_path": "/path/to/repo", + "file_path": "src/main.py", + "line_no": 42 + }) + + Returns: + BlameResult with author, commit hash, date, and message, + or Error if the operation failed. + """ + try: + return await ctx.deps.blame_line( + repo_path=Path(request.repo_path), + file_path=request.file_path, + line_no=request.line_no, + ) + except GitHunterError as e: + return Error(description=str(e)) + + +async def find_pr_discussion( + ctx: RunContext[GitHunterProtocol], + request: FindPRDiscussionRequest, +) -> PRDiscussion | None | Error: + """Find the PR discussion for a commit. + + Agent Usage: + Call this tool when you have a commit hash and want to find + the pull request discussion that introduced it: + - "What PR introduced this commit?" → find_pr_discussion + - "What was discussed when this was merged?" → check PR comments + - "Why was this change made?" → read PR description and comments + + Example: + find_pr_discussion({ + "repo_path": "/path/to/repo", + "commit_hash": "abc123def" + }) + + Returns: + PRDiscussion with PR number, title, body, and comments, + None if no PR is associated with the commit, + or Error if the operation failed. + """ + try: + return await ctx.deps.find_pr_discussion( + repo_path=Path(request.repo_path), + commit_hash=request.commit_hash, + ) + except GitHunterError as e: + return Error(description=str(e)) + + +async def get_file_experts( + ctx: RunContext[GitHunterProtocol], + request: GetExpertsRequest, +) -> list[FileExpert] | Error: + """Get experts for a file based on commit frequency. + + Agent Usage: + Call this tool when you need to identify who has the most + knowledge about a file based on their commit history: + - "Who knows this file best?" → get_file_experts + - "Who should review changes to this?" → check top experts + - "Who to ask about this code?" → contact the experts + + Example: + get_file_experts({ + "repo_path": "/path/to/repo", + "file_path": "src/auth/login.py", + "window_days": 90, + "limit": 3 + }) + + Returns: + List of FileExpert sorted by commit count (descending), + containing author info, commit count, and last commit date, + or Error if the operation failed. + """ + try: + return await ctx.deps.get_expert_for_file( + repo_path=Path(request.repo_path), + file_path=request.file_path, + window_days=request.window_days, + limit=request.limit, + ) + except GitHunterError as e: + return Error(description=str(e)) + + +# Export as toolset for BondAgent +githunter_toolset: list[Tool[GitHunterProtocol]] = [ + Tool(blame_line), + Tool(find_pr_discussion), + Tool(get_file_experts), +] From 373de9c8a8eac3be0d44ffa843888213fb1a78b5 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 16:49:20 +0000 Subject: [PATCH 03/13] test: add GitHunter toolset tests Add comprehensive tests for GitHunter tool functions: - TestBlameLine: success, file not found, line out of range, repo not found - TestFindPRDiscussion: success, None case, rate limited, repo not found - TestGetFileExperts: success, empty list, repo not found, file not found Includes MockGitHunter implementing GitHunterProtocol for testing. All 13 tests pass with mypy and ruff checks. Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-2.3.json | 17 +- .flow/tasks/fn-2.3.md | 29 +- tests/unit/tools/githunter/test_tools.py | 402 +++++++++++++++++++++++ 3 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 tests/unit/tools/githunter/test_tools.py diff --git a/.flow/tasks/fn-2.3.json b/.flow/tasks/fn-2.3.json index 05e8043..e6264b2 100644 --- a/.flow/tasks/fn-2.3.json +++ b/.flow/tasks/fn-2.3.json @@ -1,16 +1,25 @@ { - "assignee": null, + "assignee": "bordumbb@gmail.com", "claim_note": "", - "claimed_at": null, + "claimed_at": "2026-01-24T16:46:50.606295Z", "created_at": "2026-01-24T16:20:04.087789Z", "depends_on": [ "fn-2.2" ], "epic": "fn-2", + "evidence": { + "commits": [ + "69dcdcc" + ], + "prs": [], + "tests": [ + "tests/unit/tools/githunter/test_tools.py" + ] + }, "id": "fn-2.3", "priority": null, "spec_path": ".flow/tasks/fn-2.3.md", - "status": "todo", + "status": "done", "title": "Add GitHunter toolset tests", - "updated_at": "2026-01-24T16:21:09.983177Z" + "updated_at": "2026-01-24T16:49:31.302709Z" } diff --git a/.flow/tasks/fn-2.3.md b/.flow/tasks/fn-2.3.md index e5f4cda..145d33e 100644 --- a/.flow/tasks/fn-2.3.md +++ b/.flow/tasks/fn-2.3.md @@ -52,9 +52,30 @@ class TestBlameLine: - [ ] `uv run pytest tests/unit/tools/githunter/test_tools.py -v` passes - [ ] All existing GitHunter tests still pass ## Done summary -TBD +Created test_tools.py with 13 comprehensive tests: +TestBlameLine (4 tests): +- test_returns_blame_result: success case +- test_handles_file_not_found_error +- test_handles_line_out_of_range_error +- test_handles_repo_not_found_error + +TestFindPRDiscussion (4 tests): +- test_returns_pr_discussion: success case +- test_returns_none_when_no_pr +- test_handles_rate_limited_error +- test_handles_repo_not_found_error + +TestGetFileExperts (5 tests): +- test_returns_expert_list: success case +- test_returns_empty_list +- test_handles_repo_not_found_error +- test_handles_file_not_found_error +- test_uses_custom_window_and_limit + +Includes MockGitHunter class implementing GitHunterProtocol. +All tests pass with mypy and ruff checks. ## Evidence -- Commits: -- Tests: -- PRs: +- Commits: 69dcdcc +- Tests: tests/unit/tools/githunter/test_tools.py +- PRs: \ No newline at end of file diff --git a/tests/unit/tools/githunter/test_tools.py b/tests/unit/tools/githunter/test_tools.py new file mode 100644 index 0000000..52b6b68 --- /dev/null +++ b/tests/unit/tools/githunter/test_tools.py @@ -0,0 +1,402 @@ +"""Tests for GitHunter tool functions.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from bond.tools.githunter._exceptions import ( + FileNotFoundInRepoError, + LineOutOfRangeError, + RateLimitedError, + RepoNotFoundError, +) +from bond.tools.githunter._models import ( + BlameLineRequest, + Error, + FindPRDiscussionRequest, + GetExpertsRequest, +) +from bond.tools.githunter._types import ( + AuthorProfile, + BlameResult, + FileExpert, + PRDiscussion, +) +from bond.tools.githunter.tools import ( + blame_line, + find_pr_discussion, + get_file_experts, +) + + +class MockGitHunter: + """Mock implementation of GitHunterProtocol for testing.""" + + def __init__( + self, + blame_result: BlameResult | Exception | None = None, + pr_discussion: PRDiscussion | Exception | None = None, + experts: list[FileExpert] | Exception | None = None, + ) -> None: + """Initialize mock with configurable return values.""" + self._blame_result = blame_result + self._pr_discussion = pr_discussion + self._experts = experts if experts is not None else [] + + async def blame_line( + self, + repo_path: Path, + file_path: str, + line_no: int, + ) -> BlameResult: + """Mock blame_line that returns configured result or raises exception.""" + if isinstance(self._blame_result, Exception): + raise self._blame_result + if self._blame_result is None: + raise ValueError("No blame result configured") + return self._blame_result + + async def find_pr_discussion( + self, + repo_path: Path, + commit_hash: str, + ) -> PRDiscussion | None: + """Mock find_pr_discussion that returns configured result or raises exception.""" + if isinstance(self._pr_discussion, Exception): + raise self._pr_discussion + return self._pr_discussion + + async def get_expert_for_file( + self, + repo_path: Path, + file_path: str, + window_days: int = 90, + limit: int = 3, + ) -> list[FileExpert]: + """Mock get_expert_for_file that returns configured result or raises exception.""" + if isinstance(self._experts, Exception): + raise self._experts + return self._experts + + +@pytest.fixture +def sample_author() -> AuthorProfile: + """Create a sample author profile for tests.""" + return AuthorProfile( + git_email="dev@example.com", + git_name="Developer", + github_username="devuser", + ) + + +@pytest.fixture +def sample_blame_result(sample_author: AuthorProfile) -> BlameResult: + """Create a sample blame result for tests.""" + return BlameResult( + line_no=42, + content=" return result", + author=sample_author, + commit_hash="abc123def456", + commit_date=datetime(2024, 6, 15, 10, 30, tzinfo=UTC), + commit_message="Fix calculation bug", + ) + + +@pytest.fixture +def sample_pr_discussion() -> PRDiscussion: + """Create a sample PR discussion for tests.""" + return PRDiscussion( + pr_number=123, + title="Fix calculation bug in processor", + body="This PR fixes the calculation issue reported in #100.", + url="https://github.com/owner/repo/pull/123", + issue_comments=("LGTM!", "Thanks for the fix."), + ) + + +@pytest.fixture +def sample_experts(sample_author: AuthorProfile) -> list[FileExpert]: + """Create sample file experts for tests.""" + return [ + FileExpert( + author=sample_author, + commit_count=15, + last_commit_date=datetime(2024, 6, 20, tzinfo=UTC), + ), + FileExpert( + author=AuthorProfile( + git_email="other@example.com", + git_name="Other Dev", + ), + commit_count=8, + last_commit_date=datetime(2024, 5, 10, tzinfo=UTC), + ), + ] + + +def create_mock_ctx(hunter: MockGitHunter) -> MagicMock: + """Create a mock RunContext with GitHunter deps.""" + ctx = MagicMock() + ctx.deps = hunter + return ctx + + +class TestBlameLine: + """Tests for blame_line tool function.""" + + @pytest.mark.asyncio + async def test_returns_blame_result( + self, sample_blame_result: BlameResult + ) -> None: + """Test blame_line returns BlameResult on success.""" + hunter = MockGitHunter(blame_result=sample_blame_result) + ctx = create_mock_ctx(hunter) + request = BlameLineRequest( + repo_path="/path/to/repo", + file_path="src/processor.py", + line_no=42, + ) + + result = await blame_line(ctx, request) + + assert isinstance(result, BlameResult) + assert result.line_no == 42 + assert result.commit_hash == "abc123def456" + assert result.author.git_email == "dev@example.com" + + @pytest.mark.asyncio + async def test_handles_file_not_found_error(self) -> None: + """Test blame_line returns Error when file not found.""" + hunter = MockGitHunter( + blame_result=FileNotFoundInRepoError( + file_path="nonexistent.py", + repo_path="/repo", + ) + ) + ctx = create_mock_ctx(hunter) + request = BlameLineRequest( + repo_path="/repo", + file_path="nonexistent.py", + line_no=1, + ) + + result = await blame_line(ctx, request) + + assert isinstance(result, Error) + assert "File not found" in result.description + assert "nonexistent.py" in result.description + + @pytest.mark.asyncio + async def test_handles_line_out_of_range_error(self) -> None: + """Test blame_line returns Error when line out of range.""" + hunter = MockGitHunter( + blame_result=LineOutOfRangeError(line_no=1000, max_lines=50) + ) + ctx = create_mock_ctx(hunter) + request = BlameLineRequest( + repo_path="/repo", + file_path="small.py", + line_no=1000, + ) + + result = await blame_line(ctx, request) + + assert isinstance(result, Error) + assert "out of range" in result.description + + @pytest.mark.asyncio + async def test_handles_repo_not_found_error(self) -> None: + """Test blame_line returns Error when repo not found.""" + hunter = MockGitHunter( + blame_result=RepoNotFoundError(path="/not/a/repo") + ) + ctx = create_mock_ctx(hunter) + request = BlameLineRequest( + repo_path="/not/a/repo", + file_path="file.py", + line_no=1, + ) + + result = await blame_line(ctx, request) + + assert isinstance(result, Error) + assert "not inside a git repository" in result.description + + +class TestFindPRDiscussion: + """Tests for find_pr_discussion tool function.""" + + @pytest.mark.asyncio + async def test_returns_pr_discussion( + self, sample_pr_discussion: PRDiscussion + ) -> None: + """Test find_pr_discussion returns PRDiscussion when found.""" + hunter = MockGitHunter(pr_discussion=sample_pr_discussion) + ctx = create_mock_ctx(hunter) + request = FindPRDiscussionRequest( + repo_path="/path/to/repo", + commit_hash="abc123def", + ) + + result = await find_pr_discussion(ctx, request) + + assert isinstance(result, PRDiscussion) + assert result.pr_number == 123 + assert result.title == "Fix calculation bug in processor" + + @pytest.mark.asyncio + async def test_returns_none_when_no_pr(self) -> None: + """Test find_pr_discussion returns None when no PR found.""" + hunter = MockGitHunter(pr_discussion=None) + ctx = create_mock_ctx(hunter) + request = FindPRDiscussionRequest( + repo_path="/path/to/repo", + commit_hash="xyz789abc", + ) + + result = await find_pr_discussion(ctx, request) + + assert result is None + + @pytest.mark.asyncio + async def test_handles_rate_limited_error(self) -> None: + """Test find_pr_discussion returns Error when rate limited.""" + reset_at = datetime(2024, 6, 15, 12, 0, tzinfo=UTC) + hunter = MockGitHunter( + pr_discussion=RateLimitedError( + retry_after_seconds=3600, + reset_at=reset_at, + ) + ) + ctx = create_mock_ctx(hunter) + request = FindPRDiscussionRequest( + repo_path="/repo", + commit_hash="abc123def", + ) + + result = await find_pr_discussion(ctx, request) + + assert isinstance(result, Error) + assert "rate limit" in result.description.lower() + + @pytest.mark.asyncio + async def test_handles_repo_not_found_error(self) -> None: + """Test find_pr_discussion returns Error when repo not found.""" + hunter = MockGitHunter( + pr_discussion=RepoNotFoundError(path="/not/a/repo") + ) + ctx = create_mock_ctx(hunter) + request = FindPRDiscussionRequest( + repo_path="/not/a/repo", + commit_hash="abc123def", + ) + + result = await find_pr_discussion(ctx, request) + + assert isinstance(result, Error) + assert "not inside a git repository" in result.description + + +class TestGetFileExperts: + """Tests for get_file_experts tool function.""" + + @pytest.mark.asyncio + async def test_returns_expert_list( + self, sample_experts: list[FileExpert] + ) -> None: + """Test get_file_experts returns list of FileExpert on success.""" + hunter = MockGitHunter(experts=sample_experts) + ctx = create_mock_ctx(hunter) + request = GetExpertsRequest( + repo_path="/path/to/repo", + file_path="src/auth/login.py", + window_days=90, + limit=3, + ) + + result = await get_file_experts(ctx, request) + + assert isinstance(result, list) + assert len(result) == 2 + assert result[0].commit_count == 15 + assert result[0].author.git_email == "dev@example.com" + + @pytest.mark.asyncio + async def test_returns_empty_list(self) -> None: + """Test get_file_experts returns empty list when no experts.""" + hunter = MockGitHunter(experts=[]) + ctx = create_mock_ctx(hunter) + request = GetExpertsRequest( + repo_path="/path/to/repo", + file_path="new_file.py", + window_days=90, + limit=3, + ) + + result = await get_file_experts(ctx, request) + + assert isinstance(result, list) + assert len(result) == 0 + + @pytest.mark.asyncio + async def test_handles_repo_not_found_error(self) -> None: + """Test get_file_experts returns Error when repo not found.""" + hunter = MockGitHunter( + experts=RepoNotFoundError(path="/not/a/repo") + ) + ctx = create_mock_ctx(hunter) + request = GetExpertsRequest( + repo_path="/not/a/repo", + file_path="file.py", + window_days=90, + limit=3, + ) + + result = await get_file_experts(ctx, request) + + assert isinstance(result, Error) + assert "not inside a git repository" in result.description + + @pytest.mark.asyncio + async def test_handles_file_not_found_error(self) -> None: + """Test get_file_experts returns Error when file not found.""" + hunter = MockGitHunter( + experts=FileNotFoundInRepoError( + file_path="missing.py", + repo_path="/repo", + ) + ) + ctx = create_mock_ctx(hunter) + request = GetExpertsRequest( + repo_path="/repo", + file_path="missing.py", + window_days=90, + limit=3, + ) + + result = await get_file_experts(ctx, request) + + assert isinstance(result, Error) + assert "File not found" in result.description + + @pytest.mark.asyncio + async def test_uses_custom_window_and_limit(self) -> None: + """Test get_file_experts accepts custom window_days and limit.""" + hunter = MockGitHunter(experts=[]) + ctx = create_mock_ctx(hunter) + request = GetExpertsRequest( + repo_path="/repo", + file_path="file.py", + window_days=180, + limit=5, + ) + + assert request.window_days == 180 + assert request.limit == 5 + + await get_file_experts(ctx, request) + # Just verify it runs without error From a0b82bdc2ff01392f8948e6cc13380fd3edd9db3 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 16:50:36 +0000 Subject: [PATCH 04/13] docs: update GitHunter exports and API documentation Update __init__.py to export: - githunter_toolset for BondAgent integration - BlameLineRequest, FindPRDiscussionRequest, GetExpertsRequest - Error model for union return types Add GitHunter section to docs/api/tools.md with: - Protocol documentation - Type documentation (BlameResult, FileExpert, PRDiscussion, AuthorProfile) - Request model documentation - Toolset documentation Verified: - Imports work: from bond.tools.githunter import githunter_toolset - mkdocs build --strict passes - All 51 GitHunter tests pass Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-2.4.json | 17 +++++++--- .flow/tasks/fn-2.4.md | 26 ++++++++++++--- docs/api/tools.md | 50 ++++++++++++++++++++++++++++ src/bond/tools/githunter/__init__.py | 14 ++++++++ 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/.flow/tasks/fn-2.4.json b/.flow/tasks/fn-2.4.json index d3086fd..e814498 100644 --- a/.flow/tasks/fn-2.4.json +++ b/.flow/tasks/fn-2.4.json @@ -1,16 +1,25 @@ { - "assignee": null, + "assignee": "bordumbb@gmail.com", "claim_note": "", - "claimed_at": null, + "claimed_at": "2026-01-24T16:49:47.248494Z", "created_at": "2026-01-24T16:20:04.284409Z", "depends_on": [ "fn-2.3" ], "epic": "fn-2", + "evidence": { + "commits": [ + "8ebeefc" + ], + "prs": [], + "tests": [ + "tests/unit/tools/githunter/" + ] + }, "id": "fn-2.4", "priority": null, "spec_path": ".flow/tasks/fn-2.4.md", - "status": "todo", + "status": "done", "title": "Update exports and documentation", - "updated_at": "2026-01-24T16:21:10.379793Z" + "updated_at": "2026-01-24T16:50:45.762818Z" } diff --git a/.flow/tasks/fn-2.4.md b/.flow/tasks/fn-2.4.md index f8c2f95..e5092f9 100644 --- a/.flow/tasks/fn-2.4.md +++ b/.flow/tasks/fn-2.4.md @@ -55,9 +55,25 @@ from bond.tools.githunter import githunter_toolset, BlameLineRequest - [ ] `mkdocs build --strict` passes - [ ] All tests pass: `uv run pytest tests/unit/tools/githunter/ -v` ## Done summary -TBD - +Updated exports and documentation: + +__init__.py exports: +- githunter_toolset (list of 3 PydanticAI Tools) +- BlameLineRequest, FindPRDiscussionRequest, GetExpertsRequest +- Error model + +docs/api/tools.md additions: +- GitHunter Toolset section +- Protocol documentation +- Type documentation (BlameResult, FileExpert, PRDiscussion, AuthorProfile) +- Request model documentation +- Toolset reference + +Verification: +- from bond.tools.githunter import githunter_toolset works +- mkdocs build --strict passes +- All 51 GitHunter tests pass ## Evidence -- Commits: -- Tests: -- PRs: +- Commits: 8ebeefc +- Tests: tests/unit/tools/githunter/ +- PRs: \ No newline at end of file diff --git a/docs/api/tools.md b/docs/api/tools.md index 151a55b..4046974 100644 --- a/docs/api/tools.md +++ b/docs/api/tools.md @@ -75,3 +75,53 @@ The schema toolset provides database schema lookup capabilities. ::: bond.tools.schema.schema_toolset options: show_source: false + +--- + +## GitHunter Toolset + +The GitHunter toolset provides forensic code ownership analysis tools. + +### Protocol + +::: bond.tools.githunter.GitHunterProtocol + options: + show_source: true + +### Types + +::: bond.tools.githunter.BlameResult + options: + show_source: true + +::: bond.tools.githunter.FileExpert + options: + show_source: true + +::: bond.tools.githunter.PRDiscussion + options: + show_source: true + +::: bond.tools.githunter.AuthorProfile + options: + show_source: true + +### Request Models + +::: bond.tools.githunter.BlameLineRequest + options: + show_source: true + +::: bond.tools.githunter.FindPRDiscussionRequest + options: + show_source: true + +::: bond.tools.githunter.GetExpertsRequest + options: + show_source: true + +### Toolset + +::: bond.tools.githunter.githunter_toolset + options: + show_source: false diff --git a/src/bond/tools/githunter/__init__.py b/src/bond/tools/githunter/__init__.py index c207dc6..87947af 100644 --- a/src/bond/tools/githunter/__init__.py +++ b/src/bond/tools/githunter/__init__.py @@ -17,8 +17,15 @@ RepoNotFoundError, ShallowCloneError, ) +from ._models import ( + BlameLineRequest, + Error, + FindPRDiscussionRequest, + GetExpertsRequest, +) from ._protocols import GitHunterProtocol from ._types import AuthorProfile, BlameResult, FileExpert, PRDiscussion +from .tools import githunter_toolset __all__ = [ # Adapter @@ -30,6 +37,13 @@ "PRDiscussion", # Protocol "GitHunterProtocol", + # Toolset + "githunter_toolset", + # Request Models + "BlameLineRequest", + "FindPRDiscussionRequest", + "GetExpertsRequest", + "Error", # Exceptions "GitHunterError", "RepoNotFoundError", From 8d491d99b8270c5a96784372a2a578b676567239 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 16:54:47 +0000 Subject: [PATCH 05/13] feat: add trace event models and storage protocol Create src/bond/trace/ module with: - TraceEvent: Frozen dataclass for all 8 StreamHandlers callbacks - TraceMeta: Metadata for trace listing without loading events - TraceStorageProtocol: @runtime_checkable interface for backends Event types: block_start, block_end, text_delta, thinking_delta, tool_call_delta, tool_execute, tool_result, complete Both models have to_dict/from_dict for JSON serialization. Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-3.1.json | 15 +++- .flow/tasks/fn-3.1.md | 11 ++- src/bond/trace/__init__.py | 58 +++++++++++++ src/bond/trace/_models.py | 162 +++++++++++++++++++++++++++++++++++ src/bond/trace/_protocols.py | 103 ++++++++++++++++++++++ 5 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 src/bond/trace/__init__.py create mode 100644 src/bond/trace/_models.py create mode 100644 src/bond/trace/_protocols.py diff --git a/.flow/tasks/fn-3.1.json b/.flow/tasks/fn-3.1.json index 95ce12e..f3748be 100644 --- a/.flow/tasks/fn-3.1.json +++ b/.flow/tasks/fn-3.1.json @@ -1,14 +1,21 @@ { - "assignee": null, + "assignee": "bordumbb@gmail.com", "claim_note": "", - "claimed_at": null, + "claimed_at": "2026-01-24T16:53:19.694279Z", "created_at": "2026-01-24T16:22:12.408953Z", "depends_on": [], "epic": "fn-3", + "evidence": { + "commits": [ + "d01a07b" + ], + "prs": [], + "tests": [] + }, "id": "fn-3.1", "priority": null, "spec_path": ".flow/tasks/fn-3.1.md", - "status": "todo", + "status": "done", "title": "Create trace event models and protocol", - "updated_at": "2026-01-24T16:23:34.680004Z" + "updated_at": "2026-01-24T16:54:57.089197Z" } diff --git a/.flow/tasks/fn-3.1.md b/.flow/tasks/fn-3.1.md index f7cdc49..90844f8 100644 --- a/.flow/tasks/fn-3.1.md +++ b/.flow/tasks/fn-3.1.md @@ -69,9 +69,12 @@ class TraceStorageProtocol(Protocol): - [ ] Protocol is @runtime_checkable - [ ] `mypy src/bond/trace/` passes ## Done summary -TBD - +- Created src/bond/trace/ module with TraceEvent, TraceMeta models +- TraceEvent captures all 8 StreamHandlers callback types with to_dict/from_dict +- TraceMeta provides summary info (trace_id, created_at, event_count, status) +- TraceStorageProtocol defines @runtime_checkable interface for backends +- Verification: mypy and ruff pass on src/bond/trace/ ## Evidence -- Commits: +- Commits: d01a07b - Tests: -- PRs: +- PRs: \ No newline at end of file diff --git a/src/bond/trace/__init__.py b/src/bond/trace/__init__.py new file mode 100644 index 0000000..df7c036 --- /dev/null +++ b/src/bond/trace/__init__.py @@ -0,0 +1,58 @@ +"""Trace module: Forensic capture and replay for agent executions. + +Provides tools for recording all StreamHandlers events during agent runs +and replaying them later for debugging, auditing, and analysis. + +Example: + from bond.trace import JSONFileTraceStore, create_capture_handlers, TraceReplayer + + # Capture during execution + store = JSONFileTraceStore() + handlers, trace_id = create_capture_handlers(store) + result = await agent.ask("hello", stream=handlers) + + # Replay later + replayer = TraceReplayer(store, trace_id) + async for event in replayer: + print(f"{event.event_type}: {event.payload}") +""" + +from bond.trace._models import ( + ALL_EVENT_TYPES, + EVENT_BLOCK_END, + EVENT_BLOCK_START, + EVENT_COMPLETE, + EVENT_TEXT_DELTA, + EVENT_THINKING_DELTA, + EVENT_TOOL_CALL_DELTA, + EVENT_TOOL_EXECUTE, + EVENT_TOOL_RESULT, + STATUS_COMPLETE, + STATUS_FAILED, + STATUS_IN_PROGRESS, + TraceEvent, + TraceMeta, +) +from bond.trace._protocols import TraceStorageProtocol + +__all__ = [ + # Models + "TraceEvent", + "TraceMeta", + # Protocol + "TraceStorageProtocol", + # Event type constants + "EVENT_BLOCK_START", + "EVENT_BLOCK_END", + "EVENT_TEXT_DELTA", + "EVENT_THINKING_DELTA", + "EVENT_TOOL_CALL_DELTA", + "EVENT_TOOL_EXECUTE", + "EVENT_TOOL_RESULT", + "EVENT_COMPLETE", + "ALL_EVENT_TYPES", + # Status constants + "STATUS_IN_PROGRESS", + "STATUS_COMPLETE", + "STATUS_FAILED", +] diff --git a/src/bond/trace/_models.py b/src/bond/trace/_models.py new file mode 100644 index 0000000..f91d127 --- /dev/null +++ b/src/bond/trace/_models.py @@ -0,0 +1,162 @@ +"""Trace event models for forensic capture and replay. + +Provides dataclasses for trace events and metadata that capture +all 8 StreamHandlers callback types for persistence and replay. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +# Event type constants matching StreamHandlers callbacks +EVENT_BLOCK_START = "block_start" +EVENT_BLOCK_END = "block_end" +EVENT_TEXT_DELTA = "text_delta" +EVENT_THINKING_DELTA = "thinking_delta" +EVENT_TOOL_CALL_DELTA = "tool_call_delta" +EVENT_TOOL_EXECUTE = "tool_execute" +EVENT_TOOL_RESULT = "tool_result" +EVENT_COMPLETE = "complete" + +ALL_EVENT_TYPES = frozenset({ + EVENT_BLOCK_START, + EVENT_BLOCK_END, + EVENT_TEXT_DELTA, + EVENT_THINKING_DELTA, + EVENT_TOOL_CALL_DELTA, + EVENT_TOOL_EXECUTE, + EVENT_TOOL_RESULT, + EVENT_COMPLETE, +}) + + +@dataclass(frozen=True) +class TraceEvent: + """A single event in an execution trace. + + Captures one callback from StreamHandlers with full context + for later replay or analysis. + + Attributes: + trace_id: UUID identifying this trace session. + sequence: Zero-indexed order within the trace. + timestamp: Monotonic clock value for relative ordering. + wall_time: Human-readable UTC timestamp. + event_type: One of the 8 callback types. + payload: Event-specific data (varies by event_type). + """ + + trace_id: str + sequence: int + timestamp: float + wall_time: datetime + event_type: str + payload: dict[str, Any] + + def to_dict(self) -> dict[str, Any]: + """Serialize event to dictionary for JSON storage. + + Returns: + Dictionary with all fields, wall_time as ISO string. + """ + return { + "trace_id": self.trace_id, + "sequence": self.sequence, + "timestamp": self.timestamp, + "wall_time": self.wall_time.isoformat(), + "event_type": self.event_type, + "payload": self.payload, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TraceEvent: + """Deserialize event from dictionary. + + Args: + data: Dictionary with trace event fields. + + Returns: + TraceEvent instance. + """ + wall_time = data["wall_time"] + if isinstance(wall_time, str): + # Parse ISO format, handle with or without timezone + if wall_time.endswith("Z"): + wall_time = wall_time[:-1] + "+00:00" + wall_time = datetime.fromisoformat(wall_time) + if wall_time.tzinfo is None: + wall_time = wall_time.replace(tzinfo=UTC) + + return cls( + trace_id=data["trace_id"], + sequence=data["sequence"], + timestamp=data["timestamp"], + wall_time=wall_time, + event_type=data["event_type"], + payload=data["payload"], + ) + + +# Trace status constants +STATUS_IN_PROGRESS = "in_progress" +STATUS_COMPLETE = "complete" +STATUS_FAILED = "failed" + + +@dataclass(frozen=True) +class TraceMeta: + """Metadata about a stored trace. + + Provides summary information without loading all events. + + Attributes: + trace_id: UUID identifying this trace. + created_at: When the trace was started. + event_count: Number of events in the trace. + status: One of "in_progress", "complete", "failed". + """ + + trace_id: str + created_at: datetime + event_count: int + status: str + + def to_dict(self) -> dict[str, Any]: + """Serialize metadata to dictionary. + + Returns: + Dictionary with all fields, created_at as ISO string. + """ + return { + "trace_id": self.trace_id, + "created_at": self.created_at.isoformat(), + "event_count": self.event_count, + "status": self.status, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TraceMeta: + """Deserialize metadata from dictionary. + + Args: + data: Dictionary with trace metadata fields. + + Returns: + TraceMeta instance. + """ + created_at = data["created_at"] + if isinstance(created_at, str): + if created_at.endswith("Z"): + created_at = created_at[:-1] + "+00:00" + created_at = datetime.fromisoformat(created_at) + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=UTC) + + return cls( + trace_id=data["trace_id"], + created_at=created_at, + event_count=data["event_count"], + status=data["status"], + ) diff --git a/src/bond/trace/_protocols.py b/src/bond/trace/_protocols.py new file mode 100644 index 0000000..a1ce624 --- /dev/null +++ b/src/bond/trace/_protocols.py @@ -0,0 +1,103 @@ +"""Trace storage protocol - interface for trace backends. + +Defines the interface that trace storage implementations must follow. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Protocol, runtime_checkable + +from bond.trace._models import TraceEvent, TraceMeta + + +@runtime_checkable +class TraceStorageProtocol(Protocol): + """Protocol for trace storage backends. + + Provides async methods for saving, loading, and managing execution traces. + All operations are async to support various backend implementations + (file, database, remote storage). + + Implementations: + - JSONFileTraceStore: Local JSON file storage (default) + """ + + async def save_event(self, event: TraceEvent) -> None: + """Append an event to a trace. + + The trace is created if it doesn't exist. Events should be + saved in order (by sequence number). + + Args: + event: The trace event to save. + + Raises: + IOError: If storage fails. + """ + ... + + async def finalize_trace( + self, + trace_id: str, + status: str = "complete", + ) -> None: + """Mark a trace as complete or failed. + + Should be called when the agent run finishes. This updates + the trace metadata and may trigger cleanup or indexing. + + Args: + trace_id: The trace to finalize. + status: Final status ("complete" or "failed"). + + Raises: + KeyError: If trace_id doesn't exist. + IOError: If storage fails. + """ + ... + + async def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: + """Load all events from a trace for replay. + + Yields events in sequence order. Memory-efficient for large traces. + + Args: + trace_id: The trace to load. + + Yields: + TraceEvent objects in sequence order. + + Raises: + KeyError: If trace_id doesn't exist. + IOError: If loading fails. + """ + ... + + async def list_traces(self, limit: int = 100) -> list[TraceMeta]: + """List available traces with metadata. + + Returns traces ordered by creation time (newest first). + + Args: + limit: Maximum number of traces to return. + + Returns: + List of TraceMeta for available traces. + + Raises: + IOError: If listing fails. + """ + ... + + async def delete_trace(self, trace_id: str) -> None: + """Delete a trace and all its events. + + Args: + trace_id: The trace to delete. + + Raises: + KeyError: If trace_id doesn't exist. + IOError: If deletion fails. + """ + ... From 14ca6003d93a9d83956a0fbdc747ed4259daeb66 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 16:57:24 +0000 Subject: [PATCH 06/13] feat: implement JSON file storage backend for traces Add JSONFileTraceStore implementing TraceStorageProtocol: - Stores events as newline-delimited JSON ({trace_id}.json) - Stores metadata separately ({trace_id}.meta.json) - Supports save_event, finalize_trace, load_trace, list_traces, delete_trace - AsyncIterator for memory-efficient trace loading Dependencies: - Add aiofiles>=24.0.0 for async file operations - Add types-aiofiles>=24.0.0 for mypy stubs Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-3.2.json | 15 +- .flow/tasks/fn-3.2.md | 11 +- pyproject.toml | 8 + src/bond/trace/__init__.py | 3 + src/bond/trace/backends/__init__.py | 11 ++ src/bond/trace/backends/json_file.py | 231 +++++++++++++++++++++++++++ uv.lock | 24 +++ 7 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 src/bond/trace/backends/__init__.py create mode 100644 src/bond/trace/backends/json_file.py diff --git a/.flow/tasks/fn-3.2.json b/.flow/tasks/fn-3.2.json index d5e984b..802ff34 100644 --- a/.flow/tasks/fn-3.2.json +++ b/.flow/tasks/fn-3.2.json @@ -1,16 +1,23 @@ { - "assignee": null, + "assignee": "bordumbb@gmail.com", "claim_note": "", - "claimed_at": null, + "claimed_at": "2026-01-24T16:55:21.720211Z", "created_at": "2026-01-24T16:22:12.603302Z", "depends_on": [ "fn-3.1" ], "epic": "fn-3", + "evidence": { + "commits": [ + "53582a0" + ], + "prs": [], + "tests": [] + }, "id": "fn-3.2", "priority": null, "spec_path": ".flow/tasks/fn-3.2.md", - "status": "todo", + "status": "done", "title": "Implement JSON file storage backend", - "updated_at": "2026-01-24T16:23:35.074202Z" + "updated_at": "2026-01-24T16:57:33.300113Z" } diff --git a/.flow/tasks/fn-3.2.md b/.flow/tasks/fn-3.2.md index 8970570..a04049f 100644 --- a/.flow/tasks/fn-3.2.md +++ b/.flow/tasks/fn-3.2.md @@ -72,9 +72,12 @@ Add to pyproject.toml: - [ ] aiofiles added to dependencies - [ ] `mypy` passes ## Done summary -TBD - +- Created JSONFileTraceStore implementing TraceStorageProtocol +- Events stored as newline-delimited JSON for efficient streaming +- Meta files track trace status and event count +- Added aiofiles and types-aiofiles dependencies +- Verification: mypy and ruff pass on src/bond/trace/ ## Evidence -- Commits: +- Commits: 53582a0 - Tests: -- PRs: +- PRs: \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 091d214..40234cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "qdrant-client>=1.7.0", "sentence-transformers>=2.2.0", "asyncpg>=0.29.0", + "aiofiles>=24.0.0", ] [project.optional-dependencies] @@ -32,6 +33,7 @@ dev = [ "ruff>=0.2.0", "mypy>=1.8.0", "respx>=0.20.2", + "types-aiofiles>=24.0.0", ] docs = [ "mkdocs>=1.6.0", @@ -61,6 +63,7 @@ dev = [ "pytest-cov>=4.1.0", "ruff>=0.2.0", "mypy>=1.8.0", + "types-aiofiles>=24.0.0", ] # ============================================================================ @@ -100,6 +103,11 @@ module = ["bond.agent"] # PydanticAI Agent internals disallow_any_generics = false +[[tool.mypy.overrides]] +module = ["bond.trace.backends.*"] +# aiofiles doesn't have type stubs bundled +disallow_untyped_calls = false + # ============================================================================ # ruff configuration # ============================================================================ diff --git a/src/bond/trace/__init__.py b/src/bond/trace/__init__.py index df7c036..e83a7c4 100644 --- a/src/bond/trace/__init__.py +++ b/src/bond/trace/__init__.py @@ -34,6 +34,7 @@ TraceMeta, ) from bond.trace._protocols import TraceStorageProtocol +from bond.trace.backends import JSONFileTraceStore __all__ = [ # Models @@ -41,6 +42,8 @@ "TraceMeta", # Protocol "TraceStorageProtocol", + # Backends + "JSONFileTraceStore", # Event type constants "EVENT_BLOCK_START", "EVENT_BLOCK_END", diff --git a/src/bond/trace/backends/__init__.py b/src/bond/trace/backends/__init__.py new file mode 100644 index 0000000..8b64582 --- /dev/null +++ b/src/bond/trace/backends/__init__.py @@ -0,0 +1,11 @@ +"""Trace storage backends. + +Provides implementations of TraceStorageProtocol for different +storage systems. +""" + +from bond.trace.backends.json_file import JSONFileTraceStore + +__all__ = [ + "JSONFileTraceStore", +] diff --git a/src/bond/trace/backends/json_file.py b/src/bond/trace/backends/json_file.py new file mode 100644 index 0000000..403d86e --- /dev/null +++ b/src/bond/trace/backends/json_file.py @@ -0,0 +1,231 @@ +"""JSON file storage backend for traces. + +Stores traces as newline-delimited JSON files with separate metadata files. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from pathlib import Path + +import aiofiles +import aiofiles.os + +from bond.trace._models import ( + STATUS_COMPLETE, + STATUS_IN_PROGRESS, + TraceEvent, + TraceMeta, +) + + +class JSONFileTraceStore: + """Store traces as JSON files in a directory. + + Each trace consists of two files: + - {trace_id}.json: Newline-delimited JSON events + - {trace_id}.meta.json: TraceMeta as JSON + + File Structure: + {base_path}/ + ├── {trace_id}.json # Events (one JSON object per line) + └── {trace_id}.meta.json # TraceMeta + + Example: + store = JSONFileTraceStore(".bond/traces") + await store.save_event(event) + await store.finalize_trace(trace_id) + + async for event in store.load_trace(trace_id): + print(event) + """ + + def __init__(self, base_path: Path | str = ".bond/traces") -> None: + """Initialize JSON file store. + + Args: + base_path: Directory for trace files. Created if doesn't exist. + """ + self.base_path = Path(base_path) + self.base_path.mkdir(parents=True, exist_ok=True) + + def _events_path(self, trace_id: str) -> Path: + """Get path to events file for a trace.""" + return self.base_path / f"{trace_id}.json" + + def _meta_path(self, trace_id: str) -> Path: + """Get path to metadata file for a trace.""" + return self.base_path / f"{trace_id}.meta.json" + + async def save_event(self, event: TraceEvent) -> None: + """Append event to trace file. + + Creates or updates the metadata file to track event count. + Uses newline-delimited JSON for efficient streaming reads. + + Args: + event: The trace event to save. + + Raises: + IOError: If writing fails. + """ + events_path = self._events_path(event.trace_id) + meta_path = self._meta_path(event.trace_id) + + # Append event to events file + async with aiofiles.open(events_path, "a") as f: + await f.write(json.dumps(event.to_dict()) + "\n") + + # Update or create metadata + if meta_path.exists(): + async with aiofiles.open(meta_path) as f: + content = await f.read() + meta_data = json.loads(content) + meta_data["event_count"] = event.sequence + 1 + else: + meta_data = { + "trace_id": event.trace_id, + "created_at": event.wall_time.isoformat(), + "event_count": event.sequence + 1, + "status": STATUS_IN_PROGRESS, + } + + async with aiofiles.open(meta_path, "w") as f: + await f.write(json.dumps(meta_data, indent=2)) + + async def finalize_trace( + self, + trace_id: str, + status: str = STATUS_COMPLETE, + ) -> None: + """Mark a trace as complete or failed. + + Updates the metadata file with the final status. + + Args: + trace_id: The trace to finalize. + status: Final status ("complete" or "failed"). + + Raises: + KeyError: If trace_id doesn't exist. + IOError: If writing fails. + """ + meta_path = self._meta_path(trace_id) + + if not meta_path.exists(): + msg = f"Trace not found: {trace_id}" + raise KeyError(msg) + + async with aiofiles.open(meta_path) as f: + content = await f.read() + meta_data = json.loads(content) + + meta_data["status"] = status + + async with aiofiles.open(meta_path, "w") as f: + await f.write(json.dumps(meta_data, indent=2)) + + async def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: + """Load all events from a trace for replay. + + Yields events in sequence order. Memory-efficient for large traces + since it streams line by line. + + Args: + trace_id: The trace to load. + + Yields: + TraceEvent objects in sequence order. + + Raises: + KeyError: If trace_id doesn't exist. + IOError: If reading fails. + """ + events_path = self._events_path(trace_id) + + if not events_path.exists(): + msg = f"Trace not found: {trace_id}" + raise KeyError(msg) + + async with aiofiles.open(events_path) as f: + async for line in f: + line = line.strip() + if line: + yield TraceEvent.from_dict(json.loads(line)) + + async def list_traces(self, limit: int = 100) -> list[TraceMeta]: + """List available traces with metadata. + + Returns traces ordered by creation time (newest first). + + Args: + limit: Maximum number of traces to return. + + Returns: + List of TraceMeta for available traces. + + Raises: + IOError: If listing fails. + """ + traces: list[TraceMeta] = [] + + # Find all meta files + for meta_path in self.base_path.glob("*.meta.json"): + try: + async with aiofiles.open(meta_path) as f: + content = await f.read() + meta_data = json.loads(content) + traces.append(TraceMeta.from_dict(meta_data)) + except (json.JSONDecodeError, KeyError): + # Skip malformed meta files + continue + + # Sort by creation time (newest first) + traces.sort(key=lambda m: m.created_at, reverse=True) + + return traces[:limit] + + async def delete_trace(self, trace_id: str) -> None: + """Delete a trace and all its files. + + Removes both the events file and metadata file. + + Args: + trace_id: The trace to delete. + + Raises: + KeyError: If trace_id doesn't exist. + IOError: If deletion fails. + """ + events_path = self._events_path(trace_id) + meta_path = self._meta_path(trace_id) + + if not events_path.exists() and not meta_path.exists(): + msg = f"Trace not found: {trace_id}" + raise KeyError(msg) + + if events_path.exists(): + await aiofiles.os.remove(events_path) + + if meta_path.exists(): + await aiofiles.os.remove(meta_path) + + async def get_trace_meta(self, trace_id: str) -> TraceMeta | None: + """Get metadata for a specific trace. + + Args: + trace_id: The trace to get metadata for. + + Returns: + TraceMeta if found, None otherwise. + """ + meta_path = self._meta_path(trace_id) + + if not meta_path.exists(): + return None + + async with aiofiles.open(meta_path) as f: + content = await f.read() + meta_data = json.loads(content) + return TraceMeta.from_dict(meta_data) diff --git a/uv.lock b/uv.lock index a24b9ad..25b663c 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/78/eb55fabaab41abc53f52c0918a9a8c0f747807e5306273f51120fd695957/ag_ui_protocol-0.1.10-py3-none-any.whl", hash = "sha256:c81e6981f30aabdf97a7ee312bfd4df0cd38e718d9fc10019c7d438128b93ab5", size = 7889, upload-time = "2025-11-06T15:17:15.325Z" }, ] +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -318,6 +327,7 @@ name = "bond" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiofiles" }, { name = "asyncpg" }, { name = "pydantic" }, { name = "pydantic-ai" }, @@ -333,6 +343,7 @@ dev = [ { name = "pytest-cov" }, { name = "respx" }, { name = "ruff" }, + { name = "types-aiofiles" }, ] docs = [ { name = "mkdocs" }, @@ -348,10 +359,12 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "types-aiofiles" }, ] [package.metadata] requires-dist = [ + { name = "aiofiles", specifier = ">=24.0.0" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.0" }, { name = "mkdocs-autorefs", marker = "extra == 'docs'", specifier = ">=0.5.0" }, @@ -367,6 +380,7 @@ requires-dist = [ { name = "respx", marker = "extra == 'dev'", specifier = ">=0.20.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.2.0" }, { name = "sentence-transformers", specifier = ">=2.2.0" }, + { name = "types-aiofiles", marker = "extra == 'dev'", specifier = ">=24.0.0" }, ] provides-extras = ["dev", "docs"] @@ -377,6 +391,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, { name = "ruff", specifier = ">=0.2.0" }, + { name = "types-aiofiles", specifier = ">=24.0.0" }, ] [[package]] @@ -4374,6 +4389,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, ] +[[package]] +name = "types-aiofiles" +version = "25.1.0.20251011" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6c/6d23908a8217e36704aa9c79d99a620f2fdd388b66a4b7f72fbc6b6ff6c6/types_aiofiles-25.1.0.20251011.tar.gz", hash = "sha256:1c2b8ab260cb3cd40c15f9d10efdc05a6e1e6b02899304d80dfa0410e028d3ff", size = 14535, upload-time = "2025-10-11T02:44:51.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338, upload-time = "2025-10-11T02:44:50.054Z" }, +] + [[package]] name = "types-protobuf" version = "6.32.1.20251210" From 228131da8f99a70dfd29268f45470a0bda874692 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 16:59:27 +0000 Subject: [PATCH 07/13] feat: add capture handler factory for trace recording Add create_capture_handlers() that wires all 8 StreamHandlers callbacks to record TraceEvents with sequence numbers for ordering. Features: - Auto-generates trace_id UUID if not provided - Uses monotonic clock for relative timestamps - Sync callbacks schedule async saves via event loop - finalize_capture() helper marks trace complete/failed Pattern follows create_websocket_handlers() from utils.py. Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-3.3.json | 15 +++- .flow/tasks/fn-3.3.md | 12 ++- src/bond/trace/__init__.py | 4 + src/bond/trace/capture.py | 152 +++++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 src/bond/trace/capture.py diff --git a/.flow/tasks/fn-3.3.json b/.flow/tasks/fn-3.3.json index 86090cf..e148a6c 100644 --- a/.flow/tasks/fn-3.3.json +++ b/.flow/tasks/fn-3.3.json @@ -1,16 +1,23 @@ { - "assignee": null, + "assignee": "bordumbb@gmail.com", "claim_note": "", - "claimed_at": null, + "claimed_at": "2026-01-24T16:58:02.453475Z", "created_at": "2026-01-24T16:22:12.788887Z", "depends_on": [ "fn-3.2" ], "epic": "fn-3", + "evidence": { + "commits": [ + "b7f53f4" + ], + "prs": [], + "tests": [] + }, "id": "fn-3.3", "priority": null, "spec_path": ".flow/tasks/fn-3.3.md", - "status": "todo", + "status": "done", "title": "Create capture handler factory", - "updated_at": "2026-01-24T16:23:35.463066Z" + "updated_at": "2026-01-24T16:59:36.692330Z" } diff --git a/.flow/tasks/fn-3.3.md b/.flow/tasks/fn-3.3.md index 0acf4e6..f5bec07 100644 --- a/.flow/tasks/fn-3.3.md +++ b/.flow/tasks/fn-3.3.md @@ -86,9 +86,13 @@ async def finalize_capture( - [ ] Works with async agent.ask() (sync callbacks schedule async saves) - [ ] Integration test: capture → verify file exists with events ## Done summary -TBD - +- Created create_capture_handlers() returning (StreamHandlers, trace_id) +- All 8 callbacks wired to record TraceEvents with sequence numbers +- Uses monotonic clock for relative timestamps, UTC wall_time for display +- Sync callbacks schedule async saves via event loop (follows websocket pattern) +- Added finalize_capture() helper for marking traces complete/failed +- Verification: mypy and ruff pass on src/bond/trace/ ## Evidence -- Commits: +- Commits: b7f53f4 - Tests: -- PRs: +- PRs: \ No newline at end of file diff --git a/src/bond/trace/__init__.py b/src/bond/trace/__init__.py index e83a7c4..de0234e 100644 --- a/src/bond/trace/__init__.py +++ b/src/bond/trace/__init__.py @@ -35,6 +35,7 @@ ) from bond.trace._protocols import TraceStorageProtocol from bond.trace.backends import JSONFileTraceStore +from bond.trace.capture import create_capture_handlers, finalize_capture __all__ = [ # Models @@ -44,6 +45,9 @@ "TraceStorageProtocol", # Backends "JSONFileTraceStore", + # Capture + "create_capture_handlers", + "finalize_capture", # Event type constants "EVENT_BLOCK_START", "EVENT_BLOCK_END", diff --git a/src/bond/trace/capture.py b/src/bond/trace/capture.py new file mode 100644 index 0000000..9f79054 --- /dev/null +++ b/src/bond/trace/capture.py @@ -0,0 +1,152 @@ +"""Capture handler factory for trace recording. + +Creates StreamHandlers that record all events to a trace storage backend +for later replay and analysis. +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from datetime import UTC, datetime +from typing import Any + +from bond.agent import StreamHandlers +from bond.trace._models import ( + EVENT_BLOCK_END, + EVENT_BLOCK_START, + EVENT_COMPLETE, + EVENT_TEXT_DELTA, + EVENT_THINKING_DELTA, + EVENT_TOOL_CALL_DELTA, + EVENT_TOOL_EXECUTE, + EVENT_TOOL_RESULT, + STATUS_COMPLETE, + TraceEvent, +) +from bond.trace._protocols import TraceStorageProtocol + + +def create_capture_handlers( + storage: TraceStorageProtocol, + trace_id: str | None = None, +) -> tuple[StreamHandlers, str]: + """Create handlers that capture all events to storage. + + Returns handlers that can be passed to agent.ask() along with the + trace ID for later replay. All 8 StreamHandlers callbacks are wired + to record events with sequence numbers for ordering. + + Args: + storage: Backend to store events (e.g., JSONFileTraceStore). + trace_id: Optional trace ID (auto-generated UUID if None). + + Returns: + Tuple of (handlers, trace_id) - use handlers with agent.ask(), + keep trace_id for later replay. + + Example: + store = JSONFileTraceStore() + handlers, trace_id = create_capture_handlers(store) + result = await agent.ask("query", handlers=handlers) + await finalize_capture(store, trace_id) + + # Later: replay with trace_id + async for event in store.load_trace(trace_id): + print(event) + """ + if trace_id is None: + trace_id = str(uuid.uuid4()) + + # Mutable state for closure + sequence = [0] # List to allow mutation in nested function + start_time = time.monotonic() + + def _record(event_type: str, payload: dict[str, Any]) -> None: + """Record an event to storage. + + Called from sync callbacks, schedules async save. + """ + event = TraceEvent( + trace_id=trace_id, + sequence=sequence[0], + timestamp=time.monotonic() - start_time, + wall_time=datetime.now(UTC), + event_type=event_type, + payload=payload, + ) + sequence[0] += 1 + + # Schedule async save from sync callback + try: + loop = asyncio.get_running_loop() + loop.create_task(storage.save_event(event)) + except RuntimeError: + # No running loop - shouldn't happen in normal usage + pass + + return ( + StreamHandlers( + on_block_start=lambda kind, idx: _record( + EVENT_BLOCK_START, + {"kind": kind, "index": idx}, + ), + on_block_end=lambda kind, idx: _record( + EVENT_BLOCK_END, + {"kind": kind, "index": idx}, + ), + on_text_delta=lambda text: _record( + EVENT_TEXT_DELTA, + {"text": text}, + ), + on_thinking_delta=lambda text: _record( + EVENT_THINKING_DELTA, + {"text": text}, + ), + on_tool_call_delta=lambda name, args: _record( + EVENT_TOOL_CALL_DELTA, + {"name": name, "args": args}, + ), + on_tool_execute=lambda tool_id, name, args: _record( + EVENT_TOOL_EXECUTE, + {"id": tool_id, "name": name, "args": args}, + ), + on_tool_result=lambda tool_id, name, result: _record( + EVENT_TOOL_RESULT, + {"id": tool_id, "name": name, "result": result}, + ), + on_complete=lambda data: _record( + EVENT_COMPLETE, + {"data": data}, + ), + ), + trace_id, + ) + + +async def finalize_capture( + storage: TraceStorageProtocol, + trace_id: str, + status: str = STATUS_COMPLETE, +) -> None: + """Mark a trace as complete after agent.ask() returns. + + Should be called after the agent run finishes to update the trace + metadata with the final status. + + Args: + storage: The storage backend used for capture. + trace_id: The trace ID from create_capture_handlers(). + status: Final status ("complete" or "failed"). + + Example: + handlers, trace_id = create_capture_handlers(store) + try: + result = await agent.ask("query", handlers=handlers) + await finalize_capture(store, trace_id, "complete") + except Exception: + await finalize_capture(store, trace_id, "failed") + raise + """ + await storage.finalize_trace(trace_id, status) From 6dddb67a867c2efd32038131f8fd3c834adf921b Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 17:01:16 +0000 Subject: [PATCH 08/13] feat: implement trace replayer for stepping through events Add TraceReplayer class with: - Async iteration support (async for event in replayer) - Manual stepping: step(), step_back() - Position seeking: seek(position), reset() - Properties: position, total_events, current() Events loaded on-demand and cached for stepping operations. Fix protocol: load_trace is async generator, not async function. Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-3.4.json | 15 +++- .flow/tasks/fn-3.4.md | 13 +++- src/bond/trace/__init__.py | 3 + src/bond/trace/_protocols.py | 3 +- src/bond/trace/replay.py | 145 +++++++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 src/bond/trace/replay.py diff --git a/.flow/tasks/fn-3.4.json b/.flow/tasks/fn-3.4.json index c2509c2..4bd2e0d 100644 --- a/.flow/tasks/fn-3.4.json +++ b/.flow/tasks/fn-3.4.json @@ -1,16 +1,23 @@ { - "assignee": null, + "assignee": "bordumbb@gmail.com", "claim_note": "", - "claimed_at": null, + "claimed_at": "2026-01-24T16:59:53.761757Z", "created_at": "2026-01-24T16:22:12.988299Z", "depends_on": [ "fn-3.2" ], "epic": "fn-3", + "evidence": { + "commits": [ + "e249744" + ], + "prs": [], + "tests": [] + }, "id": "fn-3.4", "priority": null, "spec_path": ".flow/tasks/fn-3.4.md", - "status": "todo", + "status": "done", "title": "Implement trace replayer", - "updated_at": "2026-01-24T16:23:35.858593Z" + "updated_at": "2026-01-24T17:01:25.057157Z" } diff --git a/.flow/tasks/fn-3.4.md b/.flow/tasks/fn-3.4.md index 0906ec8..580794b 100644 --- a/.flow/tasks/fn-3.4.md +++ b/.flow/tasks/fn-3.4.md @@ -84,9 +84,14 @@ class TraceReplayer: - [ ] `position` and `total_events` properties work - [ ] Integration test: capture → replay → verify events match ## Done summary -TBD - +- Created TraceReplayer class for stepping through stored traces +- Supports async iteration (async for event in replayer) +- Manual stepping: step(), step_back() +- Position control: seek(position), reset(), current() +- Properties: position, total_events +- Fixed protocol: load_trace is async generator (not async function) +- Verification: mypy and ruff pass ## Evidence -- Commits: +- Commits: e249744 - Tests: -- PRs: +- PRs: \ No newline at end of file diff --git a/src/bond/trace/__init__.py b/src/bond/trace/__init__.py index de0234e..2b63715 100644 --- a/src/bond/trace/__init__.py +++ b/src/bond/trace/__init__.py @@ -36,6 +36,7 @@ from bond.trace._protocols import TraceStorageProtocol from bond.trace.backends import JSONFileTraceStore from bond.trace.capture import create_capture_handlers, finalize_capture +from bond.trace.replay import TraceReplayer __all__ = [ # Models @@ -48,6 +49,8 @@ # Capture "create_capture_handlers", "finalize_capture", + # Replay + "TraceReplayer", # Event type constants "EVENT_BLOCK_START", "EVENT_BLOCK_END", diff --git a/src/bond/trace/_protocols.py b/src/bond/trace/_protocols.py index a1ce624..6770785 100644 --- a/src/bond/trace/_protocols.py +++ b/src/bond/trace/_protocols.py @@ -57,10 +57,11 @@ async def finalize_trace( """ ... - async def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: + def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: """Load all events from a trace for replay. Yields events in sequence order. Memory-efficient for large traces. + This is an async generator method. Args: trace_id: The trace to load. diff --git a/src/bond/trace/replay.py b/src/bond/trace/replay.py new file mode 100644 index 0000000..71698c9 --- /dev/null +++ b/src/bond/trace/replay.py @@ -0,0 +1,145 @@ +"""Trace replayer for stepping through stored events. + +Provides TraceReplayer class for iterating through stored traces +event by event, with support for manual stepping and seeking. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from bond.trace._models import TraceEvent +from bond.trace._protocols import TraceStorageProtocol + + +class TraceReplayer: + """Replay a stored trace event by event. + + Supports both async iteration and manual stepping through events. + Events are loaded on-demand and cached for stepping operations. + + Example (async iteration): + replayer = TraceReplayer(storage, trace_id) + async for event in replayer: + print(f"{event.event_type}: {event.payload}") + + Example (manual stepping): + replayer = TraceReplayer(storage, trace_id) + while event := await replayer.step(): + print(event) + await asyncio.sleep(event.timestamp) # Replay at original timing + + Example (seeking): + replayer = TraceReplayer(storage, trace_id) + await replayer.seek(10) # Jump to event 10 + event = await replayer.step() + """ + + def __init__(self, storage: TraceStorageProtocol, trace_id: str) -> None: + """Initialize replayer for a trace. + + Args: + storage: Backend containing the trace. + trace_id: ID of the trace to replay. + """ + self.storage = storage + self.trace_id = trace_id + self._events: list[TraceEvent] | None = None + self._position: int = 0 + + async def _load(self) -> None: + """Load all events into memory for stepping. + + Called automatically by step/seek operations. + """ + if self._events is None: + self._events = [e async for e in self.storage.load_trace(self.trace_id)] + + async def __aiter__(self) -> AsyncIterator[TraceEvent]: + """Iterate through all events. + + Streams directly from storage without loading all events + into memory first. + + Yields: + TraceEvent objects in sequence order. + """ + async for event in self.storage.load_trace(self.trace_id): + yield event + + async def step(self) -> TraceEvent | None: + """Get the next event in the trace. + + Returns: + The next TraceEvent, or None if at end of trace. + """ + await self._load() + assert self._events is not None + if self._position >= len(self._events): + return None + event = self._events[self._position] + self._position += 1 + return event + + async def step_back(self) -> TraceEvent | None: + """Go back one event. + + Returns: + The previous TraceEvent, or None if at start of trace. + """ + await self._load() + assert self._events is not None + if self._position <= 0: + return None + self._position -= 1 + return self._events[self._position] + + @property + def position(self) -> int: + """Current position in trace (0-indexed). + + Returns: + The current event index. + """ + return self._position + + @property + def total_events(self) -> int | None: + """Total number of events in trace. + + Returns: + Event count if loaded, None if not yet loaded. + """ + return len(self._events) if self._events is not None else None + + async def seek(self, position: int) -> TraceEvent | None: + """Jump to a specific position in the trace. + + Args: + position: The event index to seek to (0-indexed). + + Returns: + The event at that position, or None if position is at/past end. + """ + await self._load() + assert self._events is not None + self._position = max(0, min(position, len(self._events))) + if self._position < len(self._events): + return self._events[self._position] + return None + + async def reset(self) -> None: + """Reset to the beginning of the trace.""" + self._position = 0 + + async def current(self) -> TraceEvent | None: + """Get the current event without advancing position. + + Returns: + The current TraceEvent, or None if at end. + """ + await self._load() + assert self._events is not None + if self._position < len(self._events): + return self._events[self._position] + return None From db799beecc0e236e4774a7fd631458e4c0b73443 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 17:06:51 +0000 Subject: [PATCH 09/13] feat(trace): add tests and documentation for trace module - Add comprehensive unit tests for trace module (62 tests): - test_models.py: TraceEvent/TraceMeta serialization - test_json_store.py: JSONFileTraceStore operations - test_capture.py: create_capture_handlers factory - test_replay.py: TraceReplayer navigation - Export trace types from bond package __init__.py - Add docs/api/trace.md with API reference - Add Trace Persistence section to docs/architecture.md - Add trace to mkdocs.yml navigation Closes fn-3.5 Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-3.5.json | 8 +- docs/api/trace.md | 97 +++++++++++ docs/architecture.md | 131 ++++++++++++++ mkdocs.yml | 1 + src/bond/__init__.py | 17 ++ src/bond/agent.py | 4 + src/bond/utils.py | 2 + tests/unit/trace/__init__.py | 1 + tests/unit/trace/test_capture.py | 190 ++++++++++++++++++++ tests/unit/trace/test_json_store.py | 239 ++++++++++++++++++++++++++ tests/unit/trace/test_models.py | 207 ++++++++++++++++++++++ tests/unit/trace/test_replay.py | 258 ++++++++++++++++++++++++++++ 12 files changed, 1151 insertions(+), 4 deletions(-) create mode 100644 docs/api/trace.md create mode 100644 tests/unit/trace/__init__.py create mode 100644 tests/unit/trace/test_capture.py create mode 100644 tests/unit/trace/test_json_store.py create mode 100644 tests/unit/trace/test_models.py create mode 100644 tests/unit/trace/test_replay.py diff --git a/.flow/tasks/fn-3.5.json b/.flow/tasks/fn-3.5.json index 43b95a3..fc3ac33 100644 --- a/.flow/tasks/fn-3.5.json +++ b/.flow/tasks/fn-3.5.json @@ -1,7 +1,7 @@ { - "assignee": null, + "assignee": "bordumbb@gmail.com", "claim_note": "", - "claimed_at": null, + "claimed_at": "2026-01-24T17:01:45.095054Z", "created_at": "2026-01-24T16:22:13.179296Z", "depends_on": [ "fn-3.3", @@ -11,7 +11,7 @@ "id": "fn-3.5", "priority": null, "spec_path": ".flow/tasks/fn-3.5.md", - "status": "todo", + "status": "in_progress", "title": "Add tests and documentation", - "updated_at": "2026-01-24T16:23:36.253790Z" + "updated_at": "2026-01-24T17:01:45.095228Z" } diff --git a/docs/api/trace.md b/docs/api/trace.md new file mode 100644 index 0000000..145e003 --- /dev/null +++ b/docs/api/trace.md @@ -0,0 +1,97 @@ +# Trace Module + +The trace module provides forensic capture and replay capabilities for Bond agent executions. Record all StreamHandlers events during runs and replay them later for debugging, auditing, and analysis. + +## Quick Start + +```python +from bond import ( + BondAgent, + JSONFileTraceStore, + create_capture_handlers, + finalize_capture, + TraceReplayer, +) + +# Capture during execution +store = JSONFileTraceStore(".bond/traces") +handlers, trace_id = create_capture_handlers(store) +result = await agent.ask("What is the weather?", handlers=handlers) +await finalize_capture(store, trace_id) + +# Replay later +replayer = TraceReplayer(store, trace_id) +async for event in replayer: + print(f"{event.event_type}: {event.payload}") +``` + +## Event Types + +All 8 StreamHandlers callbacks are captured: + +| Event Type | Payload Keys | Description | +|------------|--------------|-------------| +| `block_start` | `kind`, `index` | New block started | +| `block_end` | `kind`, `index` | Block finished | +| `text_delta` | `text` | Incremental text | +| `thinking_delta` | `text` | Reasoning content | +| `tool_call_delta` | `name`, `args` | Tool call forming | +| `tool_execute` | `id`, `name`, `args` | Tool executing | +| `tool_result` | `id`, `name`, `result` | Tool returned | +| `complete` | `data` | Response finished | + +--- + +## TraceEvent + +::: bond.trace.TraceEvent + options: + show_source: true + +--- + +## TraceMeta + +::: bond.trace.TraceMeta + options: + show_source: true + +--- + +## TraceStorageProtocol + +::: bond.trace.TraceStorageProtocol + options: + show_source: true + +--- + +## JSONFileTraceStore + +::: bond.trace.JSONFileTraceStore + options: + show_source: false + +--- + +## create_capture_handlers + +::: bond.trace.create_capture_handlers + options: + show_source: false + +--- + +## finalize_capture + +::: bond.trace.finalize_capture + options: + show_source: false + +--- + +## TraceReplayer + +::: bond.trace.TraceReplayer + options: + show_source: true diff --git a/docs/architecture.md b/docs/architecture.md index f0155b6..a768d1b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -245,6 +245,137 @@ handlers = create_print_handlers( | `create_sse_handlers` | One-way streaming to browsers | | `create_print_handlers` | CLI tools, debugging, testing | +## Trace Persistence + +Bond's trace system captures all StreamHandlers events during execution for later analysis and replay. This enables forensic debugging, auditing, and side-by-side run comparison. + +### Overview + +```mermaid +flowchart LR + subgraph Capture + A[BondAgent.ask] --> B[StreamHandlers] + B --> C[TraceEvent] + C --> D[TraceStorage] + end + + subgraph Replay + D --> E[TraceReplayer] + E --> F[step/seek/iterate] + end +``` + +### TraceEvent + +Every callback is captured as a `TraceEvent`: + +```python +@dataclass(frozen=True) +class TraceEvent: + trace_id: str # Unique trace identifier + sequence: int # 0-indexed event order + timestamp: float # Monotonic clock (perf_counter) + wall_time: datetime # Wall clock time (UTC) + event_type: str # One of 8 event types + payload: dict[str, Any] # Event-specific data +``` + +Event types map directly to StreamHandlers callbacks: + +| Event Type | Payload Keys | Description | +|------------|--------------|-------------| +| `block_start` | `kind`, `index` | New block started | +| `block_end` | `kind`, `index` | Block finished | +| `text_delta` | `text` | Incremental text | +| `thinking_delta` | `text` | Reasoning content | +| `tool_call_delta` | `name`, `args` | Tool call forming | +| `tool_execute` | `id`, `name`, `args` | Tool executing | +| `tool_result` | `id`, `name`, `result` | Tool returned | +| `complete` | `data` | Response finished | + +### Storage Protocol + +Any storage backend must implement `TraceStorageProtocol`: + +```python +@runtime_checkable +class TraceStorageProtocol(Protocol): + async def save_event(self, event: TraceEvent) -> None: ... + async def finalize_trace(self, trace_id: str, status: str = "complete") -> None: ... + def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: ... + async def list_traces(self, limit: int = 100) -> list[TraceMeta]: ... + async def get_trace_meta(self, trace_id: str) -> TraceMeta | None: ... + async def delete_trace(self, trace_id: str) -> None: ... +``` + +Bond includes `JSONFileTraceStore` for file-based storage: + +```python +from bond import JSONFileTraceStore + +store = JSONFileTraceStore(".bond/traces") +# Creates: .bond/traces/{trace_id}.json (events, newline-delimited) +# Creates: .bond/traces/{trace_id}.meta.json (metadata) +``` + +### Capture Workflow + +Use `create_capture_handlers` to record executions: + +```python +from bond import ( + BondAgent, + JSONFileTraceStore, + create_capture_handlers, + finalize_capture, +) + +# Setup +store = JSONFileTraceStore(".bond/traces") +handlers, trace_id = create_capture_handlers(store) + +# Execute with capture +result = await agent.ask("What is the weather?", handlers=handlers) + +# Finalize (marks trace complete) +await finalize_capture(store, trace_id) +``` + +The handlers record all 8 callback types as `TraceEvent` objects. Events are saved asynchronously to avoid blocking the main execution flow. + +### Replay + +`TraceReplayer` provides debugger-like navigation: + +```python +from bond import TraceReplayer + +replayer = TraceReplayer(store, trace_id) + +# Iterate through all events +async for event in replayer: + print(f"{event.event_type}: {event.payload}") + +# Or step through manually +await replayer.reset() +event = await replayer.step() # Move forward +event = await replayer.step_back() # Move backward +event = await replayer.seek(5) # Jump to position 5 +event = await replayer.current() # Get current without moving + +# Check position +print(f"Position: {replayer.position}/{replayer.total_events}") +``` + +### Use Cases + +| Use Case | How | +|----------|-----| +| Audit agent behavior | Replay production traces to see exactly what happened | +| Debug failures | Step through failed runs to find the problematic event | +| Compare runs | Replay two traces side-by-side to find differences | +| Testing | Capture expected traces and compare against future runs | + ## Tool Architecture Bond uses a protocol-based pattern for tool backends. diff --git a/mkdocs.yml b/mkdocs.yml index a1398ba..a3555bd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,6 +98,7 @@ nav: - Agent: api/agent.md - Utilities: api/utils.md - Tools: api/tools.md + - Trace: api/trace.md extra: social: diff --git a/src/bond/__init__.py b/src/bond/__init__.py index c1f2e2b..c74243b 100644 --- a/src/bond/__init__.py +++ b/src/bond/__init__.py @@ -5,6 +5,15 @@ """ from bond.agent import BondAgent, StreamHandlers +from bond.trace import ( + JSONFileTraceStore, + TraceEvent, + TraceMeta, + TraceReplayer, + TraceStorageProtocol, + create_capture_handlers, + finalize_capture, +) from bond.utils import ( create_print_handlers, create_sse_handlers, @@ -21,4 +30,12 @@ "create_websocket_handlers", "create_sse_handlers", "create_print_handlers", + # Trace + "TraceEvent", + "TraceMeta", + "TraceStorageProtocol", + "JSONFileTraceStore", + "create_capture_handlers", + "finalize_capture", + "TraceReplayer", ] diff --git a/src/bond/agent.py b/src/bond/agent.py index 235160a..b84aa5f 100644 --- a/src/bond/agent.py +++ b/src/bond/agent.py @@ -46,6 +46,7 @@ class StreamHandlers: on_tool_result: Tool has finished and returned data. Example: + ```python handlers = StreamHandlers( on_block_start=lambda kind, idx: print(f"[Start {kind} #{idx}]"), on_text_delta=lambda txt: print(txt, end=""), @@ -53,6 +54,7 @@ class StreamHandlers: on_tool_result=lambda id, name, res: print(f"[Result: {res}]"), on_complete=lambda data: print(f"[Done: {data}]"), ) + ``` """ # Lifecycle: Block open/close @@ -87,6 +89,7 @@ class BondAgent(Generic[T, DepsT]): - Retry handling Example: + ```python agent = BondAgent( name="assistant", instructions="You are helpful.", @@ -101,6 +104,7 @@ class BondAgent(Generic[T, DepsT]): ) response = await agent.ask("Remember my preference", handlers=handlers) + ``` """ name: str diff --git a/src/bond/utils.py b/src/bond/utils.py index 0434dce..dd4f96e 100644 --- a/src/bond/utils.py +++ b/src/bond/utils.py @@ -32,9 +32,11 @@ def create_websocket_handlers( StreamHandlers configured for WebSocket streaming. Example: + ```python async def websocket_handler(ws: WebSocket): handlers = create_websocket_handlers(ws.send_json) await agent.ask("Check the database", handlers=handlers) + ``` Message Types: - {"t": "block_start", "kind": str, "idx": int} diff --git a/tests/unit/trace/__init__.py b/tests/unit/trace/__init__.py new file mode 100644 index 0000000..26d6dc2 --- /dev/null +++ b/tests/unit/trace/__init__.py @@ -0,0 +1 @@ +"""Tests for bond.trace module.""" diff --git a/tests/unit/trace/test_capture.py b/tests/unit/trace/test_capture.py new file mode 100644 index 0000000..4cbd081 --- /dev/null +++ b/tests/unit/trace/test_capture.py @@ -0,0 +1,190 @@ +"""Tests for capture handler factory.""" + +import tempfile +from pathlib import Path + +import pytest + +from bond.trace._models import ( + EVENT_BLOCK_END, + EVENT_BLOCK_START, + EVENT_COMPLETE, + EVENT_TEXT_DELTA, + EVENT_THINKING_DELTA, + EVENT_TOOL_CALL_DELTA, + EVENT_TOOL_EXECUTE, + EVENT_TOOL_RESULT, + STATUS_COMPLETE, + STATUS_FAILED, +) +from bond.trace.backends.json_file import JSONFileTraceStore +from bond.trace.capture import create_capture_handlers, finalize_capture + + +@pytest.fixture +def temp_dir() -> Path: + """Create a temporary directory for trace files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def store(temp_dir: Path) -> JSONFileTraceStore: + """Create a JSONFileTraceStore with temp directory.""" + return JSONFileTraceStore(temp_dir) + + +class TestCreateCaptureHandlers: + """Tests for create_capture_handlers.""" + + def test_returns_handlers_and_trace_id(self, store: JSONFileTraceStore) -> None: + """Test create_capture_handlers returns tuple of handlers and trace_id.""" + handlers, trace_id = create_capture_handlers(store) + + assert handlers is not None + assert trace_id is not None + assert len(trace_id) > 0 + + def test_auto_generates_trace_id(self, store: JSONFileTraceStore) -> None: + """Test trace_id is auto-generated when not provided.""" + _, trace_id1 = create_capture_handlers(store) + _, trace_id2 = create_capture_handlers(store) + + assert trace_id1 != trace_id2 + + def test_uses_provided_trace_id(self, store: JSONFileTraceStore) -> None: + """Test uses provided trace_id.""" + _, trace_id = create_capture_handlers(store, trace_id="custom-id") + assert trace_id == "custom-id" + + def test_handlers_have_all_callbacks(self, store: JSONFileTraceStore) -> None: + """Test handlers have all 8 callback functions.""" + handlers, _ = create_capture_handlers(store) + + assert handlers.on_block_start is not None + assert handlers.on_block_end is not None + assert handlers.on_text_delta is not None + assert handlers.on_thinking_delta is not None + assert handlers.on_tool_call_delta is not None + assert handlers.on_tool_execute is not None + assert handlers.on_tool_result is not None + assert handlers.on_complete is not None + + +class TestHandlerCallbacks: + """Tests for individual handler callbacks recording events.""" + + async def test_on_block_start_records_event(self, store: JSONFileTraceStore) -> None: + """Test on_block_start records block_start event.""" + handlers, trace_id = create_capture_handlers(store) + + # Call the handler (sync) + handlers.on_block_start("text", 0) # type: ignore[misc] + + # Wait for async task to complete + import asyncio + + await asyncio.sleep(0.1) + + events = [e async for e in store.load_trace(trace_id)] + assert len(events) >= 1 + assert events[0].event_type == EVENT_BLOCK_START + assert events[0].payload == {"kind": "text", "index": 0} + + async def test_on_text_delta_records_event(self, store: JSONFileTraceStore) -> None: + """Test on_text_delta records text_delta event.""" + handlers, trace_id = create_capture_handlers(store) + + handlers.on_text_delta("Hello") # type: ignore[misc] + + import asyncio + + await asyncio.sleep(0.1) + + events = [e async for e in store.load_trace(trace_id)] + assert any(e.event_type == EVENT_TEXT_DELTA and e.payload == {"text": "Hello"} for e in events) + + async def test_sequence_numbers_unique(self, store: JSONFileTraceStore) -> None: + """Test events have unique sequence numbers (0-indexed).""" + handlers, trace_id = create_capture_handlers(store) + + handlers.on_block_start("text", 0) # type: ignore[misc] + handlers.on_text_delta("a") # type: ignore[misc] + handlers.on_text_delta("b") # type: ignore[misc] + handlers.on_block_end("text", 0) # type: ignore[misc] + + import asyncio + + await asyncio.sleep(0.2) + + events = [e async for e in store.load_trace(trace_id)] + sequences = sorted([e.sequence for e in events]) + # All sequences present (async saves may complete out of order) + assert sequences == list(range(len(sequences))) + + async def test_all_callbacks_record_correct_types( + self, store: JSONFileTraceStore + ) -> None: + """Test all 8 callbacks record correct event types.""" + handlers, trace_id = create_capture_handlers(store) + + handlers.on_block_start("text", 0) # type: ignore[misc] + handlers.on_text_delta("Hello") # type: ignore[misc] + handlers.on_thinking_delta("thinking...") # type: ignore[misc] + handlers.on_tool_call_delta("search", '{"q":') # type: ignore[misc] + handlers.on_tool_execute("tool-1", "search", {"q": "test"}) # type: ignore[misc] + handlers.on_tool_result("tool-1", "search", "result") # type: ignore[misc] + handlers.on_block_end("text", 0) # type: ignore[misc] + handlers.on_complete({"answer": "42"}) # type: ignore[misc] + + import asyncio + + await asyncio.sleep(0.3) + + events = [e async for e in store.load_trace(trace_id)] + event_types = [e.event_type for e in events] + + assert EVENT_BLOCK_START in event_types + assert EVENT_TEXT_DELTA in event_types + assert EVENT_THINKING_DELTA in event_types + assert EVENT_TOOL_CALL_DELTA in event_types + assert EVENT_TOOL_EXECUTE in event_types + assert EVENT_TOOL_RESULT in event_types + assert EVENT_BLOCK_END in event_types + assert EVENT_COMPLETE in event_types + + +class TestFinalizeCapture: + """Tests for finalize_capture.""" + + async def test_finalize_capture_marks_complete( + self, store: JSONFileTraceStore + ) -> None: + """Test finalize_capture marks trace as complete.""" + handlers, trace_id = create_capture_handlers(store) + handlers.on_text_delta("test") # type: ignore[misc] + + import asyncio + + await asyncio.sleep(0.1) + await finalize_capture(store, trace_id) + + meta = await store.get_trace_meta(trace_id) + assert meta is not None + assert meta.status == STATUS_COMPLETE + + async def test_finalize_capture_with_failed_status( + self, store: JSONFileTraceStore + ) -> None: + """Test finalize_capture with failed status.""" + handlers, trace_id = create_capture_handlers(store) + handlers.on_text_delta("test") # type: ignore[misc] + + import asyncio + + await asyncio.sleep(0.1) + await finalize_capture(store, trace_id, STATUS_FAILED) + + meta = await store.get_trace_meta(trace_id) + assert meta is not None + assert meta.status == STATUS_FAILED diff --git a/tests/unit/trace/test_json_store.py b/tests/unit/trace/test_json_store.py new file mode 100644 index 0000000..fd91a23 --- /dev/null +++ b/tests/unit/trace/test_json_store.py @@ -0,0 +1,239 @@ +"""Tests for JSONFileTraceStore backend.""" + +import json +import tempfile +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from bond.trace._models import ( + EVENT_BLOCK_START, + EVENT_COMPLETE, + EVENT_TEXT_DELTA, + STATUS_COMPLETE, + STATUS_FAILED, + TraceEvent, +) +from bond.trace.backends.json_file import JSONFileTraceStore + + +@pytest.fixture +def temp_dir() -> Path: + """Create a temporary directory for trace files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def store(temp_dir: Path) -> JSONFileTraceStore: + """Create a JSONFileTraceStore with temp directory.""" + return JSONFileTraceStore(temp_dir) + + +def make_event( + trace_id: str, + sequence: int, + event_type: str = EVENT_TEXT_DELTA, + payload: dict | None = None, +) -> TraceEvent: + """Helper to create TraceEvent.""" + return TraceEvent( + trace_id=trace_id, + sequence=sequence, + timestamp=float(sequence), + wall_time=datetime.now(UTC), + event_type=event_type, + payload=payload or {}, + ) + + +class TestSaveEvent: + """Tests for save_event.""" + + async def test_save_event_creates_file(self, store: JSONFileTraceStore) -> None: + """Test save_event creates events file.""" + event = make_event("test-trace", 0) + await store.save_event(event) + + events_path = store.base_path / "test-trace.json" + assert events_path.exists() + + async def test_save_event_appends_to_file(self, store: JSONFileTraceStore) -> None: + """Test save_event appends events to file.""" + await store.save_event(make_event("test-trace", 0, payload={"text": "a"})) + await store.save_event(make_event("test-trace", 1, payload={"text": "b"})) + await store.save_event(make_event("test-trace", 2, payload={"text": "c"})) + + events_path = store.base_path / "test-trace.json" + lines = events_path.read_text().strip().split("\n") + assert len(lines) == 3 + + # Verify each line is valid JSON + for line in lines: + data = json.loads(line) + assert "trace_id" in data + + async def test_save_event_creates_meta_file(self, store: JSONFileTraceStore) -> None: + """Test save_event creates meta file.""" + await store.save_event(make_event("test-trace", 0)) + + meta_path = store.base_path / "test-trace.meta.json" + assert meta_path.exists() + + meta = json.loads(meta_path.read_text()) + assert meta["trace_id"] == "test-trace" + assert meta["event_count"] == 1 + + async def test_save_event_updates_event_count(self, store: JSONFileTraceStore) -> None: + """Test save_event updates event count in meta.""" + await store.save_event(make_event("test-trace", 0)) + await store.save_event(make_event("test-trace", 1)) + await store.save_event(make_event("test-trace", 2)) + + meta_path = store.base_path / "test-trace.meta.json" + meta = json.loads(meta_path.read_text()) + assert meta["event_count"] == 3 + + +class TestLoadTrace: + """Tests for load_trace.""" + + async def test_load_trace_returns_events_in_order( + self, store: JSONFileTraceStore + ) -> None: + """Test load_trace returns events in sequence order.""" + await store.save_event(make_event("test-trace", 0, payload={"i": 0})) + await store.save_event(make_event("test-trace", 1, payload={"i": 1})) + await store.save_event(make_event("test-trace", 2, payload={"i": 2})) + + events = [e async for e in store.load_trace("test-trace")] + + assert len(events) == 3 + for i, event in enumerate(events): + assert event.sequence == i + assert event.payload == {"i": i} + + async def test_load_trace_raises_for_unknown(self, store: JSONFileTraceStore) -> None: + """Test load_trace raises KeyError for unknown trace.""" + with pytest.raises(KeyError, match="Trace not found"): + _ = [e async for e in store.load_trace("nonexistent")] + + +class TestFinalizeTrace: + """Tests for finalize_trace.""" + + async def test_finalize_trace_sets_status(self, store: JSONFileTraceStore) -> None: + """Test finalize_trace sets status in meta file.""" + await store.save_event(make_event("test-trace", 0)) + await store.finalize_trace("test-trace", STATUS_COMPLETE) + + meta_path = store.base_path / "test-trace.meta.json" + meta = json.loads(meta_path.read_text()) + assert meta["status"] == STATUS_COMPLETE + + async def test_finalize_trace_failed_status(self, store: JSONFileTraceStore) -> None: + """Test finalize_trace with failed status.""" + await store.save_event(make_event("test-trace", 0)) + await store.finalize_trace("test-trace", STATUS_FAILED) + + meta_path = store.base_path / "test-trace.meta.json" + meta = json.loads(meta_path.read_text()) + assert meta["status"] == STATUS_FAILED + + async def test_finalize_trace_raises_for_unknown( + self, store: JSONFileTraceStore + ) -> None: + """Test finalize_trace raises KeyError for unknown trace.""" + with pytest.raises(KeyError, match="Trace not found"): + await store.finalize_trace("nonexistent") + + +class TestListTraces: + """Tests for list_traces.""" + + async def test_list_traces_returns_empty(self, store: JSONFileTraceStore) -> None: + """Test list_traces returns empty list when no traces.""" + traces = await store.list_traces() + assert traces == [] + + async def test_list_traces_returns_traces(self, store: JSONFileTraceStore) -> None: + """Test list_traces returns all traces.""" + await store.save_event(make_event("trace-1", 0)) + await store.save_event(make_event("trace-2", 0)) + + traces = await store.list_traces() + + assert len(traces) == 2 + trace_ids = {t.trace_id for t in traces} + assert trace_ids == {"trace-1", "trace-2"} + + async def test_list_traces_respects_limit(self, store: JSONFileTraceStore) -> None: + """Test list_traces respects limit parameter.""" + for i in range(5): + await store.save_event(make_event(f"trace-{i}", 0)) + + traces = await store.list_traces(limit=3) + assert len(traces) == 3 + + async def test_list_traces_sorted_newest_first( + self, store: JSONFileTraceStore + ) -> None: + """Test list_traces returns traces sorted by creation time.""" + # Create traces with different timestamps + await store.save_event(make_event("old-trace", 0)) + await store.save_event(make_event("new-trace", 0)) + + traces = await store.list_traces() + + # Newest should be first + assert traces[0].trace_id == "new-trace" + + +class TestDeleteTrace: + """Tests for delete_trace.""" + + async def test_delete_trace_removes_files(self, store: JSONFileTraceStore) -> None: + """Test delete_trace removes both event and meta files.""" + await store.save_event(make_event("test-trace", 0)) + + events_path = store.base_path / "test-trace.json" + meta_path = store.base_path / "test-trace.meta.json" + assert events_path.exists() + assert meta_path.exists() + + await store.delete_trace("test-trace") + + assert not events_path.exists() + assert not meta_path.exists() + + async def test_delete_trace_raises_for_unknown( + self, store: JSONFileTraceStore + ) -> None: + """Test delete_trace raises KeyError for unknown trace.""" + with pytest.raises(KeyError, match="Trace not found"): + await store.delete_trace("nonexistent") + + +class TestGetTraceMeta: + """Tests for get_trace_meta.""" + + async def test_get_trace_meta_returns_meta(self, store: JSONFileTraceStore) -> None: + """Test get_trace_meta returns TraceMeta.""" + await store.save_event(make_event("test-trace", 0)) + await store.save_event(make_event("test-trace", 1)) + await store.finalize_trace("test-trace", STATUS_COMPLETE) + + meta = await store.get_trace_meta("test-trace") + + assert meta is not None + assert meta.trace_id == "test-trace" + assert meta.event_count == 2 + assert meta.status == STATUS_COMPLETE + + async def test_get_trace_meta_returns_none_for_unknown( + self, store: JSONFileTraceStore + ) -> None: + """Test get_trace_meta returns None for unknown trace.""" + meta = await store.get_trace_meta("nonexistent") + assert meta is None diff --git a/tests/unit/trace/test_models.py b/tests/unit/trace/test_models.py new file mode 100644 index 0000000..d135c98 --- /dev/null +++ b/tests/unit/trace/test_models.py @@ -0,0 +1,207 @@ +"""Tests for trace event models.""" + +from datetime import UTC, datetime + +import pytest + +from bond.trace._models import ( + ALL_EVENT_TYPES, + EVENT_BLOCK_END, + EVENT_BLOCK_START, + EVENT_COMPLETE, + EVENT_TEXT_DELTA, + EVENT_THINKING_DELTA, + EVENT_TOOL_CALL_DELTA, + EVENT_TOOL_EXECUTE, + EVENT_TOOL_RESULT, + STATUS_COMPLETE, + STATUS_IN_PROGRESS, + TraceEvent, + TraceMeta, +) + + +class TestTraceEvent: + """Tests for TraceEvent dataclass.""" + + def test_trace_event_creation(self) -> None: + """Test TraceEvent can be created with all fields.""" + event = TraceEvent( + trace_id="test-trace-id", + sequence=0, + timestamp=1.5, + wall_time=datetime(2024, 6, 15, 10, 30, tzinfo=UTC), + event_type=EVENT_TEXT_DELTA, + payload={"text": "Hello"}, + ) + assert event.trace_id == "test-trace-id" + assert event.sequence == 0 + assert event.timestamp == 1.5 + assert event.event_type == EVENT_TEXT_DELTA + assert event.payload == {"text": "Hello"} + + def test_trace_event_to_dict(self) -> None: + """Test TraceEvent serialization to dict.""" + wall_time = datetime(2024, 6, 15, 10, 30, tzinfo=UTC) + event = TraceEvent( + trace_id="test-id", + sequence=5, + timestamp=2.5, + wall_time=wall_time, + event_type=EVENT_TOOL_EXECUTE, + payload={"id": "tool-1", "name": "search", "args": {"q": "test"}}, + ) + + result = event.to_dict() + + assert result["trace_id"] == "test-id" + assert result["sequence"] == 5 + assert result["timestamp"] == 2.5 + assert result["wall_time"] == wall_time.isoformat() + assert result["event_type"] == EVENT_TOOL_EXECUTE + assert result["payload"] == {"id": "tool-1", "name": "search", "args": {"q": "test"}} + + def test_trace_event_from_dict(self) -> None: + """Test TraceEvent deserialization from dict.""" + data = { + "trace_id": "test-id", + "sequence": 3, + "timestamp": 1.0, + "wall_time": "2024-06-15T10:30:00+00:00", + "event_type": EVENT_BLOCK_START, + "payload": {"kind": "text", "index": 0}, + } + + event = TraceEvent.from_dict(data) + + assert event.trace_id == "test-id" + assert event.sequence == 3 + assert event.timestamp == 1.0 + assert event.wall_time == datetime(2024, 6, 15, 10, 30, tzinfo=UTC) + assert event.event_type == EVENT_BLOCK_START + assert event.payload == {"kind": "text", "index": 0} + + def test_trace_event_roundtrip(self) -> None: + """Test TraceEvent to_dict/from_dict roundtrip.""" + original = TraceEvent( + trace_id="roundtrip-test", + sequence=10, + timestamp=5.5, + wall_time=datetime(2024, 1, 1, 12, 0, tzinfo=UTC), + event_type=EVENT_COMPLETE, + payload={"data": {"answer": "42"}}, + ) + + serialized = original.to_dict() + restored = TraceEvent.from_dict(serialized) + + assert restored == original + + def test_trace_event_from_dict_with_z_suffix(self) -> None: + """Test from_dict handles Z timezone suffix.""" + data = { + "trace_id": "z-test", + "sequence": 0, + "timestamp": 0.0, + "wall_time": "2024-06-15T10:30:00Z", + "event_type": EVENT_TEXT_DELTA, + "payload": {}, + } + + event = TraceEvent.from_dict(data) + assert event.wall_time.tzinfo is not None + + @pytest.mark.parametrize("event_type", list(ALL_EVENT_TYPES)) + def test_all_event_types_serialize(self, event_type: str) -> None: + """Test all 8 event types serialize correctly.""" + event = TraceEvent( + trace_id="type-test", + sequence=0, + timestamp=0.0, + wall_time=datetime.now(UTC), + event_type=event_type, + payload={}, + ) + + serialized = event.to_dict() + assert serialized["event_type"] == event_type + + restored = TraceEvent.from_dict(serialized) + assert restored.event_type == event_type + + +class TestTraceMeta: + """Tests for TraceMeta dataclass.""" + + def test_trace_meta_creation(self) -> None: + """Test TraceMeta can be created.""" + meta = TraceMeta( + trace_id="test-trace", + created_at=datetime(2024, 6, 15, tzinfo=UTC), + event_count=42, + status=STATUS_COMPLETE, + ) + assert meta.trace_id == "test-trace" + assert meta.event_count == 42 + assert meta.status == STATUS_COMPLETE + + def test_trace_meta_to_dict(self) -> None: + """Test TraceMeta serialization.""" + created = datetime(2024, 6, 15, 12, 0, tzinfo=UTC) + meta = TraceMeta( + trace_id="meta-test", + created_at=created, + event_count=100, + status=STATUS_IN_PROGRESS, + ) + + result = meta.to_dict() + + assert result["trace_id"] == "meta-test" + assert result["created_at"] == created.isoformat() + assert result["event_count"] == 100 + assert result["status"] == STATUS_IN_PROGRESS + + def test_trace_meta_from_dict(self) -> None: + """Test TraceMeta deserialization.""" + data = { + "trace_id": "from-dict-test", + "created_at": "2024-06-15T12:00:00+00:00", + "event_count": 50, + "status": STATUS_COMPLETE, + } + + meta = TraceMeta.from_dict(data) + + assert meta.trace_id == "from-dict-test" + assert meta.created_at == datetime(2024, 6, 15, 12, 0, tzinfo=UTC) + assert meta.event_count == 50 + assert meta.status == STATUS_COMPLETE + + def test_trace_meta_roundtrip(self) -> None: + """Test TraceMeta to_dict/from_dict roundtrip.""" + original = TraceMeta( + trace_id="roundtrip", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + event_count=25, + status=STATUS_COMPLETE, + ) + + restored = TraceMeta.from_dict(original.to_dict()) + assert restored == original + + +class TestEventTypeConstants: + """Tests for event type constants.""" + + def test_all_event_types_set(self) -> None: + """Test ALL_EVENT_TYPES contains all 8 types.""" + assert len(ALL_EVENT_TYPES) == 8 + assert EVENT_BLOCK_START in ALL_EVENT_TYPES + assert EVENT_BLOCK_END in ALL_EVENT_TYPES + assert EVENT_TEXT_DELTA in ALL_EVENT_TYPES + assert EVENT_THINKING_DELTA in ALL_EVENT_TYPES + assert EVENT_TOOL_CALL_DELTA in ALL_EVENT_TYPES + assert EVENT_TOOL_EXECUTE in ALL_EVENT_TYPES + assert EVENT_TOOL_RESULT in ALL_EVENT_TYPES + assert EVENT_COMPLETE in ALL_EVENT_TYPES diff --git a/tests/unit/trace/test_replay.py b/tests/unit/trace/test_replay.py new file mode 100644 index 0000000..b6fe4fb --- /dev/null +++ b/tests/unit/trace/test_replay.py @@ -0,0 +1,258 @@ +"""Tests for TraceReplayer.""" + +import tempfile +from datetime import UTC, datetime +from pathlib import Path + +import pytest + +from bond.trace._models import EVENT_TEXT_DELTA, TraceEvent +from bond.trace.backends.json_file import JSONFileTraceStore +from bond.trace.replay import TraceReplayer + + +@pytest.fixture +def temp_dir() -> Path: + """Create a temporary directory for trace files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + +@pytest.fixture +def store(temp_dir: Path) -> JSONFileTraceStore: + """Create a JSONFileTraceStore with temp directory.""" + return JSONFileTraceStore(temp_dir) + + +def make_event(trace_id: str, sequence: int, text: str = "") -> TraceEvent: + """Helper to create TraceEvent.""" + return TraceEvent( + trace_id=trace_id, + sequence=sequence, + timestamp=float(sequence), + wall_time=datetime.now(UTC), + event_type=EVENT_TEXT_DELTA, + payload={"text": text or f"event-{sequence}"}, + ) + + +async def create_trace_with_events( + store: JSONFileTraceStore, trace_id: str, count: int +) -> None: + """Helper to create a trace with specified number of events.""" + for i in range(count): + await store.save_event(make_event(trace_id, i)) + + +class TestTraceReplayerIteration: + """Tests for async iteration.""" + + async def test_iteration_yields_all_events(self, store: JSONFileTraceStore) -> None: + """Test async for yields all events.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + events = [e async for e in replayer] + + assert len(events) == 5 + + async def test_iteration_yields_events_in_order( + self, store: JSONFileTraceStore + ) -> None: + """Test events yielded in sequence order.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + events = [e async for e in replayer] + + sequences = [e.sequence for e in events] + assert sequences == [0, 1, 2, 3, 4] + + async def test_iteration_with_empty_trace(self, store: JSONFileTraceStore) -> None: + """Test iteration with trace that has no events raises KeyError.""" + replayer = TraceReplayer(store, "nonexistent") + + with pytest.raises(KeyError): + _ = [e async for e in replayer] + + +class TestTraceReplayerStep: + """Tests for step() method.""" + + async def test_step_returns_next_event(self, store: JSONFileTraceStore) -> None: + """Test step() returns next event.""" + await create_trace_with_events(store, "test-trace", 3) + + replayer = TraceReplayer(store, "test-trace") + event = await replayer.step() + + assert event is not None + assert event.sequence == 0 + + async def test_step_advances_position(self, store: JSONFileTraceStore) -> None: + """Test step() advances position.""" + await create_trace_with_events(store, "test-trace", 3) + + replayer = TraceReplayer(store, "test-trace") + assert replayer.position == 0 + + await replayer.step() + assert replayer.position == 1 + + await replayer.step() + assert replayer.position == 2 + + async def test_step_returns_none_at_end(self, store: JSONFileTraceStore) -> None: + """Test step() returns None when at end of trace.""" + await create_trace_with_events(store, "test-trace", 2) + + replayer = TraceReplayer(store, "test-trace") + await replayer.step() # 0 + await replayer.step() # 1 + result = await replayer.step() # past end + + assert result is None + + +class TestTraceReplayerStepBack: + """Tests for step_back() method.""" + + async def test_step_back_returns_previous_event( + self, store: JSONFileTraceStore + ) -> None: + """Test step_back() returns previous event.""" + await create_trace_with_events(store, "test-trace", 3) + + replayer = TraceReplayer(store, "test-trace") + await replayer.step() # position 1 + await replayer.step() # position 2 + + event = await replayer.step_back() + + assert event is not None + assert event.sequence == 1 + assert replayer.position == 1 + + async def test_step_back_returns_none_at_start( + self, store: JSONFileTraceStore + ) -> None: + """Test step_back() returns None at start.""" + await create_trace_with_events(store, "test-trace", 3) + + replayer = TraceReplayer(store, "test-trace") + result = await replayer.step_back() + + assert result is None + assert replayer.position == 0 + + +class TestTraceReplayerSeek: + """Tests for seek() method.""" + + async def test_seek_jumps_to_position(self, store: JSONFileTraceStore) -> None: + """Test seek() jumps to specific position.""" + await create_trace_with_events(store, "test-trace", 10) + + replayer = TraceReplayer(store, "test-trace") + event = await replayer.seek(5) + + assert event is not None + assert event.sequence == 5 + assert replayer.position == 5 + + async def test_seek_clamps_to_bounds(self, store: JSONFileTraceStore) -> None: + """Test seek() clamps to valid range.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + + # Seek past end + await replayer.seek(100) + assert replayer.position == 5 # Clamped to len + + # Seek before start + await replayer.seek(-10) + assert replayer.position == 0 + + async def test_seek_returns_none_at_end(self, store: JSONFileTraceStore) -> None: + """Test seek() returns None when seeking to end.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + event = await replayer.seek(5) + + assert event is None + + +class TestTraceReplayerProperties: + """Tests for position and total_events properties.""" + + async def test_position_starts_at_zero(self, store: JSONFileTraceStore) -> None: + """Test position starts at 0.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + assert replayer.position == 0 + + async def test_total_events_none_before_load( + self, store: JSONFileTraceStore + ) -> None: + """Test total_events is None before loading.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + assert replayer.total_events is None + + async def test_total_events_after_load(self, store: JSONFileTraceStore) -> None: + """Test total_events returns count after loading.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + await replayer.step() # This triggers load + + assert replayer.total_events == 5 + + +class TestTraceReplayerReset: + """Tests for reset() method.""" + + async def test_reset_moves_to_start(self, store: JSONFileTraceStore) -> None: + """Test reset() moves position to 0.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + await replayer.seek(3) + assert replayer.position == 3 + + await replayer.reset() + assert replayer.position == 0 + + +class TestTraceReplayerCurrent: + """Tests for current() method.""" + + async def test_current_returns_event_at_position( + self, store: JSONFileTraceStore + ) -> None: + """Test current() returns event at current position.""" + await create_trace_with_events(store, "test-trace", 5) + + replayer = TraceReplayer(store, "test-trace") + await replayer.seek(2) + + event = await replayer.current() + + assert event is not None + assert event.sequence == 2 + # Position shouldn't change + assert replayer.position == 2 + + async def test_current_returns_none_at_end(self, store: JSONFileTraceStore) -> None: + """Test current() returns None at end.""" + await create_trace_with_events(store, "test-trace", 3) + + replayer = TraceReplayer(store, "test-trace") + await replayer.seek(3) # Past last event + + event = await replayer.current() + assert event is None From 5d7f4dd58c267dacd326db40cd204bd1e9cd8fc1 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 17:07:24 +0000 Subject: [PATCH 10/13] chore(flow): mark fn-3.5 as done All fn-3 tasks complete. Forensic Features epic finished. Co-Authored-By: Claude Opus 4.5 --- .flow/tasks/fn-3.5.json | 9 +++++++-- .flow/tasks/fn-3.5.md | 26 +++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.flow/tasks/fn-3.5.json b/.flow/tasks/fn-3.5.json index fc3ac33..a62dae6 100644 --- a/.flow/tasks/fn-3.5.json +++ b/.flow/tasks/fn-3.5.json @@ -8,10 +8,15 @@ "fn-3.4" ], "epic": "fn-3", + "evidence": { + "build": "mkdocs build --strict passes", + "commit": "db799be", + "tests": "62 passed" + }, "id": "fn-3.5", "priority": null, "spec_path": ".flow/tasks/fn-3.5.md", - "status": "in_progress", + "status": "done", "title": "Add tests and documentation", - "updated_at": "2026-01-24T17:01:45.095228Z" + "updated_at": "2026-01-24T17:07:09.561399Z" } diff --git a/.flow/tasks/fn-3.5.md b/.flow/tasks/fn-3.5.md index 4e34e8c..497e918 100644 --- a/.flow/tasks/fn-3.5.md +++ b/.flow/tasks/fn-3.5.md @@ -79,9 +79,29 @@ from bond.trace import ( - [ ] `from bond import create_capture_handlers, TraceReplayer` works - [ ] `mkdocs build --strict` passes ## Done summary -TBD +# Task fn-3.5: Add tests and documentation +## Completed Work + +1. **Unit tests (62 tests, all passing)**: + - `tests/unit/trace/test_models.py` - TraceEvent/TraceMeta serialization + - `tests/unit/trace/test_json_store.py` - JSONFileTraceStore operations + - `tests/unit/trace/test_capture.py` - create_capture_handlers factory + - `tests/unit/trace/test_replay.py` - TraceReplayer navigation + +2. **Package exports**: + - Updated `src/bond/__init__.py` with all trace types + +3. **Documentation**: + - Created `docs/api/trace.md` with API reference + - Added Trace Persistence section to `docs/architecture.md` + - Added trace to mkdocs.yml navigation + +## Acceptance Criteria Met +- All 62 unit tests pass +- mkdocs build --strict passes +- ruff and mypy pass on trace module ## Evidence - Commits: -- Tests: -- PRs: +- Tests: 62 passed +- PRs: \ No newline at end of file From 841159f1eaefb0623cb9b1fa73739c5450a77cc1 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 17:23:25 +0000 Subject: [PATCH 11/13] docs: fix docstring code block formatting and add GitHunter guide - Add proper ```python and ```json fencing to all Example: sections in docstrings so mkdocstrings renders them with syntax highlighting - Fix Message Types section in utils.py to use ```json block - Create docs/guides/githunter.md with usage guide for GitHunter toolset - Add Guides section to mkdocs.yml navigation Files fixed: - src/bond/utils.py (3 sections) - src/bond/trace/capture.py (2 examples) - src/bond/trace/backends/json_file.py (1 example) - src/bond/tools/githunter/tools.py (3 examples) - src/bond/tools/schema/tools.py (4 examples) - src/bond/tools/memory/tools.py (4 examples) - src/bond/tools/memory/backends/pgvector.py (1 example) - src/bond/tools/memory/backends/qdrant.py (1 example) - src/bond/tools/memory/backends/__init__.py (1 example) Co-Authored-By: Claude Opus 4.5 --- docs/guides/githunter.md | 156 +++++++++++++++++++++ mkdocs.yml | 2 + src/bond/tools/githunter/tools.py | 6 + src/bond/tools/memory/backends/__init__.py | 2 + src/bond/tools/memory/backends/pgvector.py | 2 + src/bond/tools/memory/backends/qdrant.py | 2 + src/bond/tools/memory/tools.py | 8 ++ src/bond/tools/schema/tools.py | 8 ++ src/bond/trace/backends/json_file.py | 2 + src/bond/trace/capture.py | 4 + src/bond/utils.py | 24 ++-- 11 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 docs/guides/githunter.md diff --git a/docs/guides/githunter.md b/docs/guides/githunter.md new file mode 100644 index 0000000..52d977c --- /dev/null +++ b/docs/guides/githunter.md @@ -0,0 +1,156 @@ +# GitHunter Toolset + +The GitHunter toolset provides forensic code ownership analysis for AI agents. It enables agents to understand who wrote code, why changes were made, and who the experts are for any file. + +## Overview + +GitHunter wraps git operations to answer questions like: + +- **"Who wrote this line?"** → `blame_line` returns author, commit, date, message +- **"Why was this changed?"** → `find_pr_discussion` returns PR title, body, comments +- **"Who should review this?"** → `get_file_experts` returns ranked contributors + +## Quick Start + +```python +from bond import BondAgent +from bond.tools.githunter import githunter_toolset, GitHunterAdapter + +# Create the GitHunter adapter (implements GitHunterProtocol) +adapter = GitHunterAdapter() + +# Create agent with GitHunter tools +agent = BondAgent( + name="code-analyst", + instructions="""You are a code analysis assistant. + Use GitHunter tools to understand code ownership and history.""", + model="anthropic:claude-sonnet-4-20250514", + toolsets=[githunter_toolset], + deps=adapter, +) + +# Ask questions about code +result = await agent.ask( + "Who wrote line 42 of src/auth/login.py and why?" +) +``` + +## Available Tools + +### blame_line + +Get blame information for a specific line of code. + +```python +blame_line({ + "repo_path": "/path/to/repo", + "file_path": "src/main.py", + "line_no": 42 +}) +``` + +**Returns:** `BlameResult` with: + +- `author`: Who last modified the line +- `commit_hash`: The commit that changed it +- `commit_date`: When it was changed +- `commit_message`: Why it was changed + +### find_pr_discussion + +Find the pull request discussion for a commit. + +```python +find_pr_discussion({ + "repo_path": "/path/to/repo", + "commit_hash": "abc123def" +}) +``` + +**Returns:** `PRDiscussion` with: + +- `pr_number`: The PR number +- `title`: PR title +- `body`: PR description +- `comments`: Discussion comments + +### get_file_experts + +Identify experts for a file based on commit frequency. + +```python +get_file_experts({ + "repo_path": "/path/to/repo", + "file_path": "src/auth/login.py", + "window_days": 90, + "limit": 3 +}) +``` + +**Returns:** List of `FileExpert` with: + +- `author`: Contributor info +- `commit_count`: Number of commits to this file +- `last_commit_date`: Most recent contribution + +## Use Cases + +| Scenario | Tool | Example Query | +|----------|------|---------------| +| Debug a bug | `blame_line` | "Who wrote this problematic line?" | +| Understand a change | `find_pr_discussion` | "What was the reasoning behind this commit?" | +| Find reviewer | `get_file_experts` | "Who should review changes to this file?" | +| Code review | `blame_line` + `find_pr_discussion` | "Explain the history of this function" | + +## Protocol Pattern + +GitHunter uses Bond's protocol pattern for backend flexibility: + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class GitHunterProtocol(Protocol): + """Protocol for git forensics backends.""" + + async def blame_line( + self, + repo_path: Path, + file_path: str, + line_no: int, + ) -> BlameResult: ... + + async def find_pr_discussion( + self, + repo_path: Path, + commit_hash: str, + ) -> PRDiscussion | None: ... + + async def get_expert_for_file( + self, + repo_path: Path, + file_path: str, + window_days: int = 90, + limit: int = 5, + ) -> list[FileExpert]: ... +``` + +This allows you to implement custom backends (e.g., GitHub API, GitLab API) while keeping the same tool interface. + +## Error Handling + +All tools return `Error` on failure: + +```python +from bond.tools.githunter import Error + +result = await agent.ask("Who wrote line 9999 of nonexistent.py?") +# Agent receives Error(description="File not found: nonexistent.py") +``` + +The agent can handle errors gracefully and ask for clarification. + +## See Also + +- [API Reference: GitHunter](../api/tools.md#githunter-toolset) - Full type definitions +- [Architecture](../architecture.md) - Protocol pattern details diff --git a/mkdocs.yml b/mkdocs.yml index a3555bd..47e3e56 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,8 @@ nav: - Home: index.md - Quickstart: quickstart.md - Architecture: architecture.md + - Guides: + - GitHunter: guides/githunter.md - API Reference: - Overview: api/index.md - Agent: api/agent.md diff --git a/src/bond/tools/githunter/tools.py b/src/bond/tools/githunter/tools.py index fdea7c4..e97153a 100644 --- a/src/bond/tools/githunter/tools.py +++ b/src/bond/tools/githunter/tools.py @@ -34,11 +34,13 @@ async def blame_line( - "What was the commit message?" → check commit_message in result Example: + ```python blame_line({ "repo_path": "/path/to/repo", "file_path": "src/main.py", "line_no": 42 }) + ``` Returns: BlameResult with author, commit hash, date, and message, @@ -68,10 +70,12 @@ async def find_pr_discussion( - "Why was this change made?" → read PR description and comments Example: + ```python find_pr_discussion({ "repo_path": "/path/to/repo", "commit_hash": "abc123def" }) + ``` Returns: PRDiscussion with PR number, title, body, and comments, @@ -101,12 +105,14 @@ async def get_file_experts( - "Who to ask about this code?" → contact the experts Example: + ```python get_file_experts({ "repo_path": "/path/to/repo", "file_path": "src/auth/login.py", "window_days": 90, "limit": 3 }) + ``` Returns: List of FileExpert sorted by commit count (descending), diff --git a/src/bond/tools/memory/backends/__init__.py b/src/bond/tools/memory/backends/__init__.py index b3bf042..62c4e30 100644 --- a/src/bond/tools/memory/backends/__init__.py +++ b/src/bond/tools/memory/backends/__init__.py @@ -52,6 +52,7 @@ def create_memory_backend( ValueError: If pgvector selected but no pool provided. Example: + ```python # pgvector (recommended) memory = create_memory_backend( backend_type=MemoryBackendType.PGVECTOR, @@ -63,6 +64,7 @@ def create_memory_backend( backend_type=MemoryBackendType.QDRANT, qdrant_url="http://localhost:6333", ) + ``` """ if backend_type == MemoryBackendType.PGVECTOR: if pool is None: diff --git a/src/bond/tools/memory/backends/pgvector.py b/src/bond/tools/memory/backends/pgvector.py index bf5364b..e1d79ba 100644 --- a/src/bond/tools/memory/backends/pgvector.py +++ b/src/bond/tools/memory/backends/pgvector.py @@ -23,6 +23,7 @@ class PgVectorMemoryStore: - Unified backup/restore with application data Example: + ```python # Inject pool from dataing's AppDatabase store = PgVectorMemoryStore(pool=app_db.pool) @@ -31,6 +32,7 @@ class PgVectorMemoryStore: pool=app_db.pool, embedding_model="openai:text-embedding-3-small", ) + ``` """ def __init__( diff --git a/src/bond/tools/memory/backends/qdrant.py b/src/bond/tools/memory/backends/qdrant.py index 1cc0227..3f84ae5 100644 --- a/src/bond/tools/memory/backends/qdrant.py +++ b/src/bond/tools/memory/backends/qdrant.py @@ -31,6 +31,7 @@ class QdrantMemoryStore: - Zero-refactor provider swapping Example: + ```python # In-memory for development/testing (local embeddings) store = QdrantMemoryStore() @@ -42,6 +43,7 @@ class QdrantMemoryStore: embedding_model="openai:text-embedding-3-small", qdrant_url="http://localhost:6333", ) + ``` """ def __init__( diff --git a/src/bond/tools/memory/tools.py b/src/bond/tools/memory/tools.py index a6add60..137828a 100644 --- a/src/bond/tools/memory/tools.py +++ b/src/bond/tools/memory/tools.py @@ -32,12 +32,14 @@ async def create_memory( - Context: "Store that we discussed the authentication flow" Example: + ```python create_memory({ "content": "User prefers dark mode and compact view", "agent_id": "assistant", "tenant_id": "550e8400-e29b-41d4-a716-446655440000", "tags": ["preferences", "ui"] }) + ``` Returns: The created Memory object with its ID, or an Error if storage failed. @@ -67,12 +69,14 @@ async def search_memories( - Find related: "Search for memories about the project deadline" Example: + ```python search_memories({ "query": "user interface preferences", "tenant_id": "550e8400-e29b-41d4-a716-446655440000", "top_k": 5, "tags": ["preferences"] }) + ``` Returns: List of SearchResult with memories and similarity scores, @@ -102,10 +106,12 @@ async def delete_memory( - Correct mistakes: "Remove the incorrect preference" Example: + ```python delete_memory({ "memory_id": "550e8400-e29b-41d4-a716-446655440000", "tenant_id": "660e8400-e29b-41d4-a716-446655440000" }) + ``` Returns: True if deleted, False if not found, or Error if deletion failed. @@ -129,10 +135,12 @@ async def get_memory( - Check metadata: "What tags does memory X have?" Example: + ```python get_memory({ "memory_id": "550e8400-e29b-41d4-a716-446655440000", "tenant_id": "660e8400-e29b-41d4-a716-446655440000" }) + ``` Returns: The Memory if found, None if not found, or Error if retrieval failed. diff --git a/src/bond/tools/schema/tools.py b/src/bond/tools/schema/tools.py index d14fa6e..2220eef 100644 --- a/src/bond/tools/schema/tools.py +++ b/src/bond/tools/schema/tools.py @@ -33,7 +33,9 @@ async def get_table_schema( - Find keys: "Which columns are primary/partition keys?" Example: + ```python get_table_schema({"table_name": "customers"}) + ``` Returns: Full table schema as JSON with columns, types, keys, etc. @@ -54,7 +56,9 @@ async def list_tables( - Explore schema: "List all tables to understand the data model" Example: + ```python list_tables({}) + ``` Returns: List of table names (may be qualified like schema.table). @@ -74,7 +78,9 @@ async def get_upstream_tables( - Trace issues: "What upstream tables might cause this anomaly?" Example: + ```python get_upstream_tables({"table_name": "orders"}) + ``` Returns: List of upstream table names (data sources for this table). @@ -94,7 +100,9 @@ async def get_downstream_tables( - Assess impact: "What would be affected by this anomaly?" Example: + ```python get_downstream_tables({"table_name": "orders"}) + ``` Returns: List of downstream table names (tables that depend on this one). diff --git a/src/bond/trace/backends/json_file.py b/src/bond/trace/backends/json_file.py index 403d86e..b5e6afa 100644 --- a/src/bond/trace/backends/json_file.py +++ b/src/bond/trace/backends/json_file.py @@ -33,12 +33,14 @@ class JSONFileTraceStore: └── {trace_id}.meta.json # TraceMeta Example: + ```python store = JSONFileTraceStore(".bond/traces") await store.save_event(event) await store.finalize_trace(trace_id) async for event in store.load_trace(trace_id): print(event) + ``` """ def __init__(self, base_path: Path | str = ".bond/traces") -> None: diff --git a/src/bond/trace/capture.py b/src/bond/trace/capture.py index 9f79054..5830881 100644 --- a/src/bond/trace/capture.py +++ b/src/bond/trace/capture.py @@ -47,6 +47,7 @@ def create_capture_handlers( keep trace_id for later replay. Example: + ```python store = JSONFileTraceStore() handlers, trace_id = create_capture_handlers(store) result = await agent.ask("query", handlers=handlers) @@ -55,6 +56,7 @@ def create_capture_handlers( # Later: replay with trace_id async for event in store.load_trace(trace_id): print(event) + ``` """ if trace_id is None: trace_id = str(uuid.uuid4()) @@ -141,6 +143,7 @@ async def finalize_capture( status: Final status ("complete" or "failed"). Example: + ```python handlers, trace_id = create_capture_handlers(store) try: result = await agent.ask("query", handlers=handlers) @@ -148,5 +151,6 @@ async def finalize_capture( except Exception: await finalize_capture(store, trace_id, "failed") raise + ``` """ await storage.finalize_trace(trace_id, status) diff --git a/src/bond/utils.py b/src/bond/utils.py index dd4f96e..9eac281 100644 --- a/src/bond/utils.py +++ b/src/bond/utils.py @@ -39,14 +39,16 @@ async def websocket_handler(ws: WebSocket): ``` Message Types: - - {"t": "block_start", "kind": str, "idx": int} - - {"t": "block_end", "kind": str, "idx": int} - - {"t": "text", "c": str} - - {"t": "thinking", "c": str} - - {"t": "tool_delta", "n": str, "a": str} - - {"t": "tool_exec", "id": str, "name": str, "args": dict} - - {"t": "tool_result", "id": str, "name": str, "result": str} - - {"t": "complete", "data": Any} + ```json + {"t": "block_start", "kind": str, "idx": int} + {"t": "block_end", "kind": str, "idx": int} + {"t": "text", "c": str} + {"t": "thinking", "c": str} + {"t": "tool_delta", "n": str, "a": str} + {"t": "tool_exec", "id": str, "name": str, "args": dict} + {"t": "tool_result", "id": str, "name": str, "result": str} + {"t": "complete", "data": Any} + ``` """ # We need to handle the sync callbacks by scheduling async sends import asyncio @@ -134,12 +136,14 @@ def create_sse_handlers( StreamHandlers configured for SSE streaming. Example: + ```python async def sse_handler(request): async def send_sse(event: str, data: dict): - await response.write(f"event: {event}\ndata: {json.dumps(data)}\n\n") + await response.write(f"event: {event}\\ndata: {json.dumps(data)}\\n\\n") handlers = create_sse_handlers(send_sse) await agent.ask("Query", handlers=handlers) + ``` """ import asyncio @@ -180,8 +184,10 @@ def create_print_handlers( StreamHandlers configured for console output. Example: + ```python handlers = create_print_handlers(show_thinking=True) await agent.ask("Hello", handlers=handlers) + ``` """ return StreamHandlers( on_block_start=lambda kind, idx: print(f"\n[{kind} block #{idx}]", end=""), From 5638d7f547274b816fc2045424cc4d5fcb3b8562 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 17:27:33 +0000 Subject: [PATCH 12/13] chore: add pre-commit hooks matching CI workflow - Add .pre-commit-config.yaml with hooks for: - ruff format --check (matches CI lint job) - ruff check (matches CI lint job) - mypy (matches CI typecheck job) - Add pre-commit>=3.5.0 to dev dependencies - Fix ruff format issues in 5 files - Fix unused imports in test_json_store.py Install: uv run pre-commit install Run manually: uv run pre-commit run --all-files Co-Authored-By: Claude Opus 4.5 --- .pre-commit-config.yaml | 33 +++++++++++ pyproject.toml | 2 + src/bond/trace/_models.py | 22 ++++---- tests/unit/tools/githunter/test_tools.py | 28 +++------- tests/unit/trace/test_capture.py | 16 ++---- tests/unit/trace/test_json_store.py | 22 ++------ tests/unit/trace/test_replay.py | 24 ++------ uv.lock | 70 ++++++++++++++++++++++++ 8 files changed, 141 insertions(+), 76 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bd9710d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +# Pre-commit hooks matching CI workflow (.github/workflows/ci.yml) +# Install: uv run pre-commit install +# Run manually: uv run pre-commit run --all-files + +repos: + - repo: local + hooks: + # Ruff format check (matches CI: uv run ruff format --check src tests) + - id: ruff-format + name: ruff format + entry: uv run ruff format --check + language: system + types: [python] + pass_filenames: false + args: [src, tests] + + # Ruff lint (matches CI: uv run ruff check src tests) + - id: ruff-lint + name: ruff lint + entry: uv run ruff check + language: system + types: [python] + pass_filenames: false + args: [src, tests] + + # Mypy type check (matches CI: uv run mypy src/bond) + - id: mypy + name: mypy + entry: uv run mypy + language: system + types: [python] + pass_filenames: false + args: [src/bond] diff --git a/pyproject.toml b/pyproject.toml index 40234cf..596e3d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "mypy>=1.8.0", "respx>=0.20.2", "types-aiofiles>=24.0.0", + "pre-commit>=3.5.0", ] docs = [ "mkdocs>=1.6.0", @@ -64,6 +65,7 @@ dev = [ "ruff>=0.2.0", "mypy>=1.8.0", "types-aiofiles>=24.0.0", + "pre-commit>=3.5.0", ] # ============================================================================ diff --git a/src/bond/trace/_models.py b/src/bond/trace/_models.py index f91d127..0c386d5 100644 --- a/src/bond/trace/_models.py +++ b/src/bond/trace/_models.py @@ -20,16 +20,18 @@ EVENT_TOOL_RESULT = "tool_result" EVENT_COMPLETE = "complete" -ALL_EVENT_TYPES = frozenset({ - EVENT_BLOCK_START, - EVENT_BLOCK_END, - EVENT_TEXT_DELTA, - EVENT_THINKING_DELTA, - EVENT_TOOL_CALL_DELTA, - EVENT_TOOL_EXECUTE, - EVENT_TOOL_RESULT, - EVENT_COMPLETE, -}) +ALL_EVENT_TYPES = frozenset( + { + EVENT_BLOCK_START, + EVENT_BLOCK_END, + EVENT_TEXT_DELTA, + EVENT_THINKING_DELTA, + EVENT_TOOL_CALL_DELTA, + EVENT_TOOL_EXECUTE, + EVENT_TOOL_RESULT, + EVENT_COMPLETE, + } +) @dataclass(frozen=True) diff --git a/tests/unit/tools/githunter/test_tools.py b/tests/unit/tools/githunter/test_tools.py index 52b6b68..f4f1331 100644 --- a/tests/unit/tools/githunter/test_tools.py +++ b/tests/unit/tools/githunter/test_tools.py @@ -149,9 +149,7 @@ class TestBlameLine: """Tests for blame_line tool function.""" @pytest.mark.asyncio - async def test_returns_blame_result( - self, sample_blame_result: BlameResult - ) -> None: + async def test_returns_blame_result(self, sample_blame_result: BlameResult) -> None: """Test blame_line returns BlameResult on success.""" hunter = MockGitHunter(blame_result=sample_blame_result) ctx = create_mock_ctx(hunter) @@ -193,9 +191,7 @@ async def test_handles_file_not_found_error(self) -> None: @pytest.mark.asyncio async def test_handles_line_out_of_range_error(self) -> None: """Test blame_line returns Error when line out of range.""" - hunter = MockGitHunter( - blame_result=LineOutOfRangeError(line_no=1000, max_lines=50) - ) + hunter = MockGitHunter(blame_result=LineOutOfRangeError(line_no=1000, max_lines=50)) ctx = create_mock_ctx(hunter) request = BlameLineRequest( repo_path="/repo", @@ -211,9 +207,7 @@ async def test_handles_line_out_of_range_error(self) -> None: @pytest.mark.asyncio async def test_handles_repo_not_found_error(self) -> None: """Test blame_line returns Error when repo not found.""" - hunter = MockGitHunter( - blame_result=RepoNotFoundError(path="/not/a/repo") - ) + hunter = MockGitHunter(blame_result=RepoNotFoundError(path="/not/a/repo")) ctx = create_mock_ctx(hunter) request = BlameLineRequest( repo_path="/not/a/repo", @@ -231,9 +225,7 @@ class TestFindPRDiscussion: """Tests for find_pr_discussion tool function.""" @pytest.mark.asyncio - async def test_returns_pr_discussion( - self, sample_pr_discussion: PRDiscussion - ) -> None: + async def test_returns_pr_discussion(self, sample_pr_discussion: PRDiscussion) -> None: """Test find_pr_discussion returns PRDiscussion when found.""" hunter = MockGitHunter(pr_discussion=sample_pr_discussion) ctx = create_mock_ctx(hunter) @@ -286,9 +278,7 @@ async def test_handles_rate_limited_error(self) -> None: @pytest.mark.asyncio async def test_handles_repo_not_found_error(self) -> None: """Test find_pr_discussion returns Error when repo not found.""" - hunter = MockGitHunter( - pr_discussion=RepoNotFoundError(path="/not/a/repo") - ) + hunter = MockGitHunter(pr_discussion=RepoNotFoundError(path="/not/a/repo")) ctx = create_mock_ctx(hunter) request = FindPRDiscussionRequest( repo_path="/not/a/repo", @@ -305,9 +295,7 @@ class TestGetFileExperts: """Tests for get_file_experts tool function.""" @pytest.mark.asyncio - async def test_returns_expert_list( - self, sample_experts: list[FileExpert] - ) -> None: + async def test_returns_expert_list(self, sample_experts: list[FileExpert]) -> None: """Test get_file_experts returns list of FileExpert on success.""" hunter = MockGitHunter(experts=sample_experts) ctx = create_mock_ctx(hunter) @@ -345,9 +333,7 @@ async def test_returns_empty_list(self) -> None: @pytest.mark.asyncio async def test_handles_repo_not_found_error(self) -> None: """Test get_file_experts returns Error when repo not found.""" - hunter = MockGitHunter( - experts=RepoNotFoundError(path="/not/a/repo") - ) + hunter = MockGitHunter(experts=RepoNotFoundError(path="/not/a/repo")) ctx = create_mock_ctx(hunter) request = GetExpertsRequest( repo_path="/not/a/repo", diff --git a/tests/unit/trace/test_capture.py b/tests/unit/trace/test_capture.py index 4cbd081..bf38dd3 100644 --- a/tests/unit/trace/test_capture.py +++ b/tests/unit/trace/test_capture.py @@ -102,7 +102,9 @@ async def test_on_text_delta_records_event(self, store: JSONFileTraceStore) -> N await asyncio.sleep(0.1) events = [e async for e in store.load_trace(trace_id)] - assert any(e.event_type == EVENT_TEXT_DELTA and e.payload == {"text": "Hello"} for e in events) + assert any( + e.event_type == EVENT_TEXT_DELTA and e.payload == {"text": "Hello"} for e in events + ) async def test_sequence_numbers_unique(self, store: JSONFileTraceStore) -> None: """Test events have unique sequence numbers (0-indexed).""" @@ -122,9 +124,7 @@ async def test_sequence_numbers_unique(self, store: JSONFileTraceStore) -> None: # All sequences present (async saves may complete out of order) assert sequences == list(range(len(sequences))) - async def test_all_callbacks_record_correct_types( - self, store: JSONFileTraceStore - ) -> None: + async def test_all_callbacks_record_correct_types(self, store: JSONFileTraceStore) -> None: """Test all 8 callbacks record correct event types.""" handlers, trace_id = create_capture_handlers(store) @@ -157,9 +157,7 @@ async def test_all_callbacks_record_correct_types( class TestFinalizeCapture: """Tests for finalize_capture.""" - async def test_finalize_capture_marks_complete( - self, store: JSONFileTraceStore - ) -> None: + async def test_finalize_capture_marks_complete(self, store: JSONFileTraceStore) -> None: """Test finalize_capture marks trace as complete.""" handlers, trace_id = create_capture_handlers(store) handlers.on_text_delta("test") # type: ignore[misc] @@ -173,9 +171,7 @@ async def test_finalize_capture_marks_complete( assert meta is not None assert meta.status == STATUS_COMPLETE - async def test_finalize_capture_with_failed_status( - self, store: JSONFileTraceStore - ) -> None: + async def test_finalize_capture_with_failed_status(self, store: JSONFileTraceStore) -> None: """Test finalize_capture with failed status.""" handlers, trace_id = create_capture_handlers(store) handlers.on_text_delta("test") # type: ignore[misc] diff --git a/tests/unit/trace/test_json_store.py b/tests/unit/trace/test_json_store.py index fd91a23..9c4f47b 100644 --- a/tests/unit/trace/test_json_store.py +++ b/tests/unit/trace/test_json_store.py @@ -8,8 +8,6 @@ import pytest from bond.trace._models import ( - EVENT_BLOCK_START, - EVENT_COMPLETE, EVENT_TEXT_DELTA, STATUS_COMPLETE, STATUS_FAILED, @@ -99,9 +97,7 @@ async def test_save_event_updates_event_count(self, store: JSONFileTraceStore) - class TestLoadTrace: """Tests for load_trace.""" - async def test_load_trace_returns_events_in_order( - self, store: JSONFileTraceStore - ) -> None: + async def test_load_trace_returns_events_in_order(self, store: JSONFileTraceStore) -> None: """Test load_trace returns events in sequence order.""" await store.save_event(make_event("test-trace", 0, payload={"i": 0})) await store.save_event(make_event("test-trace", 1, payload={"i": 1})) @@ -141,9 +137,7 @@ async def test_finalize_trace_failed_status(self, store: JSONFileTraceStore) -> meta = json.loads(meta_path.read_text()) assert meta["status"] == STATUS_FAILED - async def test_finalize_trace_raises_for_unknown( - self, store: JSONFileTraceStore - ) -> None: + async def test_finalize_trace_raises_for_unknown(self, store: JSONFileTraceStore) -> None: """Test finalize_trace raises KeyError for unknown trace.""" with pytest.raises(KeyError, match="Trace not found"): await store.finalize_trace("nonexistent") @@ -176,9 +170,7 @@ async def test_list_traces_respects_limit(self, store: JSONFileTraceStore) -> No traces = await store.list_traces(limit=3) assert len(traces) == 3 - async def test_list_traces_sorted_newest_first( - self, store: JSONFileTraceStore - ) -> None: + async def test_list_traces_sorted_newest_first(self, store: JSONFileTraceStore) -> None: """Test list_traces returns traces sorted by creation time.""" # Create traces with different timestamps await store.save_event(make_event("old-trace", 0)) @@ -207,9 +199,7 @@ async def test_delete_trace_removes_files(self, store: JSONFileTraceStore) -> No assert not events_path.exists() assert not meta_path.exists() - async def test_delete_trace_raises_for_unknown( - self, store: JSONFileTraceStore - ) -> None: + async def test_delete_trace_raises_for_unknown(self, store: JSONFileTraceStore) -> None: """Test delete_trace raises KeyError for unknown trace.""" with pytest.raises(KeyError, match="Trace not found"): await store.delete_trace("nonexistent") @@ -231,9 +221,7 @@ async def test_get_trace_meta_returns_meta(self, store: JSONFileTraceStore) -> N assert meta.event_count == 2 assert meta.status == STATUS_COMPLETE - async def test_get_trace_meta_returns_none_for_unknown( - self, store: JSONFileTraceStore - ) -> None: + async def test_get_trace_meta_returns_none_for_unknown(self, store: JSONFileTraceStore) -> None: """Test get_trace_meta returns None for unknown trace.""" meta = await store.get_trace_meta("nonexistent") assert meta is None diff --git a/tests/unit/trace/test_replay.py b/tests/unit/trace/test_replay.py index b6fe4fb..12a9617 100644 --- a/tests/unit/trace/test_replay.py +++ b/tests/unit/trace/test_replay.py @@ -36,9 +36,7 @@ def make_event(trace_id: str, sequence: int, text: str = "") -> TraceEvent: ) -async def create_trace_with_events( - store: JSONFileTraceStore, trace_id: str, count: int -) -> None: +async def create_trace_with_events(store: JSONFileTraceStore, trace_id: str, count: int) -> None: """Helper to create a trace with specified number of events.""" for i in range(count): await store.save_event(make_event(trace_id, i)) @@ -56,9 +54,7 @@ async def test_iteration_yields_all_events(self, store: JSONFileTraceStore) -> N assert len(events) == 5 - async def test_iteration_yields_events_in_order( - self, store: JSONFileTraceStore - ) -> None: + async def test_iteration_yields_events_in_order(self, store: JSONFileTraceStore) -> None: """Test events yielded in sequence order.""" await create_trace_with_events(store, "test-trace", 5) @@ -117,9 +113,7 @@ async def test_step_returns_none_at_end(self, store: JSONFileTraceStore) -> None class TestTraceReplayerStepBack: """Tests for step_back() method.""" - async def test_step_back_returns_previous_event( - self, store: JSONFileTraceStore - ) -> None: + async def test_step_back_returns_previous_event(self, store: JSONFileTraceStore) -> None: """Test step_back() returns previous event.""" await create_trace_with_events(store, "test-trace", 3) @@ -133,9 +127,7 @@ async def test_step_back_returns_previous_event( assert event.sequence == 1 assert replayer.position == 1 - async def test_step_back_returns_none_at_start( - self, store: JSONFileTraceStore - ) -> None: + async def test_step_back_returns_none_at_start(self, store: JSONFileTraceStore) -> None: """Test step_back() returns None at start.""" await create_trace_with_events(store, "test-trace", 3) @@ -194,9 +186,7 @@ async def test_position_starts_at_zero(self, store: JSONFileTraceStore) -> None: replayer = TraceReplayer(store, "test-trace") assert replayer.position == 0 - async def test_total_events_none_before_load( - self, store: JSONFileTraceStore - ) -> None: + async def test_total_events_none_before_load(self, store: JSONFileTraceStore) -> None: """Test total_events is None before loading.""" await create_trace_with_events(store, "test-trace", 5) @@ -231,9 +221,7 @@ async def test_reset_moves_to_start(self, store: JSONFileTraceStore) -> None: class TestTraceReplayerCurrent: """Tests for current() method.""" - async def test_current_returns_event_at_position( - self, store: JSONFileTraceStore - ) -> None: + async def test_current_returns_event_at_position(self, store: JSONFileTraceStore) -> None: """Test current() returns event at current position.""" await create_trace_with_events(store, "test-trace", 5) diff --git a/uv.lock b/uv.lock index 25b663c..fcf42f7 100644 --- a/uv.lock +++ b/uv.lock @@ -338,6 +338,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -355,6 +356,7 @@ docs = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -371,6 +373,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.26.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, { name = "pydantic", specifier = ">=2.5.0" }, { name = "pydantic-ai", specifier = ">=0.0.14" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, @@ -387,6 +390,7 @@ provides-extras = ["dev", "docs"] [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.8.0" }, + { name = "pre-commit", specifier = ">=3.5.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-cov", specifier = ">=4.1.0" }, @@ -510,6 +514,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -834,6 +847,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, ] +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -1415,6 +1437,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] +[[package]] +name = "identify" +version = "2.6.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -2320,6 +2351,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/04/eaac430d0e6bf21265ae989427d37e94be5e41dc216879f1fbb6c5339942/nexus_rpc-1.2.0-py3-none-any.whl", hash = "sha256:977876f3af811ad1a09b2961d3d1ac9233bda43ff0febbb0c9906483b9d9f8a3", size = 28166, upload-time = "2025-11-17T19:17:05.64Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + [[package]] name = "numpy" version = "2.4.1" @@ -2775,6 +2815,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + [[package]] name = "prometheus-client" version = "0.24.1" @@ -4462,6 +4518,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] +[[package]] +name = "virtualenv" +version = "20.36.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" From 8c73c01e2a5d140a48e9ace46dd9f8c07655e9df Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 17:37:19 +0000 Subject: [PATCH 13/13] refactor(trace): migrate TraceEvent and TraceMeta to Pydantic models Replace frozen dataclasses with Pydantic BaseModel for unified serialization with the Memory subsystem. This removes manual to_dict/from_dict methods in favor of Pydantic's model_dump/model_validate methods. Co-Authored-By: Claude Opus 4.5 --- src/bond/trace/_models.py | 98 +++------------------------- src/bond/trace/backends/json_file.py | 8 +-- tests/unit/trace/test_models.py | 44 ++++++------- 3 files changed, 36 insertions(+), 114 deletions(-) diff --git a/src/bond/trace/_models.py b/src/bond/trace/_models.py index 0c386d5..526a5d9 100644 --- a/src/bond/trace/_models.py +++ b/src/bond/trace/_models.py @@ -1,15 +1,16 @@ """Trace event models for forensic capture and replay. -Provides dataclasses for trace events and metadata that capture +Provides Pydantic models for trace events and metadata that capture all 8 StreamHandlers callback types for persistence and replay. """ from __future__ import annotations -from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import datetime from typing import Any +from pydantic import BaseModel, ConfigDict + # Event type constants matching StreamHandlers callbacks EVENT_BLOCK_START = "block_start" EVENT_BLOCK_END = "block_end" @@ -34,8 +35,7 @@ ) -@dataclass(frozen=True) -class TraceEvent: +class TraceEvent(BaseModel): """A single event in an execution trace. Captures one callback from StreamHandlers with full context @@ -50,6 +50,8 @@ class TraceEvent: payload: Event-specific data (varies by event_type). """ + model_config = ConfigDict(frozen=True) + trace_id: str sequence: int timestamp: float @@ -57,49 +59,6 @@ class TraceEvent: event_type: str payload: dict[str, Any] - def to_dict(self) -> dict[str, Any]: - """Serialize event to dictionary for JSON storage. - - Returns: - Dictionary with all fields, wall_time as ISO string. - """ - return { - "trace_id": self.trace_id, - "sequence": self.sequence, - "timestamp": self.timestamp, - "wall_time": self.wall_time.isoformat(), - "event_type": self.event_type, - "payload": self.payload, - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> TraceEvent: - """Deserialize event from dictionary. - - Args: - data: Dictionary with trace event fields. - - Returns: - TraceEvent instance. - """ - wall_time = data["wall_time"] - if isinstance(wall_time, str): - # Parse ISO format, handle with or without timezone - if wall_time.endswith("Z"): - wall_time = wall_time[:-1] + "+00:00" - wall_time = datetime.fromisoformat(wall_time) - if wall_time.tzinfo is None: - wall_time = wall_time.replace(tzinfo=UTC) - - return cls( - trace_id=data["trace_id"], - sequence=data["sequence"], - timestamp=data["timestamp"], - wall_time=wall_time, - event_type=data["event_type"], - payload=data["payload"], - ) - # Trace status constants STATUS_IN_PROGRESS = "in_progress" @@ -107,8 +66,7 @@ def from_dict(cls, data: dict[str, Any]) -> TraceEvent: STATUS_FAILED = "failed" -@dataclass(frozen=True) -class TraceMeta: +class TraceMeta(BaseModel): """Metadata about a stored trace. Provides summary information without loading all events. @@ -120,45 +78,9 @@ class TraceMeta: status: One of "in_progress", "complete", "failed". """ + model_config = ConfigDict(frozen=True) + trace_id: str created_at: datetime event_count: int status: str - - def to_dict(self) -> dict[str, Any]: - """Serialize metadata to dictionary. - - Returns: - Dictionary with all fields, created_at as ISO string. - """ - return { - "trace_id": self.trace_id, - "created_at": self.created_at.isoformat(), - "event_count": self.event_count, - "status": self.status, - } - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> TraceMeta: - """Deserialize metadata from dictionary. - - Args: - data: Dictionary with trace metadata fields. - - Returns: - TraceMeta instance. - """ - created_at = data["created_at"] - if isinstance(created_at, str): - if created_at.endswith("Z"): - created_at = created_at[:-1] + "+00:00" - created_at = datetime.fromisoformat(created_at) - if created_at.tzinfo is None: - created_at = created_at.replace(tzinfo=UTC) - - return cls( - trace_id=data["trace_id"], - created_at=created_at, - event_count=data["event_count"], - status=data["status"], - ) diff --git a/src/bond/trace/backends/json_file.py b/src/bond/trace/backends/json_file.py index b5e6afa..cfc11ab 100644 --- a/src/bond/trace/backends/json_file.py +++ b/src/bond/trace/backends/json_file.py @@ -77,7 +77,7 @@ async def save_event(self, event: TraceEvent) -> None: # Append event to events file async with aiofiles.open(events_path, "a") as f: - await f.write(json.dumps(event.to_dict()) + "\n") + await f.write(event.model_dump_json() + "\n") # Update or create metadata if meta_path.exists(): @@ -154,7 +154,7 @@ async def load_trace(self, trace_id: str) -> AsyncIterator[TraceEvent]: async for line in f: line = line.strip() if line: - yield TraceEvent.from_dict(json.loads(line)) + yield TraceEvent.model_validate_json(line) async def list_traces(self, limit: int = 100) -> list[TraceMeta]: """List available traces with metadata. @@ -178,7 +178,7 @@ async def list_traces(self, limit: int = 100) -> list[TraceMeta]: async with aiofiles.open(meta_path) as f: content = await f.read() meta_data = json.loads(content) - traces.append(TraceMeta.from_dict(meta_data)) + traces.append(TraceMeta.model_validate(meta_data)) except (json.JSONDecodeError, KeyError): # Skip malformed meta files continue @@ -230,4 +230,4 @@ async def get_trace_meta(self, trace_id: str) -> TraceMeta | None: async with aiofiles.open(meta_path) as f: content = await f.read() meta_data = json.loads(content) - return TraceMeta.from_dict(meta_data) + return TraceMeta.model_validate(meta_data) diff --git a/tests/unit/trace/test_models.py b/tests/unit/trace/test_models.py index d135c98..d700fc9 100644 --- a/tests/unit/trace/test_models.py +++ b/tests/unit/trace/test_models.py @@ -22,7 +22,7 @@ class TestTraceEvent: - """Tests for TraceEvent dataclass.""" + """Tests for TraceEvent Pydantic model.""" def test_trace_event_creation(self) -> None: """Test TraceEvent can be created with all fields.""" @@ -40,7 +40,7 @@ def test_trace_event_creation(self) -> None: assert event.event_type == EVENT_TEXT_DELTA assert event.payload == {"text": "Hello"} - def test_trace_event_to_dict(self) -> None: + def test_trace_event_model_dump(self) -> None: """Test TraceEvent serialization to dict.""" wall_time = datetime(2024, 6, 15, 10, 30, tzinfo=UTC) event = TraceEvent( @@ -52,16 +52,16 @@ def test_trace_event_to_dict(self) -> None: payload={"id": "tool-1", "name": "search", "args": {"q": "test"}}, ) - result = event.to_dict() + result = event.model_dump() assert result["trace_id"] == "test-id" assert result["sequence"] == 5 assert result["timestamp"] == 2.5 - assert result["wall_time"] == wall_time.isoformat() + assert result["wall_time"] == wall_time assert result["event_type"] == EVENT_TOOL_EXECUTE assert result["payload"] == {"id": "tool-1", "name": "search", "args": {"q": "test"}} - def test_trace_event_from_dict(self) -> None: + def test_trace_event_model_validate(self) -> None: """Test TraceEvent deserialization from dict.""" data = { "trace_id": "test-id", @@ -72,7 +72,7 @@ def test_trace_event_from_dict(self) -> None: "payload": {"kind": "text", "index": 0}, } - event = TraceEvent.from_dict(data) + event = TraceEvent.model_validate(data) assert event.trace_id == "test-id" assert event.sequence == 3 @@ -82,7 +82,7 @@ def test_trace_event_from_dict(self) -> None: assert event.payload == {"kind": "text", "index": 0} def test_trace_event_roundtrip(self) -> None: - """Test TraceEvent to_dict/from_dict roundtrip.""" + """Test TraceEvent model_dump/model_validate roundtrip.""" original = TraceEvent( trace_id="roundtrip-test", sequence=10, @@ -92,13 +92,13 @@ def test_trace_event_roundtrip(self) -> None: payload={"data": {"answer": "42"}}, ) - serialized = original.to_dict() - restored = TraceEvent.from_dict(serialized) + serialized = original.model_dump() + restored = TraceEvent.model_validate(serialized) assert restored == original - def test_trace_event_from_dict_with_z_suffix(self) -> None: - """Test from_dict handles Z timezone suffix.""" + def test_trace_event_model_validate_with_z_suffix(self) -> None: + """Test model_validate handles Z timezone suffix.""" data = { "trace_id": "z-test", "sequence": 0, @@ -108,7 +108,7 @@ def test_trace_event_from_dict_with_z_suffix(self) -> None: "payload": {}, } - event = TraceEvent.from_dict(data) + event = TraceEvent.model_validate(data) assert event.wall_time.tzinfo is not None @pytest.mark.parametrize("event_type", list(ALL_EVENT_TYPES)) @@ -123,15 +123,15 @@ def test_all_event_types_serialize(self, event_type: str) -> None: payload={}, ) - serialized = event.to_dict() + serialized = event.model_dump() assert serialized["event_type"] == event_type - restored = TraceEvent.from_dict(serialized) + restored = TraceEvent.model_validate(serialized) assert restored.event_type == event_type class TestTraceMeta: - """Tests for TraceMeta dataclass.""" + """Tests for TraceMeta Pydantic model.""" def test_trace_meta_creation(self) -> None: """Test TraceMeta can be created.""" @@ -145,7 +145,7 @@ def test_trace_meta_creation(self) -> None: assert meta.event_count == 42 assert meta.status == STATUS_COMPLETE - def test_trace_meta_to_dict(self) -> None: + def test_trace_meta_model_dump(self) -> None: """Test TraceMeta serialization.""" created = datetime(2024, 6, 15, 12, 0, tzinfo=UTC) meta = TraceMeta( @@ -155,14 +155,14 @@ def test_trace_meta_to_dict(self) -> None: status=STATUS_IN_PROGRESS, ) - result = meta.to_dict() + result = meta.model_dump() assert result["trace_id"] == "meta-test" - assert result["created_at"] == created.isoformat() + assert result["created_at"] == created assert result["event_count"] == 100 assert result["status"] == STATUS_IN_PROGRESS - def test_trace_meta_from_dict(self) -> None: + def test_trace_meta_model_validate(self) -> None: """Test TraceMeta deserialization.""" data = { "trace_id": "from-dict-test", @@ -171,7 +171,7 @@ def test_trace_meta_from_dict(self) -> None: "status": STATUS_COMPLETE, } - meta = TraceMeta.from_dict(data) + meta = TraceMeta.model_validate(data) assert meta.trace_id == "from-dict-test" assert meta.created_at == datetime(2024, 6, 15, 12, 0, tzinfo=UTC) @@ -179,7 +179,7 @@ def test_trace_meta_from_dict(self) -> None: assert meta.status == STATUS_COMPLETE def test_trace_meta_roundtrip(self) -> None: - """Test TraceMeta to_dict/from_dict roundtrip.""" + """Test TraceMeta model_dump/model_validate roundtrip.""" original = TraceMeta( trace_id="roundtrip", created_at=datetime(2024, 1, 1, tzinfo=UTC), @@ -187,7 +187,7 @@ def test_trace_meta_roundtrip(self) -> None: status=STATUS_COMPLETE, ) - restored = TraceMeta.from_dict(original.to_dict()) + restored = TraceMeta.model_validate(original.model_dump()) assert restored == original