From 3dca181b72a7c3df485e97da0928c738734157d6 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 21:48:43 +0000 Subject: [PATCH 01/23] feat: add streaming server and GitHub toolset Add production-ready infrastructure for Bond agents: ## Server Module (bond.server) - SSE and WebSocket streaming endpoints - Session management with history - CORS support and configuration - Health check endpoint ## GitHub Toolset (bond.tools.github) - 6 tools: get_repo, list_files, read_file, search_code, get_commits, get_pr - GitHubAdapter with rate limit handling - GitHubProtocol for backend flexibility ## Composite Dependencies (BondToolDeps) - Single deps object for multiple toolsets - Lazy adapter initialization - Supports GitHub + GitHunter together ## Documentation - guides/streaming-server.md - SSE/WebSocket integration - guides/github.md - GitHub toolset usage ## Tests - 36 new tests for server and GitHub modules Co-Authored-By: Claude Opus 4.5 --- docs/guides/github.md | 213 +++++++++++++ docs/guides/streaming-server.md | 391 ++++++++++++++++++++++++ mkdocs.yml | 4 +- pyproject.toml | 12 + src/bond/__init__.py | 20 ++ src/bond/server/__init__.py | 52 ++++ src/bond/server/_app.py | 114 +++++++ src/bond/server/_routes.py | 290 ++++++++++++++++++ src/bond/server/_session.py | 232 +++++++++++++++ src/bond/server/_types.py | 73 +++++ src/bond/tools/__init__.py | 48 ++- src/bond/tools/_composite.py | 204 +++++++++++++ src/bond/tools/github/__init__.py | 72 +++++ src/bond/tools/github/_adapter.py | 411 ++++++++++++++++++++++++++ src/bond/tools/github/_exceptions.py | 98 ++++++ src/bond/tools/github/_models.py | 178 +++++++++++ src/bond/tools/github/_protocols.py | 165 +++++++++++ src/bond/tools/github/_types.py | 173 +++++++++++ src/bond/tools/github/tools.py | 256 ++++++++++++++++ tests/unit/server/__init__.py | 1 + tests/unit/server/test_session.py | 202 +++++++++++++ tests/unit/tools/github/__init__.py | 1 + tests/unit/tools/github/test_tools.py | 382 ++++++++++++++++++++++++ tests/unit/tools/github/test_types.py | 195 ++++++++++++ 24 files changed, 3785 insertions(+), 2 deletions(-) create mode 100644 docs/guides/github.md create mode 100644 docs/guides/streaming-server.md create mode 100644 src/bond/server/__init__.py create mode 100644 src/bond/server/_app.py create mode 100644 src/bond/server/_routes.py create mode 100644 src/bond/server/_session.py create mode 100644 src/bond/server/_types.py create mode 100644 src/bond/tools/_composite.py create mode 100644 src/bond/tools/github/__init__.py create mode 100644 src/bond/tools/github/_adapter.py create mode 100644 src/bond/tools/github/_exceptions.py create mode 100644 src/bond/tools/github/_models.py create mode 100644 src/bond/tools/github/_protocols.py create mode 100644 src/bond/tools/github/_types.py create mode 100644 src/bond/tools/github/tools.py create mode 100644 tests/unit/server/__init__.py create mode 100644 tests/unit/server/test_session.py create mode 100644 tests/unit/tools/github/__init__.py create mode 100644 tests/unit/tools/github/test_tools.py create mode 100644 tests/unit/tools/github/test_types.py diff --git a/docs/guides/github.md b/docs/guides/github.md new file mode 100644 index 0000000..89cee3a --- /dev/null +++ b/docs/guides/github.md @@ -0,0 +1,213 @@ +# GitHub Toolset + +The GitHub toolset enables agents to browse and analyze any GitHub repository. Agents can read files, search code, explore commit history, and inspect pull requests. + +## Overview + +GitHub tools answer questions like: + +- **"What's in this repo?"** → `github_get_repo` + `github_list_files` +- **"Show me this file"** → `github_read_file` +- **"Where is X defined?"** → `github_search_code` +- **"What changed recently?"** → `github_get_commits` +- **"What does this PR do?"** → `github_get_pr` + +## Quick Start + +```python +import os +from bond import BondAgent +from bond.tools.github import github_toolset, GitHubAdapter + +# Create adapter with GitHub token +adapter = GitHubAdapter(token=os.environ["GITHUB_TOKEN"]) + +# Create agent with GitHub tools +agent = BondAgent( + name="code-explorer", + instructions="""You help users understand GitHub repositories. + Browse files, search code, and explain what you find.""", + model="openai:gpt-4o", + toolsets=[github_toolset], + deps=adapter, +) + +# Explore a repository +result = await agent.ask("What is the facebook/react repository about?") +``` + +## Available Tools + +### github_get_repo + +Get repository metadata. + +```python +github_get_repo({ + "owner": "facebook", + "repo": "react" +}) +``` + +**Returns:** `RepoInfo` with description, default branch, topics, stars, forks. + +### github_list_files + +List directory contents. + +```python +github_list_files({ + "owner": "facebook", + "repo": "react", + "path": "packages/react/src", + "ref": "main" # optional: branch, tag, or commit +}) +``` + +**Returns:** List of `TreeEntry` with name, path, type (file/dir), size. + +### github_read_file + +Read file content. + +```python +github_read_file({ + "owner": "facebook", + "repo": "react", + "path": "packages/react/package.json", + "ref": "main" +}) +``` + +**Returns:** `FileContent` with decoded content, size, SHA. + +### github_search_code + +Search code within a repository. + +```python +github_search_code({ + "query": "useState", + "owner": "facebook", + "repo": "react", + "limit": 10 +}) +``` + +**Returns:** List of `CodeSearchResult` with file paths and matching fragments. + +### github_get_commits + +Get recent commits. + +```python +github_get_commits({ + "owner": "facebook", + "repo": "react", + "path": "packages/react/src/React.js", # optional: filter by file + "limit": 10 +}) +``` + +**Returns:** List of `Commit` with SHA, message, author, date. + +### github_get_pr + +Get pull request details. + +```python +github_get_pr({ + "owner": "facebook", + "repo": "react", + "number": 25000 +}) +``` + +**Returns:** `PullRequest` with title, body, state, author, merge status. + +## Authentication + +GitHub tools require a personal access token: + +```bash +export GITHUB_TOKEN=ghp_xxxxxxxxxxxx +``` + +Or pass directly: + +```python +adapter = GitHubAdapter(token="ghp_xxxxxxxxxxxx") +``` + +**Required scopes:** +- `repo` - For private repositories +- `public_repo` - For public repositories only + +## Use Cases + +| Scenario | Tools | Example Query | +|----------|-------|---------------| +| Explore structure | `github_get_repo` + `github_list_files` | "What's the structure of this repo?" | +| Read documentation | `github_read_file` | "Show me the README" | +| Find implementations | `github_search_code` | "Where is the login function defined?" | +| Track changes | `github_get_commits` | "What changed in auth.py recently?" | +| Review PRs | `github_get_pr` | "Summarize PR #123" | +| Code review | All tools | "Review the changes in PR #456" | + +## Combining with GitHunter + +For deeper code forensics, combine GitHub tools with GitHunter: + +```python +from bond.tools import BondToolDeps, github_toolset, githunter_toolset + +deps = BondToolDeps(github_token=os.environ["GITHUB_TOKEN"]) + +agent = BondAgent( + name="forensic-analyst", + instructions="You investigate code history and ownership.", + model="openai:gpt-4o", + toolsets=[github_toolset, githunter_toolset], + deps=deps, +) + +# Now agent can: +# - Browse any GitHub repo (github_*) +# - Blame lines in local repos (blame_line) +# - Find PR discussions (find_pr_discussion) +# - Identify file experts (get_file_experts) +``` + +## Rate Limiting + +The adapter handles GitHub API rate limits automatically with exponential backoff. If rate limited, tools return an `Error`: + +```python +Error(description="GitHub API rate limit exceeded (resets at 1234567890)") +``` + +Increase limits by using an authenticated token (5000 requests/hour vs 60 unauthenticated). + +## Error Handling + +All tools return `Error` on failure: + +```python +from bond.tools.github import Error + +result = await agent.ask("Read nonexistent.txt from facebook/react") +# Agent receives Error(description="File not found: facebook/react/nonexistent.txt") +``` + +Common errors: +- `RepoNotFoundError` - Repository doesn't exist or is private +- `FileNotFoundError` - Path doesn't exist +- `PRNotFoundError` - PR number doesn't exist +- `RateLimitedError` - API rate limit exceeded +- `AuthenticationError` - Invalid or missing token + +## See Also + +- [Streaming Server](./streaming-server.md) - Serve agents via SSE/WebSocket +- [GitHunter Toolset](./githunter.md) - Local git forensics +- [API Reference: GitHub](../api/tools.md#github-toolset) - Full type definitions diff --git a/docs/guides/streaming-server.md b/docs/guides/streaming-server.md new file mode 100644 index 0000000..5e98ac9 --- /dev/null +++ b/docs/guides/streaming-server.md @@ -0,0 +1,391 @@ +# Streaming Server Integration + +The Bond server module provides production-ready SSE and WebSocket endpoints for any Bond agent. This enables real-time streaming to web UIs, mobile apps, and other clients. + +## Overview + +Bond's server module wraps your agent in an ASGI application with: + +- **SSE streaming** - Server-Sent Events for one-way real-time updates +- **WebSocket** - Bidirectional streaming for interactive chat +- **Session management** - Multi-turn conversations with history +- **CORS support** - Configurable cross-origin requests + +## Installation + +Install the server dependencies: + +```bash +pip install bond-agent[server] +``` + +This adds `starlette`, `uvicorn`, and `sse-starlette`. + +## Quick Start + +```python +from bond import BondAgent +from bond.server import create_bond_server + +# Create your agent +agent = BondAgent( + name="assistant", + instructions="You are a helpful assistant.", + model="openai:gpt-4o", +) + +# Create ASGI app +app = create_bond_server(agent) +``` + +Run with uvicorn: + +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +## API Endpoints + +### POST /ask + +Start a new streaming session. + +**Request:** +```json +{ + "prompt": "What is the capital of France?", + "session_id": "optional-session-id" +} +``` + +**Response:** +```json +{ + "session_id": "550e8400-e29b-41d4-a716-446655440000", + "stream_url": "/stream/550e8400-e29b-41d4-a716-446655440000" +} +``` + +### GET /stream/{session_id} + +SSE endpoint for receiving streaming events. + +**Event Types:** + +| Event | Data | Description | +|-------|------|-------------| +| `text` | `{"content": "..."}` | Text token delta | +| `thinking` | `{"content": "..."}` | Reasoning/thinking content | +| `tool_exec` | `{"id": "...", "name": "...", "args": {...}}` | Tool execution started | +| `tool_result` | `{"id": "...", "name": "...", "result": "..."}` | Tool returned result | +| `block_start` | `{"kind": "...", "idx": 0}` | New content block started | +| `block_end` | `{"kind": "...", "idx": 0}` | Content block finished | +| `complete` | `{"data": ...}` | Stream complete | +| `error` | `{"error": "..."}` | Error occurred | + +### WS /ws + +WebSocket endpoint for bidirectional streaming. + +**Send:** +```json +{"prompt": "Hello, how are you?"} +``` + +**Receive:** Same event types as SSE, plus: +```json +{"t": "done"} +``` + +### GET /health + +Health check endpoint. + +**Response:** +```json +{ + "status": "healthy", + "agent_name": "assistant" +} +``` + +## Client Integration + +### JavaScript/TypeScript (SSE) + +```typescript +async function askAgent(prompt: string): Promise { + // 1. Start session + const response = await fetch('http://localhost:8000/ask', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }); + const { session_id, stream_url } = await response.json(); + + // 2. Connect to SSE stream + const eventSource = new EventSource(`http://localhost:8000${stream_url}`); + + eventSource.addEventListener('text', (event) => { + const { content } = JSON.parse(event.data); + process.stdout.write(content); // Stream tokens + }); + + eventSource.addEventListener('tool_exec', (event) => { + const { name, args } = JSON.parse(event.data); + console.log(`\n[Running ${name}...]`); + }); + + eventSource.addEventListener('complete', () => { + eventSource.close(); + console.log('\n[Done]'); + }); + + eventSource.addEventListener('error', (event) => { + console.error('Stream error:', event); + eventSource.close(); + }); +} +``` + +### JavaScript/TypeScript (WebSocket) + +```typescript +function connectAgent(): WebSocket { + const ws = new WebSocket('ws://localhost:8000/ws'); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.t) { + case 'text': + process.stdout.write(data.c); + break; + case 'tool_exec': + console.log(`\n[Running ${data.name}...]`); + break; + case 'done': + console.log('\n[Complete]'); + break; + } + }; + + return ws; +} + +// Usage +const ws = connectAgent(); +ws.send(JSON.stringify({ prompt: 'Hello!' })); +``` + +### Python Client + +```python +import httpx +import json + +async def stream_response(prompt: str) -> None: + async with httpx.AsyncClient() as client: + # Start session + response = await client.post( + "http://localhost:8000/ask", + json={"prompt": prompt}, + ) + data = response.json() + + # Stream events + async with client.stream( + "GET", + f"http://localhost:8000{data['stream_url']}", + ) as stream: + async for line in stream.aiter_lines(): + if line.startswith("data: "): + event_data = json.loads(line[6:]) + if "content" in event_data: + print(event_data["content"], end="", flush=True) +``` + +### React Hook + +```typescript +import { useState, useCallback } from 'react'; + +interface Message { + role: 'user' | 'assistant'; + content: string; +} + +export function useAgent(baseUrl: string = 'http://localhost:8000') { + const [messages, setMessages] = useState([]); + const [isStreaming, setIsStreaming] = useState(false); + const [sessionId, setSessionId] = useState(null); + + const sendMessage = useCallback(async (prompt: string) => { + setMessages(prev => [...prev, { role: 'user', content: prompt }]); + setIsStreaming(true); + + // Start session + const response = await fetch(`${baseUrl}/ask`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt, session_id: sessionId }), + }); + const { session_id, stream_url } = await response.json(); + setSessionId(session_id); + + // Stream response + let assistantContent = ''; + setMessages(prev => [...prev, { role: 'assistant', content: '' }]); + + const eventSource = new EventSource(`${baseUrl}${stream_url}`); + + eventSource.addEventListener('text', (event) => { + const { content } = JSON.parse(event.data); + assistantContent += content; + setMessages(prev => [ + ...prev.slice(0, -1), + { role: 'assistant', content: assistantContent }, + ]); + }); + + eventSource.addEventListener('complete', () => { + eventSource.close(); + setIsStreaming(false); + }); + + eventSource.addEventListener('error', () => { + eventSource.close(); + setIsStreaming(false); + }); + }, [baseUrl, sessionId]); + + return { messages, sendMessage, isStreaming }; +} +``` + +## Configuration + +Customize server behavior with `ServerConfig`: + +```python +from bond.server import create_bond_server, ServerConfig + +config = ServerConfig( + host="0.0.0.0", + port=8000, + cors_origins=["http://localhost:3000", "https://myapp.com"], + session_timeout_seconds=3600, # 1 hour + max_concurrent_sessions=100, +) + +app = create_bond_server(agent, config=config) +``` + +## Multi-Turn Conversations + +Sessions maintain conversation history automatically: + +```typescript +// First message +const { session_id } = await startSession("My name is Alice."); + +// Continue with same session_id +await startSession("What's my name?", session_id); +// Agent responds: "Your name is Alice." +``` + +## Adding Tools + +Agents with tools stream tool execution events: + +```python +from bond.tools import github_toolset, GitHubAdapter + +agent = BondAgent( + name="code-assistant", + instructions="You help analyze code repositories.", + model="openai:gpt-4o", + toolsets=[github_toolset], + deps=GitHubAdapter(token=os.environ["GITHUB_TOKEN"]), +) + +app = create_bond_server(agent) +``` + +Clients receive `tool_exec` and `tool_result` events: + +```json +{"event": "tool_exec", "data": {"id": "call_123", "name": "github_get_repo", "args": {"owner": "facebook", "repo": "react"}}} +{"event": "tool_result", "data": {"id": "call_123", "name": "github_get_repo", "result": "{...}"}} +``` + +## Production Deployment + +### With Gunicorn + +```bash +gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 +``` + +### With Docker + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### Behind Nginx + +```nginx +upstream bond { + server 127.0.0.1:8000; +} + +server { + listen 80; + server_name api.example.com; + + location / { + proxy_pass http://bond; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + + # SSE support + proxy_buffering off; + proxy_cache off; + } +} +``` + +## Error Handling + +Handle connection errors and timeouts: + +```typescript +eventSource.onerror = (event) => { + if (eventSource.readyState === EventSource.CLOSED) { + // Connection was closed + console.log('Stream ended'); + } else { + // Connection error - retry + console.error('Connection error, retrying...'); + setTimeout(() => reconnect(), 1000); + } +}; +``` + +## See Also + +- [API Reference: Server](../api/server.md) - Full type definitions +- [GitHub Toolset](./github.md) - Adding GitHub tools to your agent +- [Architecture](../architecture.md) - How streaming works internally diff --git a/mkdocs.yml b/mkdocs.yml index cb230ab..f2cc224 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,7 +94,9 @@ nav: - Quickstart: quickstart.md - Architecture: architecture.md - Guides: - - GitHunter: guides/githunter.md + - Streaming Server: guides/streaming-server.md + - GitHub Toolset: guides/github.md + - GitHunter Toolset: guides/githunter.md - Development: - Overview: development/index.md - Adding Tools: development/adding-tools.md diff --git a/pyproject.toml b/pyproject.toml index 1e089c4..258dca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "sentence-transformers>=2.2.0", "asyncpg>=0.29.0", "aiofiles>=24.0.0", + "httpx>=0.27.0", ] [project.optional-dependencies] @@ -42,6 +43,11 @@ docs = [ "mkdocstrings[python]>=0.26.0", "mkdocs-autorefs>=0.5.0", ] +server = [ + "starlette>=0.40.0", + "uvicorn>=0.30.0", + "sse-starlette>=2.0.0", +] [project.urls] Documentation = "https://renbytes.github.io/bond-agent/" @@ -110,6 +116,12 @@ module = ["bond.trace.backends.*"] # aiofiles doesn't have type stubs bundled disallow_untyped_calls = false +[[tool.mypy.overrides]] +module = ["bond.server.*"] +# Starlette and sse-starlette typing +disallow_untyped_calls = false +disallow_any_generics = false + # ============================================================================ # ruff configuration # ============================================================================ diff --git a/src/bond/__init__.py b/src/bond/__init__.py index c74243b..addb0cb 100644 --- a/src/bond/__init__.py +++ b/src/bond/__init__.py @@ -39,3 +39,23 @@ "finalize_capture", "TraceReplayer", ] + + +def __getattr__(name: str) -> object: + """Lazy import for optional modules. + + The server module requires optional dependencies (starlette, uvicorn). + This provides a helpful error message if they're not installed. + """ + if name == "server": + try: + from bond import server + + return server + except ImportError as e: + raise ImportError( + "bond.server requires additional dependencies. " + "Install with: pip install bond-agent[server]" + ) from e + + raise AttributeError(f"module 'bond' has no attribute {name!r}") diff --git a/src/bond/server/__init__.py b/src/bond/server/__init__.py new file mode 100644 index 0000000..dbbf9c6 --- /dev/null +++ b/src/bond/server/__init__.py @@ -0,0 +1,52 @@ +"""Bond Server - Production-ready streaming server for Bond agents. + +This module provides a complete ASGI server that any Bond agent can use +for SSE and WebSocket streaming to UIs and clients. + +Example: + ```python + from bond import BondAgent + from bond.server import create_bond_server + + agent = BondAgent( + name="assistant", + instructions="You are helpful.", + model="openai:gpt-4o", + ) + + # Create ASGI app + app = create_bond_server(agent) + + # Run with uvicorn + # uvicorn main:app --reload + ``` + +Endpoints: + - POST /ask: Send prompt, get session_id for streaming + - GET /stream/{session_id}: SSE stream for agent response + - WS /ws: WebSocket bidirectional streaming + - GET /health: Health check +""" + +from bond.server._app import create_bond_server +from bond.server._session import Session, SessionManager, SessionStatus +from bond.server._types import ( + AskRequest, + HealthResponse, + ServerConfig, + SessionResponse, +) + +__all__ = [ + # Main factory + "create_bond_server", + # Session management + "SessionManager", + "Session", + "SessionStatus", + # Types + "ServerConfig", + "AskRequest", + "SessionResponse", + "HealthResponse", +] diff --git a/src/bond/server/_app.py b/src/bond/server/_app.py new file mode 100644 index 0000000..529c990 --- /dev/null +++ b/src/bond/server/_app.py @@ -0,0 +1,114 @@ +"""ASGI application factory for Bond server. + +Creates a production-ready Starlette application for any BondAgent. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware + +from bond.server._routes import BondRoutes +from bond.server._session import SessionManager +from bond.server._types import ServerConfig + +if TYPE_CHECKING: + from bond.agent import BondAgent + + +def create_bond_server( + agent: BondAgent[Any, Any], + config: ServerConfig | None = None, +) -> Starlette: + """Create a production-ready ASGI server for a BondAgent. + + Creates a Starlette application with SSE and WebSocket endpoints + for streaming agent responses to UIs and clients. + + Args: + agent: The BondAgent to serve. + config: Optional server configuration. Uses defaults if not provided. + + Returns: + Starlette ASGI application ready for uvicorn or other ASGI servers. + + Example: + ```python + from bond import BondAgent + from bond.server import create_bond_server, ServerConfig + + agent = BondAgent( + name="assistant", + instructions="You are helpful.", + model="openai:gpt-4o", + ) + + # Default configuration + app = create_bond_server(agent) + + # Custom configuration + app = create_bond_server( + agent, + config=ServerConfig( + port=3000, + cors_origins=["http://localhost:5173"], + ), + ) + + # Run with uvicorn + # uvicorn main:app --host 0.0.0.0 --port 8000 + ``` + + Endpoints: + POST /ask: + Request: {"prompt": "...", "session_id": "..." (optional)} + Response: {"session_id": "...", "stream_url": "/stream/..."} + + GET /stream/{session_id}: + SSE stream with events: text, thinking, tool_exec, tool_result, etc. + + WS /ws: + WebSocket endpoint. Send {"prompt": "..."}, receive streaming events. + + GET /health: + Response: {"status": "healthy", "agent_name": "..."} + """ + if config is None: + config = ServerConfig() + + # Create session manager + session_manager = SessionManager( + timeout_seconds=config.session_timeout_seconds, + max_sessions=config.max_concurrent_sessions, + ) + + # Create routes + routes = BondRoutes(agent, session_manager, config) + + # Configure CORS middleware + middleware = [ + Middleware( + CORSMiddleware, + allow_origins=config.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ), + ] + + # Create Starlette app + app = Starlette( + routes=routes.get_routes(), + middleware=middleware, + debug=False, + ) + + # Store references for access + app.state.agent = agent + app.state.session_manager = session_manager + app.state.config = config + + return app diff --git a/src/bond/server/_routes.py b/src/bond/server/_routes.py new file mode 100644 index 0000000..2917325 --- /dev/null +++ b/src/bond/server/_routes.py @@ -0,0 +1,290 @@ +"""Route handlers for Bond server. + +SSE, WebSocket, and REST endpoints for agent streaming. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Any + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route, WebSocketRoute +from starlette.websockets import WebSocket, WebSocketDisconnect + +from bond.server._session import SessionManager, SessionStatus +from bond.server._types import ( + AskRequest, + HealthResponse, + ServerConfig, + SessionResponse, +) +from bond.utils import create_sse_handlers, create_websocket_handlers + +if TYPE_CHECKING: + from bond.agent import BondAgent + + +class BondRoutes: + """Route handlers for Bond server. + + Creates route handlers that stream agent responses via SSE and WebSocket. + """ + + def __init__( + self, + agent: BondAgent[Any, Any], + session_manager: SessionManager, + config: ServerConfig, + ) -> None: + """Initialize routes. + + Args: + agent: The BondAgent to run. + session_manager: Session manager instance. + config: Server configuration. + """ + self.agent = agent + self.session_manager = session_manager + self.config = config + + async def health(self, _request: Request) -> JSONResponse: + """Health check endpoint. + + Returns: + JSON with service status and agent name. + """ + response = HealthResponse( + status="healthy", + agent_name=self.agent.name, + ) + return JSONResponse(response.model_dump()) + + async def ask(self, request: Request) -> JSONResponse: + """Start a new streaming session. + + Accepts POST with prompt, creates session, returns session_id. + Client then connects to /stream/{session_id} for SSE. + + Args: + request: Starlette request with JSON body. + + Returns: + JSON with session_id and stream_url. + """ + try: + body = await request.json() + ask_request = AskRequest.model_validate(body) + except Exception as e: + return JSONResponse( + {"error": f"Invalid request: {e}"}, + status_code=400, + ) + + try: + # Get existing history if continuing session + history = None + if ask_request.session_id: + existing = await self.session_manager.get_session(ask_request.session_id) + if existing: + history = existing.history + + session = await self.session_manager.create_session( + prompt=ask_request.prompt, + history=history, + session_id=ask_request.session_id, + ) + except ValueError as e: + return JSONResponse( + {"error": str(e)}, + status_code=503, + ) + + response = SessionResponse( + session_id=session.session_id, + stream_url=self.config.get_stream_url(session.session_id), + ) + return JSONResponse(response.model_dump()) + + async def stream(self, request: Request) -> Response: + """SSE streaming endpoint. + + Connects to a session and streams agent response events. + + Args: + request: Starlette request with session_id path param. + + Returns: + SSE event stream. + """ + from sse_starlette.sse import EventSourceResponse + + session_id = request.path_params["session_id"] + session = await self.session_manager.get_session(session_id) + + if not session: + return JSONResponse( + {"error": "Session not found or expired"}, + status_code=404, + ) + + async def event_generator() -> Any: + """Generate SSE events from agent streaming.""" + try: + await self.session_manager.update_status( + session_id, SessionStatus.STREAMING + ) + + # Set up agent history + self.agent.set_message_history(session.history) + + # Create SSE send function + async def send_sse(event: str, data: dict[str, Any]) -> None: + await session.result_queue.put({"event": event, "data": data}) + + handlers = create_sse_handlers(send_sse) + + # Start agent task + agent_task = asyncio.create_task( + self._run_agent(session_id, session.prompt, handlers) + ) + + # Yield events from queue + while True: + try: + # Wait for event with timeout + event_data = await asyncio.wait_for( + session.result_queue.get(), + timeout=1.0, + ) + + if event_data.get("event") == "_done": + # Agent completed + break + + yield { + "event": event_data["event"], + "data": json.dumps(event_data["data"]), + } + + except TimeoutError: + # Check if agent task failed + if agent_task.done(): + exc = agent_task.exception() + if exc: + yield { + "event": "error", + "data": json.dumps({"error": str(exc)}), + } + break + + except Exception as e: + await self.session_manager.update_status( + session_id, SessionStatus.ERROR, str(e) + ) + yield { + "event": "error", + "data": json.dumps({"error": str(e)}), + } + + return EventSourceResponse(event_generator()) + + async def _run_agent( + self, + session_id: str, + prompt: str, + handlers: Any, + ) -> None: + """Run agent and signal completion. + + Args: + session_id: Session to update. + prompt: User prompt. + handlers: StreamHandlers for streaming. + """ + session = await self.session_manager.get_session(session_id) + if not session: + return + + try: + await self.agent.ask(prompt, handlers=handlers) + + # Update history after completion + await self.session_manager.update_history( + session_id, + self.agent.get_message_history(), + ) + await self.session_manager.update_status( + session_id, SessionStatus.COMPLETED + ) + + except Exception as e: + await self.session_manager.update_status( + session_id, SessionStatus.ERROR, str(e) + ) + raise + finally: + # Signal completion to event generator + await session.result_queue.put({"event": "_done", "data": {}}) + + async def websocket_handler(self, websocket: WebSocket) -> None: + """WebSocket endpoint for bidirectional streaming. + + Protocol: + 1. Client connects + 2. Client sends {"prompt": "..."} messages + 3. Server streams response events + 4. Repeat or close + + Args: + websocket: Starlette WebSocket connection. + """ + await websocket.accept() + + try: + while True: + # Wait for prompt from client + data = await websocket.receive_json() + prompt = data.get("prompt") + + if not prompt: + await websocket.send_json({"error": "Missing 'prompt' field"}) + continue + + # Set history if provided + history = data.get("history") + if history: + self.agent.set_message_history(history) + + # Create WebSocket handlers + handlers = create_websocket_handlers(websocket.send_json) + + try: + # Run agent with streaming + await self.agent.ask(prompt, handlers=handlers) + + # Send completion marker + await websocket.send_json({"t": "done"}) + + except Exception as e: + await websocket.send_json({"t": "error", "error": str(e)}) + + except WebSocketDisconnect: + pass + except Exception: + await websocket.close() + + def get_routes(self) -> list[Route | WebSocketRoute]: + """Get all route definitions. + + Returns: + List of Starlette routes. + """ + return [ + Route("/health", self.health, methods=["GET"]), + Route("/ask", self.ask, methods=["POST"]), + Route("/stream/{session_id}", self.stream, methods=["GET"]), + WebSocketRoute("/ws", self.websocket_handler), + ] diff --git a/src/bond/server/_session.py b/src/bond/server/_session.py new file mode 100644 index 0000000..6743f03 --- /dev/null +++ b/src/bond/server/_session.py @@ -0,0 +1,232 @@ +"""Session management for Bond server. + +Handles session creation, lookup, and cleanup for streaming connections. +""" + +from __future__ import annotations + +import asyncio +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from pydantic_ai.messages import ModelMessage + + +class SessionStatus(str, Enum): + """Status of a streaming session.""" + + PENDING = "pending" # Created, waiting for SSE connection + STREAMING = "streaming" # Currently streaming response + COMPLETED = "completed" # Response finished + ERROR = "error" # Error occurred + EXPIRED = "expired" # Session timed out + + +@dataclass +class Session: + """A streaming session for an agent conversation. + + Attributes: + session_id: Unique session identifier. + prompt: The user's prompt for this session. + status: Current session status. + created_at: Unix timestamp when session was created. + history: Conversation history for multi-turn sessions. + result_queue: Queue for streaming results to SSE connection. + error: Error message if status is ERROR. + """ + + session_id: str + prompt: str + status: SessionStatus = SessionStatus.PENDING + created_at: float = field(default_factory=time.time) + history: list[ModelMessage] = field(default_factory=list) + result_queue: asyncio.Queue[dict[str, Any]] = field(default_factory=asyncio.Queue) + error: str | None = None + + def is_expired(self, timeout_seconds: int) -> bool: + """Check if session has expired based on timeout.""" + return time.time() - self.created_at > timeout_seconds + + +class SessionManager: + """Manages active streaming sessions. + + Thread-safe session storage with automatic cleanup of expired sessions. + + Example: + ```python + manager = SessionManager(timeout_seconds=3600) + + # Create a session + session = manager.create_session("What is 2+2?") + + # Get the session later + session = manager.get_session(session.session_id) + + # Update status + manager.update_status(session.session_id, SessionStatus.STREAMING) + + # Cleanup expired sessions periodically + await manager.cleanup_expired() + ``` + """ + + def __init__( + self, + timeout_seconds: int = 3600, + max_sessions: int = 100, + ) -> None: + """Initialize session manager. + + Args: + timeout_seconds: Time before sessions expire. + max_sessions: Maximum concurrent sessions allowed. + """ + self._sessions: dict[str, Session] = {} + self._lock = asyncio.Lock() + self._timeout_seconds = timeout_seconds + self._max_sessions = max_sessions + + async def create_session( + self, + prompt: str, + history: list[ModelMessage] | None = None, + session_id: str | None = None, + ) -> Session: + """Create a new streaming session. + + Args: + prompt: The user's prompt. + history: Optional conversation history. + session_id: Optional session ID to reuse (for continuing conversations). + + Returns: + The created Session object. + + Raises: + ValueError: If max sessions reached. + """ + async with self._lock: + # Cleanup expired first to make room + await self._cleanup_expired_locked() + + if len(self._sessions) >= self._max_sessions: + raise ValueError( + f"Maximum concurrent sessions ({self._max_sessions}) reached" + ) + + # Use provided session_id or generate new one + if session_id and session_id in self._sessions: + # Continuing existing session + session = self._sessions[session_id] + session.prompt = prompt + session.status = SessionStatus.PENDING + session.created_at = time.time() + session.result_queue = asyncio.Queue() + session.error = None + else: + # New session + new_id = session_id or str(uuid.uuid4()) + session = Session( + session_id=new_id, + prompt=prompt, + history=list(history) if history else [], + ) + self._sessions[new_id] = session + + return session + + async def get_session(self, session_id: str) -> Session | None: + """Get a session by ID. + + Args: + session_id: The session ID to look up. + + Returns: + The Session if found and not expired, None otherwise. + """ + async with self._lock: + session = self._sessions.get(session_id) + if session and session.is_expired(self._timeout_seconds): + session.status = SessionStatus.EXPIRED + del self._sessions[session_id] + return None + return session + + async def update_status( + self, + session_id: str, + status: SessionStatus, + error: str | None = None, + ) -> None: + """Update session status. + + Args: + session_id: The session to update. + status: New status. + error: Optional error message (for ERROR status). + """ + async with self._lock: + session = self._sessions.get(session_id) + if session: + session.status = status + if error: + session.error = error + + async def update_history( + self, + session_id: str, + history: list[ModelMessage], + ) -> None: + """Update session conversation history. + + Args: + session_id: The session to update. + history: New conversation history. + """ + async with self._lock: + session = self._sessions.get(session_id) + if session: + session.history = list(history) + + async def remove_session(self, session_id: str) -> None: + """Remove a session. + + Args: + session_id: The session to remove. + """ + async with self._lock: + self._sessions.pop(session_id, None) + + async def cleanup_expired(self) -> int: + """Remove all expired sessions. + + Returns: + Number of sessions removed. + """ + async with self._lock: + return await self._cleanup_expired_locked() + + async def _cleanup_expired_locked(self) -> int: + """Internal cleanup (must hold lock). + + Returns: + Number of sessions removed. + """ + expired = [ + sid + for sid, session in self._sessions.items() + if session.is_expired(self._timeout_seconds) + ] + for sid in expired: + del self._sessions[sid] + return len(expired) + + @property + def active_count(self) -> int: + """Number of active sessions.""" + return len(self._sessions) diff --git a/src/bond/server/_types.py b/src/bond/server/_types.py new file mode 100644 index 0000000..3bba76b --- /dev/null +++ b/src/bond/server/_types.py @@ -0,0 +1,73 @@ +"""Server configuration and request types. + +Type definitions for the Bond server module. +""" + +from dataclasses import dataclass, field +from typing import Annotated + +from pydantic import BaseModel, Field + + +class AskRequest(BaseModel): + """Request to start a new agent conversation. + + Sent to POST /ask endpoint to initiate a streaming session. + """ + + prompt: Annotated[ + str, + Field(description="The user's message/question for the agent"), + ] + + session_id: Annotated[ + str | None, + Field(default=None, description="Optional session ID to continue a conversation"), + ] + + +class SessionResponse(BaseModel): + """Response from POST /ask with session information. + + Contains the session_id needed to connect to the SSE stream. + """ + + session_id: Annotated[ + str, + Field(description="Unique session identifier"), + ] + + stream_url: Annotated[ + str, + Field(description="URL to connect for SSE streaming"), + ] + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: Annotated[str, Field(description="Service status")] + agent_name: Annotated[str, Field(description="Name of the configured agent")] + + +@dataclass +class ServerConfig: + """Configuration for the Bond server. + + Attributes: + host: Host to bind to (default: "0.0.0.0"). + port: Port to bind to (default: 8000). + cors_origins: Allowed CORS origins (default: ["*"]). + session_timeout_seconds: Session expiry time (default: 3600). + max_concurrent_sessions: Maximum concurrent sessions (default: 100). + """ + + host: str = "0.0.0.0" + port: int = 8000 + cors_origins: list[str] = field(default_factory=lambda: ["*"]) + session_timeout_seconds: int = 3600 + max_concurrent_sessions: int = 100 + + def get_stream_url(self, session_id: str) -> str: + """Generate the stream URL for a session.""" + return f"/stream/{session_id}" diff --git a/src/bond/tools/__init__.py b/src/bond/tools/__init__.py index 571a964..6f675a9 100644 --- a/src/bond/tools/__init__.py +++ b/src/bond/tools/__init__.py @@ -1 +1,47 @@ -"""Bond toolsets for agent capabilities.""" +"""Bond toolsets for agent capabilities. + +Provides ready-to-use toolsets for common agent capabilities: +- GitHub: Browse and analyze GitHub repositories +- GitHunter: Forensic code ownership analysis +- Memory: Semantic memory with vector databases +- Schema: Database schema exploration + +Example: + ```python + from bond import BondAgent + from bond.tools import BondToolDeps, github_toolset, githunter_toolset + + # Create composite deps for multiple toolsets + deps = BondToolDeps(github_token=os.environ["GITHUB_TOKEN"]) + + # Create agent with multiple capabilities + agent = BondAgent( + name="code-analyst", + instructions="You analyze code repositories.", + model="openai:gpt-4o", + toolsets=[github_toolset, githunter_toolset], + deps=deps, + ) + ``` +""" + +from bond.tools._composite import BondToolDeps +from bond.tools.github import GitHubAdapter, GitHubProtocol, github_toolset +from bond.tools.githunter import GitHunterAdapter, GitHunterProtocol, githunter_toolset +from bond.tools.memory import AgentMemoryProtocol, memory_toolset + +__all__ = [ + # Composite deps + "BondToolDeps", + # GitHub + "github_toolset", + "GitHubAdapter", + "GitHubProtocol", + # GitHunter + "githunter_toolset", + "GitHunterAdapter", + "GitHunterProtocol", + # Memory + "memory_toolset", + "AgentMemoryProtocol", +] diff --git a/src/bond/tools/_composite.py b/src/bond/tools/_composite.py new file mode 100644 index 0000000..04fbddc --- /dev/null +++ b/src/bond/tools/_composite.py @@ -0,0 +1,204 @@ +"""Composite dependencies for Bond agents. + +Provides a unified dependencies object that satisfies multiple tool protocols, +enabling agents to use multiple toolsets with a single deps injection. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bond.tools.github._adapter import GitHubAdapter + from bond.tools.github._types import ( + CodeSearchResult, + Commit, + FileContent, + PullRequest, + RepoInfo, + TreeEntry, + ) + from bond.tools.githunter._adapter import GitHunterAdapter + from bond.tools.githunter._types import BlameResult, FileExpert, PRDiscussion + + +class BondToolDeps: + """Composite dependencies satisfying GitHubProtocol and GitHunterProtocol. + + This class delegates to specialized adapters, allowing a single deps object + to be used with multiple toolsets. Adapters are lazily initialized based + on which capabilities are configured. + + Example: + ```python + from bond import BondAgent + from bond.tools import BondToolDeps, github_toolset + from bond.tools.githunter import githunter_toolset + + # Create composite deps with GitHub capabilities + deps = BondToolDeps(github_token=os.environ["GITHUB_TOKEN"]) + + # Create agent with multiple toolsets + agent = BondAgent( + name="code-analyst", + instructions="You analyze code repositories.", + model="openai:gpt-4o", + toolsets=[github_toolset, githunter_toolset], + deps=deps, + ) + ``` + + Note: + - GitHub tools require `github_token` to be set + - GitHunter tools work locally without token but need token for PR lookup + - All adapters are lazily initialized on first use + """ + + def __init__( + self, + github_token: str | None = None, + repo_path: Path | None = None, + ) -> None: + """Initialize composite dependencies. + + Args: + github_token: GitHub personal access token for API access. + Falls back to GITHUB_TOKEN environment variable. + repo_path: Default local repository path for GitHunter tools. + Can be overridden per-call in tool requests. + """ + self._github_token = github_token + self._repo_path = repo_path + + # Lazy-initialized adapters + self._github_adapter: GitHubAdapter | None = None + self._githunter_adapter: GitHunterAdapter | None = None + + def _get_github_adapter(self) -> GitHubAdapter: + """Get or create GitHubAdapter.""" + if self._github_adapter is None: + from bond.tools.github._adapter import GitHubAdapter + + self._github_adapter = GitHubAdapter(token=self._github_token) + return self._github_adapter + + def _get_githunter_adapter(self) -> GitHunterAdapter: + """Get or create GitHunterAdapter.""" + if self._githunter_adapter is None: + from bond.tools.githunter._adapter import GitHunterAdapter + + self._githunter_adapter = GitHunterAdapter() + return self._githunter_adapter + + # ========================================================================= + # GitHubProtocol implementation + # ========================================================================= + + async def get_repo(self, owner: str, repo: str) -> RepoInfo: + """Get repository metadata. Delegates to GitHubAdapter.""" + return await self._get_github_adapter().get_repo(owner, repo) + + async def list_tree( + self, + owner: str, + repo: str, + path: str = "", + ref: str | None = None, + ) -> list[TreeEntry]: + """List directory contents. Delegates to GitHubAdapter.""" + return await self._get_github_adapter().list_tree(owner, repo, path, ref) + + async def get_file( + self, + owner: str, + repo: str, + path: str, + ref: str | None = None, + ) -> FileContent: + """Read file content. Delegates to GitHubAdapter.""" + return await self._get_github_adapter().get_file(owner, repo, path, ref) + + async def search_code( + self, + query: str, + owner: str | None = None, + repo: str | None = None, + limit: int = 10, + ) -> list[CodeSearchResult]: + """Search code. Delegates to GitHubAdapter.""" + return await self._get_github_adapter().search_code(query, owner, repo, limit) + + async def get_commits( + self, + owner: str, + repo: str, + path: str | None = None, + ref: str | None = None, + limit: int = 10, + ) -> list[Commit]: + """Get commit history. Delegates to GitHubAdapter.""" + return await self._get_github_adapter().get_commits(owner, repo, path, ref, limit) + + async def get_pr( + self, + owner: str, + repo: str, + number: int, + ) -> PullRequest: + """Get pull request details. Delegates to GitHubAdapter.""" + return await self._get_github_adapter().get_pr(owner, repo, number) + + # ========================================================================= + # GitHunterProtocol implementation + # ========================================================================= + + async def blame_line( + self, + repo_path: Path, + file_path: str, + line_no: int, + ) -> BlameResult: + """Get blame information for a line. Delegates to GitHunterAdapter.""" + return await self._get_githunter_adapter().blame_line(repo_path, file_path, line_no) + + async def find_pr_discussion( + self, + repo_path: Path, + commit_hash: str, + ) -> PRDiscussion | None: + """Find PR discussion for commit. Delegates to GitHunterAdapter.""" + return await self._get_githunter_adapter().find_pr_discussion(repo_path, commit_hash) + + async def get_expert_for_file( + self, + repo_path: Path, + file_path: str, + window_days: int = 90, + limit: int = 3, + ) -> list[FileExpert]: + """Get file experts. Delegates to GitHunterAdapter.""" + return await self._get_githunter_adapter().get_expert_for_file( + repo_path, file_path, window_days, limit + ) + + # ========================================================================= + # Lifecycle management + # ========================================================================= + + async def close(self) -> None: + """Close all adapter connections.""" + if self._github_adapter: + await self._github_adapter.close() + self._github_adapter = None + if self._githunter_adapter: + await self._githunter_adapter.close() + self._githunter_adapter = None + + async def __aenter__(self) -> BondToolDeps: + """Async context manager entry.""" + return self + + async def __aexit__(self, *args: object) -> None: + """Async context manager exit.""" + await self.close() diff --git a/src/bond/tools/github/__init__.py b/src/bond/tools/github/__init__.py new file mode 100644 index 0000000..af5864a --- /dev/null +++ b/src/bond/tools/github/__init__.py @@ -0,0 +1,72 @@ +"""GitHub toolset for Bond agents. + +Provides tools to browse and analyze any GitHub repository. + +Example: + ```python + from bond import BondAgent + from bond.tools.github import github_toolset, GitHubAdapter + + # Create adapter with token + adapter = GitHubAdapter(token=os.environ["GITHUB_TOKEN"]) + + # Create agent with GitHub tools + agent = BondAgent( + name="code-analyst", + instructions="You analyze code repositories.", + model="openai:gpt-4o", + toolsets=[github_toolset], + deps=adapter, + ) + + # Use the agent + response = await agent.ask("What is the structure of the react repo?") + ``` +""" + +from bond.tools.github._adapter import GitHubAdapter +from bond.tools.github._exceptions import ( + AuthenticationError, + FileNotFoundError, + GitHubAPIError, + GitHubError, + PRNotFoundError, + RateLimitedError, + RepoNotFoundError, +) +from bond.tools.github._protocols import GitHubProtocol +from bond.tools.github._types import ( + CodeSearchResult, + Commit, + CommitAuthor, + FileContent, + PullRequest, + PullRequestUser, + RepoInfo, + TreeEntry, +) +from bond.tools.github.tools import github_toolset + +__all__ = [ + # Main exports + "github_toolset", + "GitHubAdapter", + "GitHubProtocol", + # Types + "RepoInfo", + "TreeEntry", + "FileContent", + "CodeSearchResult", + "Commit", + "CommitAuthor", + "PullRequest", + "PullRequestUser", + # Exceptions + "GitHubError", + "RepoNotFoundError", + "FileNotFoundError", + "PRNotFoundError", + "RateLimitedError", + "AuthenticationError", + "GitHubAPIError", +] diff --git a/src/bond/tools/github/_adapter.py b/src/bond/tools/github/_adapter.py new file mode 100644 index 0000000..0387247 --- /dev/null +++ b/src/bond/tools/github/_adapter.py @@ -0,0 +1,411 @@ +"""GitHub API adapter. + +Implements GitHubProtocol using httpx for the GitHub REST API. +""" + +from __future__ import annotations + +import asyncio +import base64 +import os +from datetime import datetime +from typing import Any + +import httpx + +from ._exceptions import ( + AuthenticationError, + FileNotFoundError, + GitHubAPIError, + PRNotFoundError, + RateLimitedError, + RepoNotFoundError, +) +from ._types import ( + CodeSearchResult, + Commit, + CommitAuthor, + FileContent, + PullRequest, + PullRequestUser, + RepoInfo, + TreeEntry, +) + +# GitHub API base URL +GITHUB_API_BASE = "https://api.github.com" + + +class GitHubAdapter: + """GitHub API adapter implementing GitHubProtocol. + + Uses httpx.AsyncClient for efficient HTTP requests with: + - Automatic rate limit handling with exponential backoff + - Token authentication from environment or constructor + - Connection pooling for performance + + Example: + ```python + # Use token from environment + adapter = GitHubAdapter() + + # Or provide token explicitly + adapter = GitHubAdapter(token="ghp_...") + + # Use the adapter + repo = await adapter.get_repo("facebook", "react") + print(repo.description) + ``` + """ + + def __init__( + self, + token: str | None = None, + base_url: str = GITHUB_API_BASE, + max_retries: int = 3, + ) -> None: + """Initialize the adapter. + + Args: + token: GitHub personal access token. Falls back to GITHUB_TOKEN env var. + base_url: GitHub API base URL (for GitHub Enterprise). + max_retries: Maximum retries for rate-limited requests. + """ + self._token = token or os.environ.get("GITHUB_TOKEN") + self._base_url = base_url.rstrip("/") + self._max_retries = max_retries + self._client: httpx.AsyncClient | None = None + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create the HTTP client.""" + if self._client is None: + headers: dict[str, str] = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "bond-agent/0.1", + } + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + + self._client = httpx.AsyncClient( + base_url=self._base_url, + headers=headers, + timeout=30.0, + ) + return self._client + + async def close(self) -> None: + """Close the HTTP client.""" + if self._client: + await self._client.aclose() + self._client = None + + async def _request( + self, + method: str, + path: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any] | list[Any]: + """Make an API request with rate limit handling. + + Args: + method: HTTP method. + path: API path. + params: Query parameters. + + Returns: + Parsed JSON response. + + Raises: + GitHubError: On API errors. + """ + client = await self._get_client() + retries = 0 + + while True: + response = await client.request(method, path, params=params) + + # Handle rate limiting + if response.status_code == 403: + remaining = response.headers.get("X-RateLimit-Remaining", "0") + if remaining == "0": + if retries >= self._max_retries: + reset_at = response.headers.get("X-RateLimit-Reset") + raise RateLimitedError(int(reset_at) if reset_at else None) + + # Exponential backoff + wait_time = 2 ** retries + await asyncio.sleep(wait_time) + retries += 1 + continue + + # Handle authentication errors + if response.status_code == 401: + raise AuthenticationError() + + # Handle not found + if response.status_code == 404: + return {"_status": 404} + + # Handle other errors + if response.status_code >= 400: + try: + error_data = response.json() + message = error_data.get("message", "Unknown error") + except Exception: + message = response.text or "Unknown error" + raise GitHubAPIError(response.status_code, message) + + return response.json() # type: ignore[no-any-return] + + def _parse_datetime(self, value: str | None) -> datetime: + """Parse ISO 8601 datetime string.""" + if not value: + return datetime.min + # Handle Z suffix and remove microseconds if present + value = value.replace("Z", "+00:00") + return datetime.fromisoformat(value) + + async def get_repo(self, owner: str, repo: str) -> RepoInfo: + """Get repository metadata.""" + data = await self._request("GET", f"/repos/{owner}/{repo}") + + if isinstance(data, dict) and data.get("_status") == 404: + raise RepoNotFoundError(owner, repo) + + if not isinstance(data, dict): + raise GitHubAPIError(500, "Unexpected response format") + + return RepoInfo( + owner=data["owner"]["login"], + name=data["name"], + full_name=data["full_name"], + description=data.get("description"), + default_branch=data["default_branch"], + topics=tuple(data.get("topics", [])), + language=data.get("language"), + stars=data.get("stargazers_count", 0), + forks=data.get("forks_count", 0), + is_private=data.get("private", False), + created_at=self._parse_datetime(data.get("created_at")), + updated_at=self._parse_datetime(data.get("updated_at")), + ) + + async def list_tree( + self, + owner: str, + repo: str, + path: str = "", + ref: str | None = None, + ) -> list[TreeEntry]: + """List directory contents at path.""" + # Build the path + api_path = f"/repos/{owner}/{repo}/contents/{path.lstrip('/')}" + params: dict[str, str] = {} + if ref: + params["ref"] = ref + + data = await self._request("GET", api_path, params=params or None) + + if isinstance(data, dict) and data.get("_status") == 404: + raise FileNotFoundError(owner, repo, path) + + # Single file returns dict, directory returns list + if isinstance(data, dict): + # It's a file, not a directory + return [ + TreeEntry( + path=data["path"], + name=data["name"], + type=data["type"], + size=data.get("size"), + sha=data["sha"], + ) + ] + + return [ + TreeEntry( + path=item["path"], + name=item["name"], + type=item["type"], + size=item.get("size"), + sha=item["sha"], + ) + for item in data + ] + + async def get_file( + self, + owner: str, + repo: str, + path: str, + ref: str | None = None, + ) -> FileContent: + """Read file content.""" + api_path = f"/repos/{owner}/{repo}/contents/{path.lstrip('/')}" + params: dict[str, str] = {} + if ref: + params["ref"] = ref + + data = await self._request("GET", api_path, params=params or None) + + if isinstance(data, dict) and data.get("_status") == 404: + raise FileNotFoundError(owner, repo, path) + + if not isinstance(data, dict): + raise GitHubAPIError(500, "Unexpected response format") + + if data.get("type") != "file": + raise GitHubAPIError(400, f"Path is not a file: {path}") + + # Decode base64 content + content = data.get("content", "") + encoding = data.get("encoding", "base64") + + if encoding == "base64": + # GitHub returns base64 with newlines + content = base64.b64decode(content.replace("\n", "")).decode("utf-8") + + return FileContent( + path=data["path"], + content=content, + encoding=encoding, + size=data.get("size", 0), + sha=data["sha"], + ) + + async def search_code( + self, + query: str, + owner: str | None = None, + repo: str | None = None, + limit: int = 10, + ) -> list[CodeSearchResult]: + """Search code within repository or across GitHub.""" + # Build search query + search_query = query + if owner and repo: + search_query = f"{query} repo:{owner}/{repo}" + elif owner: + search_query = f"{query} user:{owner}" + + params = { + "q": search_query, + "per_page": str(min(limit, 100)), + } + + # Request text matches + headers = {"Accept": "application/vnd.github.text-match+json"} + client = await self._get_client() + + response = await client.request( + "GET", + "/search/code", + params=params, + headers=headers, + ) + + if response.status_code == 403: + raise RateLimitedError() + if response.status_code >= 400: + raise GitHubAPIError(response.status_code, response.text) + + data = response.json() + items = data.get("items", []) + + return [ + CodeSearchResult( + path=item["path"], + repository=item["repository"]["full_name"], + html_url=item["html_url"], + text_matches=tuple( + match.get("fragment", "") + for match in item.get("text_matches", []) + ), + ) + for item in items + ] + + async def get_commits( + self, + owner: str, + repo: str, + path: str | None = None, + ref: str | None = None, + limit: int = 10, + ) -> list[Commit]: + """Get recent commits for file or repository.""" + params: dict[str, str] = { + "per_page": str(min(limit, 100)), + } + if path: + params["path"] = path + if ref: + params["sha"] = ref + + data = await self._request( + "GET", + f"/repos/{owner}/{repo}/commits", + params=params, + ) + + if isinstance(data, dict) and data.get("_status") == 404: + raise RepoNotFoundError(owner, repo) + + if not isinstance(data, list): + raise GitHubAPIError(500, "Unexpected response format") + + return [ + Commit( + sha=item["sha"], + message=item["commit"]["message"], + author=CommitAuthor( + name=item["commit"]["author"]["name"], + email=item["commit"]["author"]["email"], + date=self._parse_datetime(item["commit"]["author"]["date"]), + ), + committer=CommitAuthor( + name=item["commit"]["committer"]["name"], + email=item["commit"]["committer"]["email"], + date=self._parse_datetime(item["commit"]["committer"]["date"]), + ), + html_url=item["html_url"], + ) + for item in data + ] + + async def get_pr( + self, + owner: str, + repo: str, + number: int, + ) -> PullRequest: + """Get pull request details by number.""" + data = await self._request( + "GET", + f"/repos/{owner}/{repo}/pulls/{number}", + ) + + if isinstance(data, dict) and data.get("_status") == 404: + raise PRNotFoundError(owner, repo, number) + + if not isinstance(data, dict): + raise GitHubAPIError(500, "Unexpected response format") + + return PullRequest( + number=data["number"], + title=data["title"], + body=data.get("body"), + state=data["state"], + user=PullRequestUser( + login=data["user"]["login"], + html_url=data["user"]["html_url"], + ), + html_url=data["html_url"], + created_at=self._parse_datetime(data.get("created_at")), + updated_at=self._parse_datetime(data.get("updated_at")), + merged_at=self._parse_datetime(data.get("merged_at")) + if data.get("merged_at") + else None, + base_branch=data["base"]["ref"], + head_branch=data["head"]["ref"], + ) diff --git a/src/bond/tools/github/_exceptions.py b/src/bond/tools/github/_exceptions.py new file mode 100644 index 0000000..e6de48e --- /dev/null +++ b/src/bond/tools/github/_exceptions.py @@ -0,0 +1,98 @@ +"""GitHub-specific exceptions. + +Custom exception hierarchy for GitHub operations. +""" + + +class GitHubError(Exception): + """Base exception for GitHub operations.""" + + pass + + +class RepoNotFoundError(GitHubError): + """Repository not found.""" + + def __init__(self, owner: str, repo: str) -> None: + """Initialize error. + + Args: + owner: Repository owner. + repo: Repository name. + """ + self.owner = owner + self.repo = repo + super().__init__(f"Repository not found: {owner}/{repo}") + + +class FileNotFoundError(GitHubError): + """File not found in repository.""" + + def __init__(self, owner: str, repo: str, path: str) -> None: + """Initialize error. + + Args: + owner: Repository owner. + repo: Repository name. + path: File path that was not found. + """ + self.owner = owner + self.repo = repo + self.path = path + super().__init__(f"File not found: {owner}/{repo}/{path}") + + +class PRNotFoundError(GitHubError): + """Pull request not found.""" + + def __init__(self, owner: str, repo: str, number: int) -> None: + """Initialize error. + + Args: + owner: Repository owner. + repo: Repository name. + number: PR number. + """ + self.owner = owner + self.repo = repo + self.number = number + super().__init__(f"PR not found: {owner}/{repo}#{number}") + + +class RateLimitedError(GitHubError): + """GitHub API rate limit exceeded.""" + + def __init__(self, reset_at: int | None = None) -> None: + """Initialize error. + + Args: + reset_at: Unix timestamp when rate limit resets. + """ + self.reset_at = reset_at + msg = "GitHub API rate limit exceeded" + if reset_at: + msg += f" (resets at {reset_at})" + super().__init__(msg) + + +class AuthenticationError(GitHubError): + """GitHub authentication failed.""" + + def __init__(self) -> None: + """Initialize error.""" + super().__init__("GitHub authentication failed. Check GITHUB_TOKEN.") + + +class GitHubAPIError(GitHubError): + """Generic GitHub API error.""" + + def __init__(self, status_code: int, message: str) -> None: + """Initialize error. + + Args: + status_code: HTTP status code. + message: Error message from API. + """ + self.status_code = status_code + self.message = message + super().__init__(f"GitHub API error ({status_code}): {message}") diff --git a/src/bond/tools/github/_models.py b/src/bond/tools/github/_models.py new file mode 100644 index 0000000..e182b22 --- /dev/null +++ b/src/bond/tools/github/_models.py @@ -0,0 +1,178 @@ +"""GitHub request and error models. + +Pydantic models for GitHub tool inputs and error responses. +""" + +from typing import Annotated + +from pydantic import BaseModel, Field + + +class GetRepoRequest(BaseModel): + """Request to get repository metadata. + + Agent Usage: Use this to get basic information about a GitHub repository + including description, default branch, topics, and statistics. + """ + + owner: Annotated[ + str, + Field(description="Repository owner (username or organization)"), + ] + + repo: Annotated[ + str, + Field(description="Repository name"), + ] + + +class ListFilesRequest(BaseModel): + """Request to list files in a directory. + + Agent Usage: Use this to browse the file structure of a repository. + Start from the root (empty path) and navigate into subdirectories. + """ + + owner: Annotated[ + str, + Field(description="Repository owner"), + ] + + repo: Annotated[ + str, + Field(description="Repository name"), + ] + + path: Annotated[ + str, + Field(default="", description="Path relative to repo root (empty for root)"), + ] + + ref: Annotated[ + str | None, + Field(default=None, description="Branch, tag, or commit SHA (default branch if None)"), + ] + + +class ReadFileRequest(BaseModel): + """Request to read file content. + + Agent Usage: Use this to read the contents of a specific file. + Combine with list_files to find files first. + """ + + owner: Annotated[ + str, + Field(description="Repository owner"), + ] + + repo: Annotated[ + str, + Field(description="Repository name"), + ] + + path: Annotated[ + str, + Field(description="Path to file relative to repo root"), + ] + + ref: Annotated[ + str | None, + Field(default=None, description="Branch, tag, or commit SHA (default branch if None)"), + ] + + +class SearchCodeRequest(BaseModel): + """Request to search code. + + Agent Usage: Use this to find code containing specific terms, patterns, + or identifiers. Can search within a specific repo or across GitHub. + """ + + query: Annotated[ + str, + Field(description="Search query (e.g., 'class UserService', 'TODO fix')"), + ] + + owner: Annotated[ + str | None, + Field(default=None, description="Optional owner to scope search"), + ] + + repo: Annotated[ + str | None, + Field(default=None, description="Optional repo name to scope search (requires owner)"), + ] + + limit: Annotated[ + int, + Field(default=10, ge=1, le=100, description="Maximum results to return"), + ] + + +class GetCommitsRequest(BaseModel): + """Request to get commit history. + + Agent Usage: Use this to see recent changes to a file or repository. + Useful for understanding when and why code changed. + """ + + owner: Annotated[ + str, + Field(description="Repository owner"), + ] + + repo: Annotated[ + str, + Field(description="Repository name"), + ] + + path: Annotated[ + str | None, + Field(default=None, description="Optional file path to filter commits"), + ] + + ref: Annotated[ + str | None, + Field(default=None, description="Branch, tag, or commit to start from"), + ] + + limit: Annotated[ + int, + Field(default=10, ge=1, le=100, description="Maximum commits to return"), + ] + + +class GetPRRequest(BaseModel): + """Request to get pull request details. + + Agent Usage: Use this to get information about a specific PR + including title, description, author, and merge status. + """ + + owner: Annotated[ + str, + Field(description="Repository owner"), + ] + + repo: Annotated[ + str, + Field(description="Repository name"), + ] + + number: Annotated[ + int, + Field(ge=1, description="Pull request number"), + ] + + +class Error(BaseModel): + """Error response from GitHub operations. + + Used as union return type: `RepoInfo | Error`, `list[TreeEntry] | Error`, etc. + """ + + description: Annotated[ + str, + Field(description="Error message explaining what went wrong"), + ] diff --git a/src/bond/tools/github/_protocols.py b/src/bond/tools/github/_protocols.py new file mode 100644 index 0000000..d4cde51 --- /dev/null +++ b/src/bond/tools/github/_protocols.py @@ -0,0 +1,165 @@ +"""Protocol definition for GitHub tools. + +Defines the interface that GitHubAdapter must implement. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from ._types import ( + CodeSearchResult, + Commit, + FileContent, + PullRequest, + RepoInfo, + TreeEntry, +) + + +@runtime_checkable +class GitHubProtocol(Protocol): + """Protocol for GitHub API access. + + Provides methods to: + - Get repository metadata + - Browse repository file tree + - Read file contents + - Search code + - Get commit history + - Get pull request details + """ + + async def get_repo( + self, + owner: str, + repo: str, + ) -> RepoInfo: + """Get repository metadata. + + Args: + owner: Repository owner (user or organization). + repo: Repository name. + + Returns: + RepoInfo with repository metadata. + + Raises: + GitHubError: If repository not found or API error. + """ + ... + + async def list_tree( + self, + owner: str, + repo: str, + path: str = "", + ref: str | None = None, + ) -> list[TreeEntry]: + """List directory contents at path. + + Args: + owner: Repository owner. + repo: Repository name. + path: Path relative to repo root (empty string for root). + ref: Git ref (branch, tag, commit). Uses default branch if None. + + Returns: + List of TreeEntry for files and directories at path. + + Raises: + GitHubError: If path not found or API error. + """ + ... + + async def get_file( + self, + owner: str, + repo: str, + path: str, + ref: str | None = None, + ) -> FileContent: + """Read file content. + + Args: + owner: Repository owner. + repo: Repository name. + path: Path to file relative to repo root. + ref: Git ref (branch, tag, commit). Uses default branch if None. + + Returns: + FileContent with decoded file content. + + Raises: + GitHubError: If file not found or API error. + """ + ... + + async def search_code( + self, + query: str, + owner: str | None = None, + repo: str | None = None, + limit: int = 10, + ) -> list[CodeSearchResult]: + """Search code within repository or across GitHub. + + Args: + query: Search query string. + owner: Optional owner to scope search. + repo: Optional repo name to scope search (requires owner). + limit: Maximum results to return. + + Returns: + List of CodeSearchResult with matching files. + + Raises: + GitHubError: If search fails or rate limited. + """ + ... + + async def get_commits( + self, + owner: str, + repo: str, + path: str | None = None, + ref: str | None = None, + limit: int = 10, + ) -> list[Commit]: + """Get recent commits for file or repository. + + Args: + owner: Repository owner. + repo: Repository name. + path: Optional path to filter commits by file. + ref: Git ref to start from. Uses default branch if None. + limit: Maximum commits to return. + + Returns: + List of Commit sorted by date descending. + + Raises: + GitHubError: If repository not found or API error. + """ + ... + + async def get_pr( + self, + owner: str, + repo: str, + number: int, + ) -> PullRequest: + """Get pull request details by number. + + Args: + owner: Repository owner. + repo: Repository name. + number: Pull request number. + + Returns: + PullRequest with PR details. + + Raises: + GitHubError: If PR not found or API error. + """ + ... diff --git a/src/bond/tools/github/_types.py b/src/bond/tools/github/_types.py new file mode 100644 index 0000000..95d0d25 --- /dev/null +++ b/src/bond/tools/github/_types.py @@ -0,0 +1,173 @@ +"""GitHub domain types. + +Frozen dataclass types for GitHub API responses. +""" + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class RepoInfo: + """Repository metadata. + + Attributes: + owner: Repository owner (user or organization). + name: Repository name. + full_name: Full name in owner/repo format. + description: Repository description. + default_branch: Default branch name (e.g., "main"). + topics: List of repository topics. + language: Primary programming language. + stars: Star count. + forks: Fork count. + is_private: Whether the repository is private. + created_at: Creation timestamp. + updated_at: Last update timestamp. + """ + + owner: str + name: str + full_name: str + description: str | None + default_branch: str + topics: tuple[str, ...] + language: str | None + stars: int + forks: int + is_private: bool + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True) +class TreeEntry: + """Entry in a repository file tree. + + Attributes: + path: Path relative to repository root. + name: File or directory name. + type: Either "file" or "dir". + size: File size in bytes (None for directories). + sha: Git SHA hash. + """ + + path: str + name: str + type: str # "file" or "dir" + size: int | None + sha: str + + +@dataclass(frozen=True) +class FileContent: + """File content from a repository. + + Attributes: + path: Path relative to repository root. + content: Decoded file content. + encoding: Original encoding (usually "base64"). + size: File size in bytes. + sha: Git SHA hash. + """ + + path: str + content: str + encoding: str + size: int + sha: str + + +@dataclass(frozen=True) +class CodeSearchResult: + """Result from code search. + + Attributes: + path: File path where match was found. + repository: Repository full name (owner/repo). + html_url: URL to view file on GitHub. + text_matches: List of matching text fragments. + """ + + path: str + repository: str + html_url: str + text_matches: tuple[str, ...] + + +@dataclass(frozen=True) +class CommitAuthor: + """Commit author information. + + Attributes: + name: Author's name. + email: Author's email. + date: Commit date. + """ + + name: str + email: str + date: datetime + + +@dataclass(frozen=True) +class Commit: + """Git commit information. + + Attributes: + sha: Full commit SHA. + message: Commit message. + author: Author information. + committer: Committer information. + html_url: URL to view commit on GitHub. + """ + + sha: str + message: str + author: CommitAuthor + committer: CommitAuthor + html_url: str + + +@dataclass(frozen=True) +class PullRequestUser: + """GitHub user in PR context. + + Attributes: + login: GitHub username. + html_url: Profile URL. + """ + + login: str + html_url: str + + +@dataclass(frozen=True) +class PullRequest: + """Pull request information. + + Attributes: + number: PR number. + title: PR title. + body: PR description/body. + state: PR state (open, closed, merged). + user: PR author. + html_url: URL to view PR on GitHub. + created_at: Creation timestamp. + updated_at: Last update timestamp. + merged_at: Merge timestamp (None if not merged). + base_branch: Target branch name. + head_branch: Source branch name. + """ + + number: int + title: str + body: str | None + state: str + user: PullRequestUser + html_url: str + created_at: datetime + updated_at: datetime + merged_at: datetime | None + base_branch: str + head_branch: str diff --git a/src/bond/tools/github/tools.py b/src/bond/tools/github/tools.py new file mode 100644 index 0000000..53373c7 --- /dev/null +++ b/src/bond/tools/github/tools.py @@ -0,0 +1,256 @@ +"""GitHub tools for PydanticAI agents. + +This module provides the agent-facing tool functions that use +RunContext to access the GitHub adapter via dependency injection. +""" + +from pydantic_ai import RunContext +from pydantic_ai.tools import Tool + +from bond.tools.github._exceptions import GitHubError +from bond.tools.github._models import ( + Error, + GetCommitsRequest, + GetPRRequest, + GetRepoRequest, + ListFilesRequest, + ReadFileRequest, + SearchCodeRequest, +) +from bond.tools.github._protocols import GitHubProtocol +from bond.tools.github._types import ( + CodeSearchResult, + Commit, + FileContent, + PullRequest, + RepoInfo, + TreeEntry, +) + + +async def github_get_repo( + ctx: RunContext[GitHubProtocol], + request: GetRepoRequest, +) -> RepoInfo | Error: + """Get repository metadata. + + Agent Usage: + Call this tool to get basic information about a GitHub repository: + - "What is this repository about?" → get description, language, topics + - "How popular is this project?" → check stars and forks + - "What's the default branch?" → get default_branch for other operations + + Example: + ```python + github_get_repo({ + "owner": "facebook", + "repo": "react" + }) + ``` + + Returns: + RepoInfo with repository metadata (description, default branch, + topics, language, stars, forks), or Error if the operation failed. + """ + try: + return await ctx.deps.get_repo( + owner=request.owner, + repo=request.repo, + ) + except GitHubError as e: + return Error(description=str(e)) + + +async def github_list_files( + ctx: RunContext[GitHubProtocol], + request: ListFilesRequest, +) -> list[TreeEntry] | Error: + """List directory contents at path. + + Agent Usage: + Call this tool to browse the file structure of a repository: + - "What files are in this repo?" → list_files with empty path + - "What's in the src folder?" → list_files with path="src" + - "Show me the test directory" → list_files with path="tests" + + Example: + ```python + github_list_files({ + "owner": "facebook", + "repo": "react", + "path": "packages/react/src", + "ref": "main" + }) + ``` + + Returns: + List of TreeEntry with name, path, type (file/dir), and size, + or Error if the operation failed. + """ + try: + return await ctx.deps.list_tree( + owner=request.owner, + repo=request.repo, + path=request.path, + ref=request.ref, + ) + except GitHubError as e: + return Error(description=str(e)) + + +async def github_read_file( + ctx: RunContext[GitHubProtocol], + request: ReadFileRequest, +) -> FileContent | Error: + """Read file content. + + Agent Usage: + Call this tool to read the contents of a specific file: + - "Show me the README" → read_file with path="README.md" + - "What does this file contain?" → read_file with the path + - "Read the configuration" → read_file with config file path + + Example: + ```python + github_read_file({ + "owner": "facebook", + "repo": "react", + "path": "packages/react/package.json", + "ref": "main" + }) + ``` + + Returns: + FileContent with decoded content, size, and SHA, + or Error if the operation failed (file not found, binary file, etc). + """ + try: + return await ctx.deps.get_file( + owner=request.owner, + repo=request.repo, + path=request.path, + ref=request.ref, + ) + except GitHubError as e: + return Error(description=str(e)) + + +async def github_search_code( + ctx: RunContext[GitHubProtocol], + request: SearchCodeRequest, +) -> list[CodeSearchResult] | Error: + """Search code within repository. + + Agent Usage: + Call this tool to find code containing specific terms: + - "Find where X is defined" → search for "class X" or "function X" + - "Where is Y used?" → search for "Y(" + - "Find all TODO comments" → search for "TODO" + + Example: + ```python + github_search_code({ + "query": "useState", + "owner": "facebook", + "repo": "react", + "limit": 10 + }) + ``` + + Returns: + List of CodeSearchResult with file paths and matching text fragments, + or Error if the operation failed (rate limited, invalid query, etc). + """ + try: + return await ctx.deps.search_code( + query=request.query, + owner=request.owner, + repo=request.repo, + limit=request.limit, + ) + except GitHubError as e: + return Error(description=str(e)) + + +async def github_get_commits( + ctx: RunContext[GitHubProtocol], + request: GetCommitsRequest, +) -> list[Commit] | Error: + """Get recent commits for file or repository. + + Agent Usage: + Call this tool to see the history of changes: + - "What changed recently?" → get_commits for the repo + - "Who modified this file?" → get_commits with the file path + - "Show me recent changes" → get_commits with limit + + Example: + ```python + github_get_commits({ + "owner": "facebook", + "repo": "react", + "path": "packages/react/src/React.js", + "limit": 5 + }) + ``` + + Returns: + List of Commit with SHA, message, author info, and date, + or Error if the operation failed. + """ + try: + return await ctx.deps.get_commits( + owner=request.owner, + repo=request.repo, + path=request.path, + ref=request.ref, + limit=request.limit, + ) + except GitHubError as e: + return Error(description=str(e)) + + +async def github_get_pr( + ctx: RunContext[GitHubProtocol], + request: GetPRRequest, +) -> PullRequest | Error: + """Get pull request details by number. + + Agent Usage: + Call this tool to get information about a specific PR: + - "What does PR #123 do?" → get PR title and description + - "Who created this PR?" → get PR author + - "Is this PR merged?" → check state and merged_at + + Example: + ```python + github_get_pr({ + "owner": "facebook", + "repo": "react", + "number": 25000 + }) + ``` + + Returns: + PullRequest with title, body, author, state, and merge info, + or Error if the operation failed. + """ + try: + return await ctx.deps.get_pr( + owner=request.owner, + repo=request.repo, + number=request.number, + ) + except GitHubError as e: + return Error(description=str(e)) + + +# Export as toolset for BondAgent +github_toolset: list[Tool[GitHubProtocol]] = [ + Tool(github_get_repo), + Tool(github_list_files), + Tool(github_read_file), + Tool(github_search_code), + Tool(github_get_commits), + Tool(github_get_pr), +] diff --git a/tests/unit/server/__init__.py b/tests/unit/server/__init__.py new file mode 100644 index 0000000..753c3cb --- /dev/null +++ b/tests/unit/server/__init__.py @@ -0,0 +1 @@ +"""Tests for Bond server module.""" diff --git a/tests/unit/server/test_session.py b/tests/unit/server/test_session.py new file mode 100644 index 0000000..11c6bc3 --- /dev/null +++ b/tests/unit/server/test_session.py @@ -0,0 +1,202 @@ +"""Tests for server session management.""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from bond.server._session import Session, SessionManager, SessionStatus + + +class TestSession: + """Tests for Session dataclass.""" + + def test_session_creation(self) -> None: + """Test session is created with correct defaults.""" + session = Session(session_id="test-123", prompt="Hello") + + assert session.session_id == "test-123" + assert session.prompt == "Hello" + assert session.status == SessionStatus.PENDING + assert session.history == [] + assert session.error is None + + def test_session_is_expired(self) -> None: + """Test session expiry detection.""" + session = Session(session_id="test", prompt="test") + session.created_at = time.time() - 100 # 100 seconds ago + + assert session.is_expired(50) is True # 50 second timeout + assert session.is_expired(200) is False # 200 second timeout + + +class TestSessionManager: + """Tests for SessionManager.""" + + @pytest.mark.asyncio + async def test_create_session(self) -> None: + """Test creating a new session.""" + manager = SessionManager(timeout_seconds=3600) + + session = await manager.create_session("What is 2+2?") + + assert session.prompt == "What is 2+2?" + assert session.status == SessionStatus.PENDING + assert session.session_id is not None + assert manager.active_count == 1 + + @pytest.mark.asyncio + async def test_create_session_with_custom_id(self) -> None: + """Test creating session with custom ID.""" + manager = SessionManager() + + session = await manager.create_session("test", session_id="custom-id-123") + + assert session.session_id == "custom-id-123" + + @pytest.mark.asyncio + async def test_get_session(self) -> None: + """Test retrieving a session.""" + manager = SessionManager() + created = await manager.create_session("Hello") + + retrieved = await manager.get_session(created.session_id) + + assert retrieved is not None + assert retrieved.session_id == created.session_id + assert retrieved.prompt == "Hello" + + @pytest.mark.asyncio + async def test_get_session_not_found(self) -> None: + """Test retrieving non-existent session returns None.""" + manager = SessionManager() + + result = await manager.get_session("nonexistent-id") + + assert result is None + + @pytest.mark.asyncio + async def test_get_expired_session_returns_none(self) -> None: + """Test expired session is removed and returns None.""" + manager = SessionManager(timeout_seconds=1) + session = await manager.create_session("test") + + # Manually expire the session + session.created_at = time.time() - 10 + + result = await manager.get_session(session.session_id) + + assert result is None + assert manager.active_count == 0 + + @pytest.mark.asyncio + async def test_update_status(self) -> None: + """Test updating session status.""" + manager = SessionManager() + session = await manager.create_session("test") + + await manager.update_status(session.session_id, SessionStatus.STREAMING) + + updated = await manager.get_session(session.session_id) + assert updated is not None + assert updated.status == SessionStatus.STREAMING + + @pytest.mark.asyncio + async def test_update_status_with_error(self) -> None: + """Test updating session status with error message.""" + manager = SessionManager() + session = await manager.create_session("test") + + await manager.update_status( + session.session_id, SessionStatus.ERROR, "Something went wrong" + ) + + updated = await manager.get_session(session.session_id) + assert updated is not None + assert updated.status == SessionStatus.ERROR + assert updated.error == "Something went wrong" + + @pytest.mark.asyncio + async def test_update_history(self) -> None: + """Test updating session history.""" + manager = SessionManager() + session = await manager.create_session("test") + + # Mock message history (simplified) + new_history = [{"role": "user", "content": "test"}] # type: ignore[list-item] + await manager.update_history(session.session_id, new_history) # type: ignore[arg-type] + + updated = await manager.get_session(session.session_id) + assert updated is not None + assert len(updated.history) == 1 + + @pytest.mark.asyncio + async def test_remove_session(self) -> None: + """Test removing a session.""" + manager = SessionManager() + session = await manager.create_session("test") + assert manager.active_count == 1 + + await manager.remove_session(session.session_id) + + assert manager.active_count == 0 + assert await manager.get_session(session.session_id) is None + + @pytest.mark.asyncio + async def test_cleanup_expired(self) -> None: + """Test cleaning up expired sessions.""" + manager = SessionManager(timeout_seconds=1) + session1 = await manager.create_session("test1") + session2 = await manager.create_session("test2") + + # Expire only session1 + session1.created_at = time.time() - 10 + + removed = await manager.cleanup_expired() + + assert removed == 1 + assert manager.active_count == 1 + assert await manager.get_session(session2.session_id) is not None + + @pytest.mark.asyncio + async def test_max_sessions_limit(self) -> None: + """Test max concurrent sessions limit.""" + manager = SessionManager(max_sessions=2) + await manager.create_session("test1") + await manager.create_session("test2") + + with pytest.raises(ValueError, match="Maximum concurrent sessions"): + await manager.create_session("test3") + + @pytest.mark.asyncio + async def test_reuse_session_id(self) -> None: + """Test continuing an existing session with same ID.""" + manager = SessionManager() + session1 = await manager.create_session("first prompt", session_id="shared-id") + original_time = session1.created_at + + # Small delay to ensure different timestamp + await asyncio.sleep(0.01) + + # Continue with same session_id + session2 = await manager.create_session("second prompt", session_id="shared-id") + + assert session2.session_id == "shared-id" + assert session2.prompt == "second prompt" + assert session2.status == SessionStatus.PENDING + assert session2.created_at > original_time + assert manager.active_count == 1 + + +class TestSessionStatus: + """Tests for SessionStatus enum.""" + + def test_status_values(self) -> None: + """Test all expected status values exist.""" + assert SessionStatus.PENDING.value == "pending" + assert SessionStatus.STREAMING.value == "streaming" + assert SessionStatus.COMPLETED.value == "completed" + assert SessionStatus.ERROR.value == "error" + assert SessionStatus.EXPIRED.value == "expired" diff --git a/tests/unit/tools/github/__init__.py b/tests/unit/tools/github/__init__.py new file mode 100644 index 0000000..5ca0f2b --- /dev/null +++ b/tests/unit/tools/github/__init__.py @@ -0,0 +1 @@ +"""Tests for GitHub toolset.""" diff --git a/tests/unit/tools/github/test_tools.py b/tests/unit/tools/github/test_tools.py new file mode 100644 index 0000000..34a09eb --- /dev/null +++ b/tests/unit/tools/github/test_tools.py @@ -0,0 +1,382 @@ +"""Tests for GitHub tool functions.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import MagicMock + +import pytest + +from bond.tools.github._exceptions import ( + FileNotFoundError, + PRNotFoundError, + RateLimitedError, + RepoNotFoundError, +) +from bond.tools.github._models import ( + Error, + GetCommitsRequest, + GetPRRequest, + GetRepoRequest, + ListFilesRequest, + ReadFileRequest, + SearchCodeRequest, +) +from bond.tools.github._types import ( + CodeSearchResult, + Commit, + CommitAuthor, + FileContent, + PullRequest, + PullRequestUser, + RepoInfo, + TreeEntry, +) +from bond.tools.github.tools import ( + github_get_commits, + github_get_pr, + github_get_repo, + github_list_files, + github_read_file, + github_search_code, +) + + +class MockGitHub: + """Mock implementation of GitHubProtocol for testing.""" + + def __init__( + self, + repo_info: RepoInfo | Exception | None = None, + tree_entries: list[TreeEntry] | Exception | None = None, + file_content: FileContent | Exception | None = None, + search_results: list[CodeSearchResult] | Exception | None = None, + commits: list[Commit] | Exception | None = None, + pull_request: PullRequest | Exception | None = None, + ) -> None: + """Initialize mock with configurable return values.""" + self._repo_info = repo_info + self._tree_entries = tree_entries if tree_entries is not None else [] + self._file_content = file_content + self._search_results = search_results if search_results is not None else [] + self._commits = commits if commits is not None else [] + self._pull_request = pull_request + + async def get_repo(self, owner: str, repo: str) -> RepoInfo: + if isinstance(self._repo_info, Exception): + raise self._repo_info + if self._repo_info is None: + raise ValueError("No repo info configured") + return self._repo_info + + async def list_tree( + self, owner: str, repo: str, path: str = "", ref: str | None = None + ) -> list[TreeEntry]: + if isinstance(self._tree_entries, Exception): + raise self._tree_entries + return self._tree_entries + + async def get_file( + self, owner: str, repo: str, path: str, ref: str | None = None + ) -> FileContent: + if isinstance(self._file_content, Exception): + raise self._file_content + if self._file_content is None: + raise ValueError("No file content configured") + return self._file_content + + async def search_code( + self, query: str, owner: str | None = None, repo: str | None = None, limit: int = 10 + ) -> list[CodeSearchResult]: + if isinstance(self._search_results, Exception): + raise self._search_results + return self._search_results + + async def get_commits( + self, + owner: str, + repo: str, + path: str | None = None, + ref: str | None = None, + limit: int = 10, + ) -> list[Commit]: + if isinstance(self._commits, Exception): + raise self._commits + return self._commits + + async def get_pr(self, owner: str, repo: str, number: int) -> PullRequest: + if isinstance(self._pull_request, Exception): + raise self._pull_request + if self._pull_request is None: + raise ValueError("No PR configured") + return self._pull_request + + +@pytest.fixture +def sample_repo_info() -> RepoInfo: + """Create a sample repository info for tests.""" + return RepoInfo( + owner="facebook", + name="react", + full_name="facebook/react", + description="A declarative UI library", + default_branch="main", + topics=("javascript", "ui", "frontend"), + language="JavaScript", + stars=200000, + forks=40000, + is_private=False, + created_at=datetime(2013, 5, 24, tzinfo=UTC), + updated_at=datetime(2024, 6, 15, tzinfo=UTC), + ) + + +@pytest.fixture +def sample_tree_entries() -> list[TreeEntry]: + """Create sample tree entries for tests.""" + return [ + TreeEntry(path="README.md", name="README.md", type="file", size=1234, sha="abc123"), + TreeEntry(path="src", name="src", type="dir", size=None, sha="def456"), + TreeEntry(path="package.json", name="package.json", type="file", size=500, sha="ghi789"), + ] + + +@pytest.fixture +def sample_file_content() -> FileContent: + """Create sample file content for tests.""" + return FileContent( + path="README.md", + content="# React\n\nA JavaScript library for building user interfaces.", + encoding="base64", + size=60, + sha="abc123", + ) + + +@pytest.fixture +def sample_commits() -> list[Commit]: + """Create sample commits for tests.""" + author = CommitAuthor( + name="Developer", + email="dev@example.com", + date=datetime(2024, 6, 15, 10, 30, tzinfo=UTC), + ) + return [ + Commit( + sha="abc123def456", + message="Fix bug in component", + author=author, + committer=author, + html_url="https://github.com/facebook/react/commit/abc123def456", + ), + ] + + +@pytest.fixture +def sample_pr() -> PullRequest: + """Create sample pull request for tests.""" + return PullRequest( + number=12345, + title="Fix rendering issue", + body="This PR fixes the rendering issue.", + state="merged", + user=PullRequestUser(login="developer", html_url="https://github.com/developer"), + html_url="https://github.com/facebook/react/pull/12345", + created_at=datetime(2024, 6, 10, tzinfo=UTC), + updated_at=datetime(2024, 6, 15, tzinfo=UTC), + merged_at=datetime(2024, 6, 15, tzinfo=UTC), + base_branch="main", + head_branch="fix-rendering", + ) + + +def create_mock_ctx(github: MockGitHub) -> MagicMock: + """Create a mock RunContext with GitHub deps.""" + ctx = MagicMock() + ctx.deps = github + return ctx + + +class TestGitHubGetRepo: + """Tests for github_get_repo tool function.""" + + @pytest.mark.asyncio + async def test_returns_repo_info(self, sample_repo_info: RepoInfo) -> None: + """Test github_get_repo returns RepoInfo on success.""" + github = MockGitHub(repo_info=sample_repo_info) + ctx = create_mock_ctx(github) + request = GetRepoRequest(owner="facebook", repo="react") + + result = await github_get_repo(ctx, request) + + assert isinstance(result, RepoInfo) + assert result.full_name == "facebook/react" + assert result.stars == 200000 + + @pytest.mark.asyncio + async def test_handles_repo_not_found(self) -> None: + """Test github_get_repo returns Error when repo not found.""" + github = MockGitHub(repo_info=RepoNotFoundError("facebook", "nonexistent")) + ctx = create_mock_ctx(github) + request = GetRepoRequest(owner="facebook", repo="nonexistent") + + result = await github_get_repo(ctx, request) + + assert isinstance(result, Error) + assert "not found" in result.description.lower() + + +class TestGitHubListFiles: + """Tests for github_list_files tool function.""" + + @pytest.mark.asyncio + async def test_returns_tree_entries(self, sample_tree_entries: list[TreeEntry]) -> None: + """Test github_list_files returns list of TreeEntry.""" + github = MockGitHub(tree_entries=sample_tree_entries) + ctx = create_mock_ctx(github) + request = ListFilesRequest(owner="facebook", repo="react", path="") + + result = await github_list_files(ctx, request) + + assert isinstance(result, list) + assert len(result) == 3 + assert result[0].name == "README.md" + assert result[1].type == "dir" + + @pytest.mark.asyncio + async def test_handles_path_not_found(self) -> None: + """Test github_list_files returns Error when path not found.""" + github = MockGitHub(tree_entries=FileNotFoundError("facebook", "react", "nonexistent")) + ctx = create_mock_ctx(github) + request = ListFilesRequest(owner="facebook", repo="react", path="nonexistent") + + result = await github_list_files(ctx, request) + + assert isinstance(result, Error) + assert "not found" in result.description.lower() + + +class TestGitHubReadFile: + """Tests for github_read_file tool function.""" + + @pytest.mark.asyncio + async def test_returns_file_content(self, sample_file_content: FileContent) -> None: + """Test github_read_file returns FileContent on success.""" + github = MockGitHub(file_content=sample_file_content) + ctx = create_mock_ctx(github) + request = ReadFileRequest(owner="facebook", repo="react", path="README.md") + + result = await github_read_file(ctx, request) + + assert isinstance(result, FileContent) + assert "# React" in result.content + assert result.path == "README.md" + + @pytest.mark.asyncio + async def test_handles_file_not_found(self) -> None: + """Test github_read_file returns Error when file not found.""" + github = MockGitHub(file_content=FileNotFoundError("facebook", "react", "missing.txt")) + ctx = create_mock_ctx(github) + request = ReadFileRequest(owner="facebook", repo="react", path="missing.txt") + + result = await github_read_file(ctx, request) + + assert isinstance(result, Error) + assert "not found" in result.description.lower() + + +class TestGitHubSearchCode: + """Tests for github_search_code tool function.""" + + @pytest.mark.asyncio + async def test_returns_search_results(self) -> None: + """Test github_search_code returns list of CodeSearchResult.""" + results = [ + CodeSearchResult( + path="src/React.js", + repository="facebook/react", + html_url="https://github.com/facebook/react/blob/main/src/React.js", + text_matches=("export default React;",), + ), + ] + github = MockGitHub(search_results=results) + ctx = create_mock_ctx(github) + request = SearchCodeRequest(query="export default React", owner="facebook", repo="react") + + result = await github_search_code(ctx, request) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].path == "src/React.js" + + @pytest.mark.asyncio + async def test_handles_rate_limit(self) -> None: + """Test github_search_code returns Error when rate limited.""" + github = MockGitHub(search_results=RateLimitedError()) + ctx = create_mock_ctx(github) + request = SearchCodeRequest(query="test") + + result = await github_search_code(ctx, request) + + assert isinstance(result, Error) + assert "rate limit" in result.description.lower() + + +class TestGitHubGetCommits: + """Tests for github_get_commits tool function.""" + + @pytest.mark.asyncio + async def test_returns_commits(self, sample_commits: list[Commit]) -> None: + """Test github_get_commits returns list of Commit.""" + github = MockGitHub(commits=sample_commits) + ctx = create_mock_ctx(github) + request = GetCommitsRequest(owner="facebook", repo="react", limit=10) + + result = await github_get_commits(ctx, request) + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].sha == "abc123def456" + assert result[0].author.email == "dev@example.com" + + @pytest.mark.asyncio + async def test_handles_repo_not_found(self) -> None: + """Test github_get_commits returns Error when repo not found.""" + github = MockGitHub(commits=RepoNotFoundError("owner", "missing")) + ctx = create_mock_ctx(github) + request = GetCommitsRequest(owner="owner", repo="missing") + + result = await github_get_commits(ctx, request) + + assert isinstance(result, Error) + assert "not found" in result.description.lower() + + +class TestGitHubGetPR: + """Tests for github_get_pr tool function.""" + + @pytest.mark.asyncio + async def test_returns_pull_request(self, sample_pr: PullRequest) -> None: + """Test github_get_pr returns PullRequest on success.""" + github = MockGitHub(pull_request=sample_pr) + ctx = create_mock_ctx(github) + request = GetPRRequest(owner="facebook", repo="react", number=12345) + + result = await github_get_pr(ctx, request) + + assert isinstance(result, PullRequest) + assert result.number == 12345 + assert result.state == "merged" + assert result.merged_at is not None + + @pytest.mark.asyncio + async def test_handles_pr_not_found(self) -> None: + """Test github_get_pr returns Error when PR not found.""" + github = MockGitHub(pull_request=PRNotFoundError("facebook", "react", 99999)) + ctx = create_mock_ctx(github) + request = GetPRRequest(owner="facebook", repo="react", number=99999) + + result = await github_get_pr(ctx, request) + + assert isinstance(result, Error) + assert "not found" in result.description.lower() diff --git a/tests/unit/tools/github/test_types.py b/tests/unit/tools/github/test_types.py new file mode 100644 index 0000000..3d21fa4 --- /dev/null +++ b/tests/unit/tools/github/test_types.py @@ -0,0 +1,195 @@ +"""Tests for GitHub types.""" + +from datetime import UTC, datetime + +import pytest + +from bond.tools.github._types import ( + CodeSearchResult, + Commit, + CommitAuthor, + FileContent, + PullRequest, + PullRequestUser, + RepoInfo, + TreeEntry, +) + + +class TestRepoInfo: + """Tests for RepoInfo dataclass.""" + + def test_creation(self) -> None: + """Test RepoInfo creation with all fields.""" + info = RepoInfo( + owner="facebook", + name="react", + full_name="facebook/react", + description="A UI library", + default_branch="main", + topics=("javascript", "ui"), + language="JavaScript", + stars=200000, + forks=40000, + is_private=False, + created_at=datetime(2013, 5, 24, tzinfo=UTC), + updated_at=datetime(2024, 6, 15, tzinfo=UTC), + ) + + assert info.owner == "facebook" + assert info.name == "react" + assert info.full_name == "facebook/react" + assert info.topics == ("javascript", "ui") + + def test_frozen(self) -> None: + """Test RepoInfo is immutable.""" + info = RepoInfo( + owner="a", + name="b", + full_name="a/b", + description=None, + default_branch="main", + topics=(), + language=None, + stars=0, + forks=0, + is_private=False, + created_at=datetime.now(tz=UTC), + updated_at=datetime.now(tz=UTC), + ) + + with pytest.raises(AttributeError): + info.stars = 100 # type: ignore[misc] + + +class TestTreeEntry: + """Tests for TreeEntry dataclass.""" + + def test_file_entry(self) -> None: + """Test file tree entry.""" + entry = TreeEntry( + path="src/index.js", + name="index.js", + type="file", + size=1234, + sha="abc123", + ) + + assert entry.type == "file" + assert entry.size == 1234 + + def test_dir_entry(self) -> None: + """Test directory tree entry.""" + entry = TreeEntry( + path="src", + name="src", + type="dir", + size=None, + sha="def456", + ) + + assert entry.type == "dir" + assert entry.size is None + + +class TestFileContent: + """Tests for FileContent dataclass.""" + + def test_creation(self) -> None: + """Test FileContent creation.""" + content = FileContent( + path="README.md", + content="# Hello World", + encoding="base64", + size=13, + sha="abc123", + ) + + assert content.path == "README.md" + assert content.content == "# Hello World" + + +class TestCodeSearchResult: + """Tests for CodeSearchResult dataclass.""" + + def test_creation(self) -> None: + """Test CodeSearchResult creation.""" + result = CodeSearchResult( + path="src/main.js", + repository="owner/repo", + html_url="https://github.com/owner/repo/blob/main/src/main.js", + text_matches=("const x = 1;", "function test() {}"), + ) + + assert result.repository == "owner/repo" + assert len(result.text_matches) == 2 + + +class TestCommit: + """Tests for Commit dataclass.""" + + def test_creation(self) -> None: + """Test Commit creation.""" + author = CommitAuthor( + name="Developer", + email="dev@example.com", + date=datetime(2024, 6, 15, tzinfo=UTC), + ) + commit = Commit( + sha="abc123def456", + message="Fix bug", + author=author, + committer=author, + html_url="https://github.com/owner/repo/commit/abc123", + ) + + assert commit.sha == "abc123def456" + assert commit.author.name == "Developer" + + +class TestPullRequest: + """Tests for PullRequest dataclass.""" + + def test_creation(self) -> None: + """Test PullRequest creation.""" + user = PullRequestUser( + login="developer", + html_url="https://github.com/developer", + ) + pr = PullRequest( + number=123, + title="Add feature", + body="This adds a new feature.", + state="open", + user=user, + html_url="https://github.com/owner/repo/pull/123", + created_at=datetime(2024, 6, 10, tzinfo=UTC), + updated_at=datetime(2024, 6, 15, tzinfo=UTC), + merged_at=None, + base_branch="main", + head_branch="feature", + ) + + assert pr.number == 123 + assert pr.state == "open" + assert pr.merged_at is None + + def test_merged_pr(self) -> None: + """Test merged PullRequest.""" + user = PullRequestUser(login="dev", html_url="https://github.com/dev") + pr = PullRequest( + number=456, + title="Fix", + body=None, + state="closed", + user=user, + html_url="https://github.com/o/r/pull/456", + created_at=datetime(2024, 6, 10, tzinfo=UTC), + updated_at=datetime(2024, 6, 15, tzinfo=UTC), + merged_at=datetime(2024, 6, 14, tzinfo=UTC), + base_branch="main", + head_branch="fix", + ) + + assert pr.state == "closed" + assert pr.merged_at is not None From 8325bc564e403c3353d92f908f51370df09929c8 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 21:52:01 +0000 Subject: [PATCH 02/23] docs(ui): add bond.server integration instructions - Option 1: Using bond.server with wrapper endpoint for UI - Option 2: Custom SSE endpoint (simpler) - SSE event format reference - Future roadmap for full session-based integration Co-Authored-By: Claude Opus 4.5 --- ui/README.md | 137 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 131 insertions(+), 6 deletions(-) diff --git a/ui/README.md b/ui/README.md index b984e10..a789cf0 100644 --- a/ui/README.md +++ b/ui/README.md @@ -20,24 +20,133 @@ Open http://localhost:5173 and click **Run Demo** to see a pre-recorded agent se ## Connect to a Live Agent -Click **Connect** and enter your SSE endpoint URL. Your backend needs to stream events using Bond's `create_sse_handlers()`: +### Option 1: Using bond.server (Recommended) + +The `bond.server` module provides a production-ready streaming server. Install with: + +```bash +pip install bond-agent[server] +``` + +Create a server (`server.py`): ```python +import os +from bond import BondAgent +from bond.server import create_bond_server +from bond.tools import github_toolset, GitHubAdapter + +# Create your agent +agent = BondAgent( + name="assistant", + instructions="You are a helpful assistant.", + model="openai:gpt-4o", + toolsets=[github_toolset], # Optional: add tools + deps=GitHubAdapter(token=os.environ.get("GITHUB_TOKEN")), +) + +# Create ASGI app +app = create_bond_server(agent) +``` + +Run it: + +```bash +uvicorn server:app --reload --port 8000 +``` + +**Connecting the UI:** + +The bond.server uses a 2-step flow: +1. `POST /ask` with `{"prompt": "..."}` → returns `{"session_id": "...", "stream_url": "/stream/..."}` +2. Connect SSE to the `stream_url` + +To use with the UI's "Connect" button, create a simple wrapper endpoint: + +```python +# Add to server.py - a convenience endpoint for the UI +from starlette.routing import Route from starlette.responses import StreamingResponse -from bond import Agent +import json + +async def ui_stream(request): + """Single-step SSE endpoint for the Bond UI.""" + prompt = request.query_params.get("prompt", "Hello!") + + async def generate(): + async def send_sse(event: str, data: dict): + yield f"event: {event}\ndata: {json.dumps(data)}\n\n" + + from bond.utils import create_sse_handlers + handlers = create_sse_handlers(send_sse) + await agent.ask(prompt, handlers=handlers) + + return StreamingResponse(generate(), media_type="text/event-stream") + +# Add route to your app +app.routes.append(Route("/ui-stream", ui_stream)) +``` + +Then click **Connect** in the UI and enter: +``` +http://localhost:8000/ui-stream?prompt=What%20is%202%2B2 +``` + +### Option 2: Custom SSE Endpoint + +For simpler setups, create a custom SSE endpoint using `create_sse_handlers()`: + +```python +import json +from starlette.applications import Starlette +from starlette.responses import StreamingResponse +from starlette.routing import Route +from bond import BondAgent from bond.utils import create_sse_handlers -async def sse_endpoint(request): - async def stream(): +agent = BondAgent( + name="assistant", + instructions="You are helpful.", + model="openai:gpt-4o", +) + +async def stream_endpoint(request): + prompt = request.query_params.get("prompt", "Hello!") + + async def generate(): async def send_sse(event: str, data: dict): yield f"event: {event}\ndata: {json.dumps(data)}\n\n" handlers = create_sse_handlers(send_sse) - await agent.ask("Your prompt here", handlers=handlers) + await agent.ask(prompt, handlers=handlers) + + return StreamingResponse(generate(), media_type="text/event-stream") - return StreamingResponse(stream(), media_type="text/event-stream") +app = Starlette(routes=[Route("/stream", stream_endpoint)]) ``` +Run with: +```bash +uvicorn server:app --reload --port 8000 +``` + +Click **Connect** and enter: `http://localhost:8000/stream?prompt=Hello` + +## SSE Event Format + +The UI expects these SSE event types: + +| Event | Data | Description | +|-------|------|-------------| +| `block_start` | `{kind, idx}` | New block started | +| `block_end` | `{kind, idx}` | Block finished | +| `text` | `{content}` | Text token | +| `thinking` | `{content}` | Reasoning token | +| `tool_delta` | `{n, a}` | Tool name/args streaming | +| `tool_exec` | `{id, name, args}` | Tool execution started | +| `tool_result` | `{id, name, result}` | Tool returned | +| `complete` | `{data}` | Stream finished | + ## Keyboard Shortcuts | Key | Action | @@ -46,3 +155,19 @@ async def sse_endpoint(request): | L | Jump to Live | | J/K | Step Back/Forward | | Escape | Close Inspector | + +## Future: Full bond.server Integration + +The UI's "Connect" button currently expects a single SSE URL. A future update could add support for bond.server's session-based flow: + +1. UI prompts for server URL and initial message +2. UI calls `POST /ask` to get session +3. UI connects to `/stream/{session_id}` +4. Multi-turn: UI calls `POST /ask` with same `session_id` for follow-ups + +This would enable: +- Persistent conversation history +- Session management +- Multiple concurrent users + +See [Streaming Server Guide](../docs/guides/streaming-server.md) for full API documentation. From 60716c96bda7a3b8cb459e6b5f8141e3cd251a51 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 21:59:56 +0000 Subject: [PATCH 03/23] feat(ui): add input component Co-Authored-By: Claude Opus 4.5 --- ui/src/components/ui/input.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 ui/src/components/ui/input.tsx diff --git a/ui/src/components/ui/input.tsx b/ui/src/components/ui/input.tsx new file mode 100644 index 0000000..9a52b61 --- /dev/null +++ b/ui/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } From 24fda0e76867396ba8aabce2e5ddd8c97ae3216f Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:09:41 +0000 Subject: [PATCH 04/23] feat(ui): add dialog component --- ui/src/components/ui/dialog.tsx | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 ui/src/components/ui/dialog.tsx diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx new file mode 100644 index 0000000..502471e --- /dev/null +++ b/ui/src/components/ui/dialog.tsx @@ -0,0 +1,86 @@ +import * as React from "react" +import { X } from "lucide-react" +import { cn } from "@/lib/utils" + +interface DialogProps { + open: boolean + onOpenChange: (open: boolean) => void + children: React.ReactNode +} + +export function Dialog({ open, onOpenChange, children }: DialogProps) { + if (!open) return null + + return ( +
+ {/* Backdrop */} +
onOpenChange(false)} + /> + {/* Content */} +
+ {children} +
+
+ ) +} + +interface DialogContentProps { + children: React.ReactNode + className?: string + onClose?: () => void +} + +export function DialogContent({ children, className, onClose }: DialogContentProps) { + return ( +
+ {onClose && ( + + )} + {children} +
+ ) +} + +export function DialogHeader({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +export function DialogTitle({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +

+ {children} +

+ ) +} + +export function DialogDescription({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +

+ {children} +

+ ) +} + +export function DialogFooter({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} From ac1326a13c52cd6e0d8bbe199d22cbd7be02a6b5 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:09:46 +0000 Subject: [PATCH 05/23] feat(ui): add useBondServer hook for bond.server integration --- ui/src/bond/useBondServer.ts | 234 +++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 ui/src/bond/useBondServer.ts diff --git a/ui/src/bond/useBondServer.ts b/ui/src/bond/useBondServer.ts new file mode 100644 index 0000000..a16331b --- /dev/null +++ b/ui/src/bond/useBondServer.ts @@ -0,0 +1,234 @@ +/** + * Bond Server Hook + * + * Handles the 2-step bond.server flow: + * 1. POST /ask with prompt -> get session_id and stream_url + * 2. Connect EventSource to stream_url + * 3. For follow-ups, POST /ask with same session_id + */ + +import { useCallback, useEffect, useReducer, useRef, useState } from "react" +import type { BondEvent, BondState } from "./types" +import { initialBondState } from "./types" +import { bondReducer } from "./reducer" +import { normalizeSSEEvent } from "./normalize" +import { useEventHistory } from "./useEventHistory" + +export type ServerStatus = "idle" | "connecting" | "live" | "streaming" | "error" + +export interface BondServerControls { + /** Current block state */ + state: BondState + /** Server connection status */ + status: ServerStatus + /** Current session ID */ + sessionId: string | null + /** Error message if status is error */ + error: string | null + /** Whether event processing is paused */ + paused: boolean + /** Set pause state */ + setPaused: (paused: boolean) => void + /** Send a message to the server */ + sendMessage: (prompt: string) => Promise + /** Disconnect from server */ + disconnect: () => void + /** Event history for replay */ + history: { + events: BondEvent[] + count: number + getUpTo: (index: number) => BondEvent[] + } + /** Reset state */ + reset: () => void +} + +interface AskResponse { + session_id: string + stream_url: string +} + +export function useBondServer(serverUrl: string | null): BondServerControls { + const [state, dispatch] = useReducer(bondReducer, initialBondState) + const [status, setStatus] = useState("idle") + const [sessionId, setSessionId] = useState(null) + const [error, setError] = useState(null) + const [paused, setPaused] = useState(false) + + const eventSourceRef = useRef(null) + const pausedRef = useRef(paused) + const pauseBufferRef = useRef([]) + + const history = useEventHistory() + + // Keep pausedRef in sync + pausedRef.current = paused + + const processEvent = useCallback( + (event: BondEvent) => { + history.push(event) + if (!pausedRef.current) { + dispatch(event) + } else { + pauseBufferRef.current.push(event) + } + }, + [history] + ) + + const connectToStream = useCallback( + (streamUrl: string) => { + // Close existing connection + if (eventSourceRef.current) { + eventSourceRef.current.close() + } + + const fullUrl = serverUrl ? `${serverUrl}${streamUrl}` : streamUrl + const es = new EventSource(fullUrl) + eventSourceRef.current = es + + es.onopen = () => { + setStatus("streaming") + } + + es.onerror = () => { + if (es.readyState === EventSource.CLOSED) { + setStatus("live") // Stream ended, but still connected to server + } + } + + // Handle named SSE events + const eventTypes = [ + "block_start", + "block_end", + "text", + "thinking", + "tool_delta", + "tool_exec", + "tool_result", + "complete", + ] + + eventTypes.forEach((eventType) => { + es.addEventListener(eventType, (e: MessageEvent) => { + try { + const data = JSON.parse(e.data) + const bondEvent = normalizeSSEEvent(eventType, data) + if (bondEvent) { + processEvent(bondEvent) + } + // On complete, close the stream + if (eventType === "complete") { + es.close() + setStatus("live") + } + } catch (err) { + console.warn(`Failed to parse SSE event: ${eventType}`, err) + } + }) + }) + + // Handle error events + es.addEventListener("error", (e: MessageEvent) => { + try { + const data = JSON.parse(e.data) + setError(data.error || "Unknown error") + setStatus("error") + es.close() + } catch { + // Ignore parse errors on error events + } + }) + }, + [serverUrl, processEvent] + ) + + const sendMessage = useCallback( + async (prompt: string) => { + if (!serverUrl) { + setError("No server URL configured") + setStatus("error") + return + } + + setStatus("connecting") + setError(null) + + try { + const response = await fetch(`${serverUrl}/ask`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, + session_id: sessionId, + }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || `Server error: ${response.status}`) + } + + const data: AskResponse = await response.json() + setSessionId(data.session_id) + connectToStream(data.stream_url) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to connect") + setStatus("error") + } + }, + [serverUrl, sessionId, connectToStream] + ) + + const disconnect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + setStatus("idle") + setSessionId(null) + setError(null) + }, []) + + const reset = useCallback(() => { + disconnect() + history.clear() + pauseBufferRef.current = [] + }, [disconnect, history]) + + // Handle unpause - flush buffered events + useEffect(() => { + if (!paused && pauseBufferRef.current.length > 0) { + pauseBufferRef.current.forEach((event) => { + dispatch(event) + }) + pauseBufferRef.current = [] + } + }, [paused]) + + // Cleanup on unmount + useEffect(() => { + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close() + } + } + }, []) + + return { + state, + status, + sessionId, + error, + paused, + setPaused, + sendMessage, + disconnect, + history: { + events: history.events, + count: history.count, + getUpTo: history.getUpTo, + }, + reset, + } +} From 0a3ea96ee094a913331f7f18fcfb26aa411d1006 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:09:50 +0000 Subject: [PATCH 06/23] feat(ui): add ConnectDialog component --- ui/src/ui/ConnectDialog.tsx | 103 ++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 ui/src/ui/ConnectDialog.tsx diff --git a/ui/src/ui/ConnectDialog.tsx b/ui/src/ui/ConnectDialog.tsx new file mode 100644 index 0000000..e935e11 --- /dev/null +++ b/ui/src/ui/ConnectDialog.tsx @@ -0,0 +1,103 @@ +/** + * Connect Dialog + * + * Modal for connecting to a bond.server instance. + * Collects server URL and initial prompt. + */ + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" + +interface ConnectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConnect: (serverUrl: string, prompt: string) => void + isConnecting?: boolean +} + +export function ConnectDialog({ + open, + onOpenChange, + onConnect, + isConnecting = false, +}: ConnectDialogProps) { + const [serverUrl, setServerUrl] = useState("http://localhost:8000") + const [prompt, setPrompt] = useState("") + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (serverUrl && prompt) { + onConnect(serverUrl, prompt) + } + } + + return ( + + +
+ + Connect to Bond Server + + Enter your server URL and initial message to start a session. + + + +
+
+ + setServerUrl(e.target.value)} + disabled={isConnecting} + /> +
+ +
+ + setPrompt(e.target.value)} + disabled={isConnecting} + autoFocus + /> +
+
+ + + + + +
+
+
+ ) +} From 52f4644c76469380bbe434d4e5daca50e50c7f7d Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:09:55 +0000 Subject: [PATCH 07/23] feat(ui): add ChatInput component --- ui/src/ui/ChatInput.tsx | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 ui/src/ui/ChatInput.tsx diff --git a/ui/src/ui/ChatInput.tsx b/ui/src/ui/ChatInput.tsx new file mode 100644 index 0000000..4062581 --- /dev/null +++ b/ui/src/ui/ChatInput.tsx @@ -0,0 +1,50 @@ +/** + * Chat Input + * + * Input field for sending follow-up messages in a conversation. + */ + +import { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Send } from "lucide-react" + +interface ChatInputProps { + onSend: (message: string) => void + disabled?: boolean + placeholder?: string +} + +export function ChatInput({ + onSend, + disabled = false, + placeholder = "Type a message...", +}: ChatInputProps) { + const [message, setMessage] = useState("") + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + if (message.trim() && !disabled) { + onSend(message.trim()) + setMessage("") + } + }, + [message, disabled, onSend] + ) + + return ( +
+ setMessage(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="flex-1" + /> + +
+ ) +} From 89d5c361f06d55aeb9ddd2053d7e49959299ab99 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:09:59 +0000 Subject: [PATCH 08/23] feat(ui): integrate bond.server with connect dialog and chat input --- ui/src/App.tsx | 187 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 149 insertions(+), 38 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index fcdf0ae..a5ea94a 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -7,6 +7,7 @@ * - Timeline view with blocks * - Inspector panel * - Replay/Demo controls + * - Chat input for multi-turn conversations */ import { useState, useCallback, useMemo } from "react" @@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge" import { Wifi, WifiOff, Loader2 } from "lucide-react" import { useBondStream } from "@/bond/useBondStream" +import { useBondServer } from "@/bond/useBondServer" import { useBondReplayFromFile } from "@/bond/useBondReplayFromFile" import { useReplayState } from "@/bond/useReplayState" import { useSelection } from "@/ui/useSelection" @@ -25,22 +27,39 @@ import { Timeline } from "@/ui/Timeline" import { Inspector } from "@/ui/Inspector" import { ReplayControls } from "@/ui/ReplayControls" import { DemoControls } from "@/ui/DemoControls" +import { ConnectDialog } from "@/ui/ConnectDialog" +import { ChatInput } from "@/ui/ChatInput" -type AppMode = "idle" | "live" | "demo" +type AppMode = "idle" | "live" | "server" | "demo" export default function App() { const [mode, setMode] = useState("idle") const [sseUrl, setSseUrl] = useState(null) + const [serverUrl, setServerUrl] = useState(null) + const [connectDialogOpen, setConnectDialogOpen] = useState(false) - // Stream hook for live mode + // Stream hook for legacy live mode (direct SSE) const stream = useBondStream(sseUrl) + // Server hook for bond.server integration + const server = useBondServer(serverUrl) + // Demo hook for demo mode const demo = useBondReplayFromFile() // Determine which events/state to use based on mode - const events = mode === "demo" ? demo.events : stream.history.events - const liveState = mode === "demo" ? demo.state : stream.state + const events = + mode === "demo" + ? demo.events + : mode === "server" + ? server.history.events + : stream.history.events + const liveState = + mode === "demo" + ? demo.state + : mode === "server" + ? server.state + : stream.state // Replay state for scrubbing through history const replay = useReplayState(events) @@ -58,40 +77,80 @@ export default function App() { ) // Connection status for display - const connectionStatus = mode === "live" ? stream.status : "idle" - const isConnected = connectionStatus === "live" + const connectionStatus = + mode === "server" + ? server.status + : mode === "live" + ? stream.status + : "idle" + const isConnected = connectionStatus === "live" || connectionStatus === "streaming" const isConnecting = connectionStatus === "connecting" + const isStreaming = connectionStatus === "streaming" // Event count const eventCount = events.length - // Trace ID (demo shows demo ID) - const traceId = mode === "demo" ? "demo-001" : mode === "live" ? "live" : "—" + // Trace ID + const traceId = + mode === "demo" + ? "demo-001" + : mode === "server" && server.sessionId + ? server.sessionId.slice(0, 8) + : mode === "live" + ? "live" + : "—" // Status for display const displayStatus = mode === "demo" ? demo.status - : mode === "live" - ? stream.status - : "idle" + : mode === "server" + ? server.status + : mode === "live" + ? stream.status + : "idle" - // Handle connect button + // Handle connect button - opens dialog for server mode const handleConnect = useCallback(() => { - const url = prompt("Enter SSE endpoint URL:", "http://localhost:8000/stream") - if (url) { - setSseUrl(url) - setMode("live") - stream.connect() - } - }, [stream]) + setConnectDialogOpen(true) + }, []) + + // Handle server connection from dialog + const handleServerConnect = useCallback( + async (url: string, prompt: string) => { + setServerUrl(url) + setMode("server") + setConnectDialogOpen(false) + // Small delay to ensure state is set + setTimeout(() => { + server.sendMessage(prompt) + }, 0) + }, + [server] + ) + // Handle disconnect const handleDisconnect = useCallback(() => { - stream.disconnect() - setSseUrl(null) + if (mode === "server") { + server.disconnect() + setServerUrl(null) + } else { + stream.disconnect() + setSseUrl(null) + } setMode("idle") - }, [stream]) + }, [mode, server, stream]) + + // Handle sending follow-up messages + const handleSendMessage = useCallback( + (message: string) => { + if (mode === "server") { + server.sendMessage(message) + } + }, + [mode, server] + ) // Handle demo start const handleStartDemo = useCallback(async () => { @@ -105,9 +164,11 @@ export default function App() { setMode("idle") }, [demo]) - // Toggle pause (works for both live and demo) + // Toggle pause const handleTogglePause = useCallback(() => { - if (mode === "live") { + if (mode === "server") { + server.setPaused(!server.paused) + } else if (mode === "live") { stream.setPaused(!stream.paused) } else if (mode === "demo") { if (demo.status === "playing") { @@ -116,7 +177,7 @@ export default function App() { demo.resume() } } - }, [mode, stream, demo]) + }, [mode, server, stream, demo]) // Jump to live const handleJumpToLive = useCallback(() => { @@ -146,6 +207,8 @@ export default function App() { enabled: mode !== "idle", }) + const isPaused = mode === "server" ? server.paused : stream.paused + return (
{/* Header */} @@ -160,16 +223,20 @@ export default function App() { variant="secondary" size="sm" onClick={mode === "demo" ? handleStopDemo : handleStartDemo} - disabled={mode === "live"} + disabled={mode === "live" || mode === "server"} > {mode === "demo" ? "Stop Demo" : "Run Demo"}
@@ -179,7 +246,7 @@ export default function App() {
- Trace: + Session: {traceId}
@@ -188,7 +255,9 @@ export default function App() { variant={ displayStatus === "idle" ? "secondary" - : displayStatus === "live" || displayStatus === "playing" + : displayStatus === "live" || + displayStatus === "playing" || + displayStatus === "streaming" ? "success" : displayStatus === "error" ? "destructive" @@ -209,15 +278,20 @@ export default function App() { Connecting... + ) : isStreaming ? ( + <> + + Streaming... + ) : isConnected ? ( <> Connected ) : mode === "demo" ? ( - <> - Demo - + + Demo + ) : ( <> @@ -228,6 +302,15 @@ export default function App() {
+ {/* Error Banner */} + {mode === "server" && server.error && ( +
+
+ Error: {server.error} +
+
+ )} + {/* Main Content */}
{/* Sidebar */} @@ -246,6 +329,15 @@ export default function App() { Playing demo session. Use the controls below to pause, scrub, or change playback speed. + ) : mode === "server" ? ( + <> + Connected to bond.server. Send messages using the input below. + {server.sessionId && ( + + Session: {server.sessionId} + + )} + ) : ( <> Connected to live stream. Events will appear in the timeline @@ -285,7 +377,7 @@ export default function App() { )}
-
+
+ {/* Chat input for server mode */} + {mode === "server" && ( + + )} + {/* Controls based on mode */} {mode === "demo" && ( )} - {mode === "live" && eventCount > 0 && ( + {(mode === "live" || mode === "server") && eventCount > 0 && ( stream.setPaused(!stream.paused)} + paused={isPaused} + onPauseToggle={handleTogglePause} position={replay.position} totalEvents={replay.totalEvents} onPositionChange={replay.setPosition} @@ -325,6 +428,14 @@ export default function App() { {/* Inspector Panel */} + + {/* Connect Dialog */} +
) -} +} \ No newline at end of file From b5ff54323f268979b0345df09fa9ae3dcaca5a4d Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:10:39 +0000 Subject: [PATCH 09/23] test: add simple test server for UI integration --- test_server.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 test_server.py diff --git a/test_server.py b/test_server.py new file mode 100644 index 0000000..feed247 --- /dev/null +++ b/test_server.py @@ -0,0 +1,15 @@ +import os +from bond import BondAgent +from bond.server import create_bond_server + +agent = BondAgent( + name="test-assistant", + instructions="You are a helpful test assistant. Keep responses brief.", + model="openai:gpt-4o-mini", +) + +app = create_bond_server(agent) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) From 84f22134625bb704cf32e389f6e0fdfc3e9f8b3c Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:10:45 +0000 Subject: [PATCH 10/23] docs: add bond-server ui integration plan --- .../2025-01-24-bond-server-ui-integration.md | 1179 +++++++++++++++++ 1 file changed, 1179 insertions(+) create mode 100644 docs/plans/2025-01-24-bond-server-ui-integration.md diff --git a/docs/plans/2025-01-24-bond-server-ui-integration.md b/docs/plans/2025-01-24-bond-server-ui-integration.md new file mode 100644 index 0000000..25777e6 --- /dev/null +++ b/docs/plans/2025-01-24-bond-server-ui-integration.md @@ -0,0 +1,1179 @@ +# Bond Server UI Integration Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add full bond.server integration to the UI with a connect dialog, message input, and multi-turn conversation support. + +**Architecture:** Replace the simple `prompt()` connect flow with a modal dialog. Create a new `useBondServer` hook that handles the 2-step flow (POST /ask → SSE stream). Add a chat input component for multi-turn conversations. Reuse existing `useBondStream` internals for SSE handling. + +**Tech Stack:** React, TypeScript, Tailwind CSS, shadcn/ui components + +--- + +## Task 1: Create Input Component + +**Files:** +- Create: `ui/src/components/ui/input.tsx` + +**Step 1: Create the input component** + +```tsx +import * as React from "react" +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } +``` + +**Step 2: Verify it builds** + +Run: `cd ui && pnpm build` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add ui/src/components/ui/input.tsx +git commit -m "feat(ui): add input component" +``` + +--- + +## Task 2: Create Dialog Component + +**Files:** +- Create: `ui/src/components/ui/dialog.tsx` + +**Step 1: Create the dialog component** + +```tsx +import * as React from "react" +import { X } from "lucide-react" +import { cn } from "@/lib/utils" + +interface DialogProps { + open: boolean + onOpenChange: (open: boolean) => void + children: React.ReactNode +} + +export function Dialog({ open, onOpenChange, children }: DialogProps) { + if (!open) return null + + return ( +
+ {/* Backdrop */} +
onOpenChange(false)} + /> + {/* Content */} +
+ {children} +
+
+ ) +} + +interface DialogContentProps { + children: React.ReactNode + className?: string + onClose?: () => void +} + +export function DialogContent({ children, className, onClose }: DialogContentProps) { + return ( +
+ {onClose && ( + + )} + {children} +
+ ) +} + +export function DialogHeader({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} + +export function DialogTitle({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +

+ {children} +

+ ) +} + +export function DialogDescription({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +

+ {children} +

+ ) +} + +export function DialogFooter({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} +``` + +**Step 2: Verify it builds** + +Run: `cd ui && pnpm build` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add ui/src/components/ui/dialog.tsx +git commit -m "feat(ui): add dialog component" +``` + +--- + +## Task 3: Create useBondServer Hook + +**Files:** +- Create: `ui/src/bond/useBondServer.ts` + +**Step 1: Create the hook** + +```tsx +/** + * Bond Server Hook + * + * Handles the 2-step bond.server flow: + * 1. POST /ask with prompt -> get session_id and stream_url + * 2. Connect EventSource to stream_url + * 3. For follow-ups, POST /ask with same session_id + */ + +import { useCallback, useEffect, useReducer, useRef, useState } from "react" +import type { BondEvent, BondState } from "./types" +import { initialBondState } from "./types" +import { bondReducer } from "./reducer" +import { normalizeSSEEvent } from "./normalize" +import { useEventHistory } from "./useEventHistory" + +export type ServerStatus = "idle" | "connecting" | "live" | "streaming" | "error" + +export interface BondServerControls { + /** Current block state */ + state: BondState + /** Server connection status */ + status: ServerStatus + /** Current session ID */ + sessionId: string | null + /** Error message if status is error */ + error: string | null + /** Whether event processing is paused */ + paused: boolean + /** Set pause state */ + setPaused: (paused: boolean) => void + /** Send a message to the server */ + sendMessage: (prompt: string) => Promise + /** Disconnect from server */ + disconnect: () => void + /** Event history for replay */ + history: { + events: BondEvent[] + count: number + getUpTo: (index: number) => BondEvent[] + } + /** Reset state */ + reset: () => void +} + +interface AskResponse { + session_id: string + stream_url: string +} + +export function useBondServer(serverUrl: string | null): BondServerControls { + const [state, dispatch] = useReducer(bondReducer, initialBondState) + const [status, setStatus] = useState("idle") + const [sessionId, setSessionId] = useState(null) + const [error, setError] = useState(null) + const [paused, setPaused] = useState(false) + + const eventSourceRef = useRef(null) + const pausedRef = useRef(paused) + const pauseBufferRef = useRef([]) + + const history = useEventHistory() + + // Keep pausedRef in sync + pausedRef.current = paused + + const processEvent = useCallback( + (event: BondEvent) => { + history.push(event) + if (!pausedRef.current) { + dispatch(event) + } else { + pauseBufferRef.current.push(event) + } + }, + [history] + ) + + const connectToStream = useCallback( + (streamUrl: string) => { + // Close existing connection + if (eventSourceRef.current) { + eventSourceRef.current.close() + } + + const fullUrl = serverUrl ? `${serverUrl}${streamUrl}` : streamUrl + const es = new EventSource(fullUrl) + eventSourceRef.current = es + + es.onopen = () => { + setStatus("streaming") + } + + es.onerror = () => { + if (es.readyState === EventSource.CLOSED) { + setStatus("live") // Stream ended, but still connected to server + } + } + + // Handle named SSE events + const eventTypes = [ + "block_start", + "block_end", + "text", + "thinking", + "tool_delta", + "tool_exec", + "tool_result", + "complete", + ] + + eventTypes.forEach((eventType) => { + es.addEventListener(eventType, (e: MessageEvent) => { + try { + const data = JSON.parse(e.data) + const bondEvent = normalizeSSEEvent(eventType, data) + if (bondEvent) { + processEvent(bondEvent) + } + // On complete, close the stream + if (eventType === "complete") { + es.close() + setStatus("live") + } + } catch (err) { + console.warn(`Failed to parse SSE event: ${eventType}`, err) + } + }) + }) + + // Handle error events + es.addEventListener("error", (e: MessageEvent) => { + try { + const data = JSON.parse(e.data) + setError(data.error || "Unknown error") + setStatus("error") + es.close() + } catch { + // Ignore parse errors on error events + } + }) + }, + [serverUrl, processEvent] + ) + + const sendMessage = useCallback( + async (prompt: string) => { + if (!serverUrl) { + setError("No server URL configured") + setStatus("error") + return + } + + setStatus("connecting") + setError(null) + + try { + const response = await fetch(`${serverUrl}/ask`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, + session_id: sessionId, + }), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || `Server error: ${response.status}`) + } + + const data: AskResponse = await response.json() + setSessionId(data.session_id) + connectToStream(data.stream_url) + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to connect") + setStatus("error") + } + }, + [serverUrl, sessionId, connectToStream] + ) + + const disconnect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + setStatus("idle") + setSessionId(null) + setError(null) + }, []) + + const reset = useCallback(() => { + disconnect() + history.clear() + pauseBufferRef.current = [] + }, [disconnect, history]) + + // Handle unpause - flush buffered events + useEffect(() => { + if (!paused && pauseBufferRef.current.length > 0) { + pauseBufferRef.current.forEach((event) => { + dispatch(event) + }) + pauseBufferRef.current = [] + } + }, [paused]) + + // Cleanup on unmount + useEffect(() => { + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close() + } + } + }, []) + + return { + state, + status, + sessionId, + error, + paused, + setPaused, + sendMessage, + disconnect, + history: { + events: history.events, + count: history.count, + getUpTo: history.getUpTo, + }, + reset, + } +} +``` + +**Step 2: Verify it builds** + +Run: `cd ui && pnpm build` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add ui/src/bond/useBondServer.ts +git commit -m "feat(ui): add useBondServer hook for bond.server integration" +``` + +--- + +## Task 4: Create ConnectDialog Component + +**Files:** +- Create: `ui/src/ui/ConnectDialog.tsx` + +**Step 1: Create the connect dialog** + +```tsx +/** + * Connect Dialog + * + * Modal for connecting to a bond.server instance. + * Collects server URL and initial prompt. + */ + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" + +interface ConnectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onConnect: (serverUrl: string, prompt: string) => void + isConnecting?: boolean +} + +export function ConnectDialog({ + open, + onOpenChange, + onConnect, + isConnecting = false, +}: ConnectDialogProps) { + const [serverUrl, setServerUrl] = useState("http://localhost:8000") + const [prompt, setPrompt] = useState("") + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (serverUrl && prompt) { + onConnect(serverUrl, prompt) + } + } + + return ( + + +
+ + Connect to Bond Server + + Enter your server URL and initial message to start a session. + + + +
+
+ + setServerUrl(e.target.value)} + disabled={isConnecting} + /> +
+ +
+ + setPrompt(e.target.value)} + disabled={isConnecting} + autoFocus + /> +
+
+ + + + + +
+
+
+ ) +} +``` + +**Step 2: Verify it builds** + +Run: `cd ui && pnpm build` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add ui/src/ui/ConnectDialog.tsx +git commit -m "feat(ui): add ConnectDialog component" +``` + +--- + +## Task 5: Create ChatInput Component + +**Files:** +- Create: `ui/src/ui/ChatInput.tsx` + +**Step 1: Create the chat input** + +```tsx +/** + * Chat Input + * + * Input field for sending follow-up messages in a conversation. + */ + +import { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Send } from "lucide-react" + +interface ChatInputProps { + onSend: (message: string) => void + disabled?: boolean + placeholder?: string +} + +export function ChatInput({ + onSend, + disabled = false, + placeholder = "Type a message...", +}: ChatInputProps) { + const [message, setMessage] = useState("") + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + if (message.trim() && !disabled) { + onSend(message.trim()) + setMessage("") + } + }, + [message, disabled, onSend] + ) + + return ( +
+ setMessage(e.target.value)} + placeholder={placeholder} + disabled={disabled} + className="flex-1" + /> + +
+ ) +} +``` + +**Step 2: Verify it builds** + +Run: `cd ui && pnpm build` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add ui/src/ui/ChatInput.tsx +git commit -m "feat(ui): add ChatInput component" +``` + +--- + +## Task 6: Update App.tsx with Server Integration + +**Files:** +- Modify: `ui/src/App.tsx` + +**Step 1: Update App.tsx with new imports and state** + +Replace the entire App.tsx with the updated version that integrates bond.server: + +```tsx +/** + * Bond Forensic Timeline + * + * Main application shell with: + * - Header with connection controls + * - Run status line + * - Timeline view with blocks + * - Inspector panel + * - Replay/Demo controls + * - Chat input for multi-turn conversations + */ + +import { useState, useCallback, useMemo } from "react" +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Wifi, WifiOff, Loader2 } from "lucide-react" + +import { useBondStream } from "@/bond/useBondStream" +import { useBondServer } from "@/bond/useBondServer" +import { useBondReplayFromFile } from "@/bond/useBondReplayFromFile" +import { useReplayState } from "@/bond/useReplayState" +import { useSelection } from "@/ui/useSelection" +import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts" + +import { Timeline } from "@/ui/Timeline" +import { Inspector } from "@/ui/Inspector" +import { ReplayControls } from "@/ui/ReplayControls" +import { DemoControls } from "@/ui/DemoControls" +import { ConnectDialog } from "@/ui/ConnectDialog" +import { ChatInput } from "@/ui/ChatInput" + +type AppMode = "idle" | "live" | "server" | "demo" + +export default function App() { + const [mode, setMode] = useState("idle") + const [sseUrl, setSseUrl] = useState(null) + const [serverUrl, setServerUrl] = useState(null) + const [connectDialogOpen, setConnectDialogOpen] = useState(false) + + // Stream hook for legacy live mode (direct SSE) + const stream = useBondStream(sseUrl) + + // Server hook for bond.server integration + const server = useBondServer(serverUrl) + + // Demo hook for demo mode + const demo = useBondReplayFromFile() + + // Determine which events/state to use based on mode + const events = + mode === "demo" + ? demo.events + : mode === "server" + ? server.history.events + : stream.history.events + const liveState = + mode === "demo" + ? demo.state + : mode === "server" + ? server.state + : stream.state + + // Replay state for scrubbing through history + const replay = useReplayState(events) + + // Selection state for inspector + const selection = useSelection() + + // Determine visible state (live or replayed) + const visibleState = replay.isAtLive ? liveState : replay.visibleState + + // Find selected block + const selectedBlock = useMemo( + () => visibleState.blocks.find((b) => b.id === selection.selectedBlockId), + [visibleState.blocks, selection.selectedBlockId] + ) + + // Connection status for display + const connectionStatus = + mode === "server" + ? server.status + : mode === "live" + ? stream.status + : "idle" + const isConnected = connectionStatus === "live" || connectionStatus === "streaming" + const isConnecting = connectionStatus === "connecting" + const isStreaming = connectionStatus === "streaming" + + // Event count + const eventCount = events.length + + // Trace ID + const traceId = + mode === "demo" + ? "demo-001" + : mode === "server" && server.sessionId + ? server.sessionId.slice(0, 8) + : mode === "live" + ? "live" + : "—" + + // Status for display + const displayStatus = + mode === "demo" + ? demo.status + : mode === "server" + ? server.status + : mode === "live" + ? stream.status + : "idle" + + // Handle connect button - opens dialog for server mode + const handleConnect = useCallback(() => { + setConnectDialogOpen(true) + }, []) + + // Handle server connection from dialog + const handleServerConnect = useCallback( + async (url: string, prompt: string) => { + setServerUrl(url) + setMode("server") + setConnectDialogOpen(false) + // Small delay to ensure state is set + setTimeout(() => { + server.sendMessage(prompt) + }, 0) + }, + [server] + ) + + // Handle legacy direct SSE connect + const handleLegacyConnect = useCallback(() => { + const url = prompt("Enter SSE endpoint URL:", "http://localhost:8000/stream") + if (url) { + setSseUrl(url) + setMode("live") + stream.connect() + } + }, [stream]) + + // Handle disconnect + const handleDisconnect = useCallback(() => { + if (mode === "server") { + server.disconnect() + setServerUrl(null) + } else { + stream.disconnect() + setSseUrl(null) + } + setMode("idle") + }, [mode, server, stream]) + + // Handle sending follow-up messages + const handleSendMessage = useCallback( + (message: string) => { + if (mode === "server") { + server.sendMessage(message) + } + }, + [mode, server] + ) + + // Handle demo start + const handleStartDemo = useCallback(async () => { + setMode("demo") + await demo.startDemo("/demo-events.ndjson") + }, [demo]) + + // Handle demo stop + const handleStopDemo = useCallback(() => { + demo.stop() + setMode("idle") + }, [demo]) + + // Toggle pause + const handleTogglePause = useCallback(() => { + if (mode === "server") { + server.setPaused(!server.paused) + } else if (mode === "live") { + stream.setPaused(!stream.paused) + } else if (mode === "demo") { + if (demo.status === "playing") { + demo.pause() + } else if (demo.status === "paused") { + demo.resume() + } + } + }, [mode, server, stream, demo]) + + // Jump to live + const handleJumpToLive = useCallback(() => { + replay.jumpToLive() + }, [replay]) + + // Step backward + const handleStepBackward = useCallback(() => { + if (replay.position > 0) { + replay.setPosition(replay.position - 1) + } + }, [replay]) + + // Step forward + const handleStepForward = useCallback(() => { + if (replay.position < replay.totalEvents - 1) { + replay.setPosition(replay.position + 1) + } + }, [replay]) + + // Keyboard shortcuts + useKeyboardShortcuts({ + onTogglePause: handleTogglePause, + onJumpToLive: handleJumpToLive, + onStepBackward: handleStepBackward, + onStepForward: handleStepForward, + enabled: mode !== "idle", + }) + + const isPaused = mode === "server" ? server.paused : stream.paused + + return ( +
+ {/* Header */} +
+
+
+
Bond
+
Forensic Timeline
+
+
+ + +
+
+
+ + {/* Run Header / Status Line */} +
+
+
+ Session: + {traceId} +
+
+ Status: + + {displayStatus} + +
+
+ Events: + {eventCount} +
+
+ {isConnecting ? ( + <> + + Connecting... + + ) : isStreaming ? ( + <> + + Streaming... + + ) : isConnected ? ( + <> + + Connected + + ) : mode === "demo" ? ( + + Demo + + ) : ( + <> + + Disconnected + + )} +
+
+
+ + {/* Error Banner */} + {mode === "server" && server.error && ( +
+
+ Error: {server.error} +
+
+ )} + + {/* Main Content */} +
+ {/* Sidebar */} + + + {/* Timeline */} +
+ +
+
Timeline
+ {!replay.isAtLive && ( + + Viewing event {replay.position + 1} of {replay.totalEvents} + + )} +
+ +
+ +
+ + {/* Chat input for server mode */} + {mode === "server" && ( + + )} + + {/* Controls based on mode */} + {mode === "demo" && ( + + )} + + {(mode === "live" || mode === "server") && eventCount > 0 && ( + + )} +
+
+
+ + {/* Inspector Panel */} + + + {/* Connect Dialog */} + +
+ ) +} +``` + +**Step 2: Verify it builds** + +Run: `cd ui && pnpm build` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add ui/src/App.tsx +git commit -m "feat(ui): integrate bond.server with connect dialog and chat input" +``` + +--- + +## Task 7: Test the Integration + +**Step 1: Start a test server** + +Create a simple test server (`test_server.py`) in the project root: + +```python +import os +from bond import BondAgent +from bond.server import create_bond_server + +agent = BondAgent( + name="test-assistant", + instructions="You are a helpful test assistant. Keep responses brief.", + model="openai:gpt-4o-mini", +) + +app = create_bond_server(agent) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) +``` + +**Step 2: Run the server** + +```bash +pip install bond-agent[server] +python test_server.py +``` + +**Step 3: Run the UI** + +```bash +cd ui && pnpm dev +``` + +**Step 4: Test the flow** + +1. Open http://localhost:5173 +2. Click "Connect" +3. Enter server URL: `http://localhost:8000` +4. Enter message: "Hello! What's 2+2?" +5. Click "Connect" +6. Verify timeline shows streaming events +7. Send follow-up message using chat input +8. Verify conversation continues + +**Step 5: Commit test server** + +```bash +git add test_server.py +git commit -m "test: add simple test server for UI integration" +``` + +--- + +## Task 8: Final Commit and Push + +**Step 1: Create final commit with all changes** + +```bash +git add -A +git status # Verify all files +git push +``` + +**Step 2: Update PR description** + +Add to the PR: +- UI now supports full bond.server integration +- Connect dialog for server URL + initial message +- Chat input for multi-turn conversations +- Session persistence across messages From 3816c340dc28decd9c7f5016150b2ae759717163 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:17:26 +0000 Subject: [PATCH 11/23] fix(ui): resolve race condition in server connection flow --- ui/src/App.tsx | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a5ea94a..3e6103b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -10,7 +10,7 @@ * - Chat input for multi-turn conversations */ -import { useState, useCallback, useMemo } from "react" +import { useState, useCallback, useMemo, useEffect } from "react" import { Card } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" @@ -36,6 +36,7 @@ export default function App() { const [mode, setMode] = useState("idle") const [sseUrl, setSseUrl] = useState(null) const [serverUrl, setServerUrl] = useState(null) + const [initialPrompt, setInitialPrompt] = useState(null) const [connectDialogOpen, setConnectDialogOpen] = useState(false) // Stream hook for legacy live mode (direct SSE) @@ -117,18 +118,31 @@ export default function App() { // Handle server connection from dialog const handleServerConnect = useCallback( - async (url: string, prompt: string) => { + (url: string, prompt: string) => { setServerUrl(url) + setInitialPrompt(prompt) setMode("server") setConnectDialogOpen(false) - // Small delay to ensure state is set - setTimeout(() => { - server.sendMessage(prompt) - }, 0) }, - [server] + [] ) + // Trigger initial message when server is ready + // This avoids race conditions where serverUrl hasn't propagated to useBondServer yet + useEffect(() => { + if ( + mode === "server" && + serverUrl && + initialPrompt && + !server.sessionId && + !server.error && + connectionStatus === "idle" + ) { + server.sendMessage(initialPrompt) + setInitialPrompt(null) + } + }, [mode, serverUrl, initialPrompt, server, connectionStatus]) + // Handle disconnect const handleDisconnect = useCallback(() => { From d42cdd50d79088aa6889f29222691fd0a57d1050 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:20:42 +0000 Subject: [PATCH 12/23] fix: update test server CORS and add UI debugging logs --- test_server.py | 22 +++++++++++++++++++--- ui/src/bond/useBondServer.ts | 2 ++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/test_server.py b/test_server.py index feed247..58a0c8f 100644 --- a/test_server.py +++ b/test_server.py @@ -1,6 +1,11 @@ import os from bond import BondAgent -from bond.server import create_bond_server +from bond.server import create_bond_server, ServerConfig + +# Check for API key +if "OPENAI_API_KEY" not in os.environ: + print("WARNING: OPENAI_API_KEY not found in environment variables.") + print("The server may fail to generate responses.") agent = BondAgent( name="test-assistant", @@ -8,8 +13,19 @@ model="openai:gpt-4o-mini", ) -app = create_bond_server(agent) +# Configure server with explicit CORS origins +config = ServerConfig( + cors_origins=[ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:8000" + ] +) + +app = create_bond_server(agent, config=config) if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + print("Starting Bond Test Server on http://0.0.0.0:8000") + print(f"Allowed Origins: {config.cors_origins}") + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/ui/src/bond/useBondServer.ts b/ui/src/bond/useBondServer.ts index a16331b..1a19ba2 100644 --- a/ui/src/bond/useBondServer.ts +++ b/ui/src/bond/useBondServer.ts @@ -170,9 +170,11 @@ export function useBondServer(serverUrl: string | null): BondServerControls { } const data: AskResponse = await response.json() + console.log("BondServer: Session created", data) setSessionId(data.session_id) connectToStream(data.stream_url) } catch (err) { + console.error("BondServer: Connection failed", err) setError(err instanceof Error ? err.message : "Failed to connect") setStatus("error") } From b0263c348057a8acf2ecebe89e04c679a34aa676 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:25:51 +0000 Subject: [PATCH 13/23] fix: add localhost:5175 to CORS origins for test server --- test_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test_server.py b/test_server.py index 58a0c8f..b62e37a 100644 --- a/test_server.py +++ b/test_server.py @@ -18,7 +18,9 @@ cors_origins=[ "http://localhost:5173", "http://127.0.0.1:5173", - "http://localhost:8000" + "http://localhost:8000", + "http://localhost:5175", + "http://127.0.0.1:5175" ] ) From e38c96263ad42e9a7625a1964d4a13fc13e02154 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:44:54 +0000 Subject: [PATCH 14/23] fix(ui): normalize tool_delta events and add debug logging --- ui/src/bond/normalize.ts | 6 ++++-- ui/src/bond/useBondServer.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/bond/normalize.ts b/ui/src/bond/normalize.ts index a2c697c..4e8584f 100644 --- a/ui/src/bond/normalize.ts +++ b/ui/src/bond/normalize.ts @@ -81,7 +81,7 @@ export function normalizeWireEvent(wire: WireEvent): BondEvent { */ type SSEBlockData = { kind: string; idx: number } type SSETextData = { c: string } | { content: string } -type SSEToolDeltaData = { n: string; a: string } +type SSEToolDeltaData = { n?: string; a?: string; name?: string; args?: string } type SSEToolExecData = { id: string; name: string; args: Record } type SSEToolResultData = { id: string; name: string; result: string } type SSECompleteData = { data: unknown } @@ -127,7 +127,9 @@ export function normalizeSSEEvent( case "tool_delta": { const d = data as SSEToolDeltaData - return { type: "tool_call_delta", nameDelta: d.n, argsDelta: d.a } + const name = d.name ?? d.n ?? "" + const args = d.args ?? d.a ?? "" + return { type: "tool_call_delta", nameDelta: name, argsDelta: args } } case "tool_exec": { diff --git a/ui/src/bond/useBondServer.ts b/ui/src/bond/useBondServer.ts index 1a19ba2..4b16f57 100644 --- a/ui/src/bond/useBondServer.ts +++ b/ui/src/bond/useBondServer.ts @@ -112,6 +112,7 @@ export function useBondServer(serverUrl: string | null): BondServerControls { eventTypes.forEach((eventType) => { es.addEventListener(eventType, (e: MessageEvent) => { try { + console.log(`BondServer: Received ${eventType}`, e.data) const data = JSON.parse(e.data) const bondEvent = normalizeSSEEvent(eventType, data) if (bondEvent) { From 21b12b23f803033a8ef1eb29389b21699b84d92b Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:49:56 +0000 Subject: [PATCH 15/23] fix(ui): handle non-streaming complete event as text response --- ui/src/bond/useBondServer.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/src/bond/useBondServer.ts b/ui/src/bond/useBondServer.ts index 4b16f57..d9db9fb 100644 --- a/ui/src/bond/useBondServer.ts +++ b/ui/src/bond/useBondServer.ts @@ -120,6 +120,14 @@ export function useBondServer(serverUrl: string | null): BondServerControls { } // On complete, close the stream if (eventType === "complete") { + const d = data as { data: string } + // If we only got a complete event (no streaming), treat it as a text response + if (history.count === 0 && d.data) { + const text = typeof d.data === "string" ? d.data : JSON.stringify(d.data) + processEvent({ type: "block_start", kind: "text", index: 0 }) + processEvent({ type: "text_delta", delta: text }) + processEvent({ type: "block_end", kind: "text", index: 0 }) + } es.close() setStatus("live") } From f9fd990417c81d8417c575be29c418583f7dd765 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:57:32 +0000 Subject: [PATCH 16/23] fix(ui): ensure non-streaming complete event is handled for all messages --- ui/src/bond/useBondServer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/src/bond/useBondServer.ts b/ui/src/bond/useBondServer.ts index d9db9fb..bc8cea3 100644 --- a/ui/src/bond/useBondServer.ts +++ b/ui/src/bond/useBondServer.ts @@ -54,6 +54,8 @@ export function useBondServer(serverUrl: string | null): BondServerControls { const [sessionId, setSessionId] = useState(null) const [error, setError] = useState(null) const [paused, setPaused] = useState(false) + // Track if current stream has yielded events + const streamHasEventsRef = useRef(false) const eventSourceRef = useRef(null) const pausedRef = useRef(paused) @@ -66,6 +68,7 @@ export function useBondServer(serverUrl: string | null): BondServerControls { const processEvent = useCallback( (event: BondEvent) => { + streamHasEventsRef.current = true history.push(event) if (!pausedRef.current) { dispatch(event) @@ -83,6 +86,7 @@ export function useBondServer(serverUrl: string | null): BondServerControls { eventSourceRef.current.close() } + streamHasEventsRef.current = false const fullUrl = serverUrl ? `${serverUrl}${streamUrl}` : streamUrl const es = new EventSource(fullUrl) eventSourceRef.current = es @@ -122,7 +126,7 @@ export function useBondServer(serverUrl: string | null): BondServerControls { if (eventType === "complete") { const d = data as { data: string } // If we only got a complete event (no streaming), treat it as a text response - if (history.count === 0 && d.data) { + if (!streamHasEventsRef.current && d.data) { const text = typeof d.data === "string" ? d.data : JSON.stringify(d.data) processEvent({ type: "block_start", kind: "text", index: 0 }) processEvent({ type: "text_delta", delta: text }) From fe5372fe1ac78b932fff3a9fd10592a560f1b6a6 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 22:57:52 +0000 Subject: [PATCH 17/23] feat(test): add list_files tool to test server --- test_server.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test_server.py b/test_server.py index b62e37a..842eab5 100644 --- a/test_server.py +++ b/test_server.py @@ -7,10 +7,19 @@ print("WARNING: OPENAI_API_KEY not found in environment variables.") print("The server may fail to generate responses.") +def list_files(directory: str = ".") -> str: + """List files in the given directory.""" + try: + files = os.listdir(directory) + return "\n".join(files) + except Exception as e: + return f"Error: {e}" + agent = BondAgent( name="test-assistant", - instructions="You are a helpful test assistant. Keep responses brief.", + instructions="You are a helpful test assistant. You can list files using the list_files tool.", model="openai:gpt-4o-mini", + tools=[list_files], ) # Configure server with explicit CORS origins From ef74a8a3ecd455d1ca32270b0bf025a0a7c3c53d Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 23:03:59 +0000 Subject: [PATCH 18/23] fix(test): correct BondAgent initialization in test server --- test_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_server.py b/test_server.py index 842eab5..6ce94e5 100644 --- a/test_server.py +++ b/test_server.py @@ -19,7 +19,7 @@ def list_files(directory: str = ".") -> str: name="test-assistant", instructions="You are a helpful test assistant. You can list files using the list_files tool.", model="openai:gpt-4o-mini", - tools=[list_files], + toolsets=[[list_files]], ) # Configure server with explicit CORS origins From 7df034c721a91a59bbb50817e6f95b0c5deb279d Mon Sep 17 00:00:00 2001 From: bordumb Date: Sat, 24 Jan 2026 23:07:29 +0000 Subject: [PATCH 19/23] fix(ui): re-index blocks to ensure unique IDs across conversation turns --- ui/src/bond/useBondServer.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/ui/src/bond/useBondServer.ts b/ui/src/bond/useBondServer.ts index bc8cea3..01e9cce 100644 --- a/ui/src/bond/useBondServer.ts +++ b/ui/src/bond/useBondServer.ts @@ -56,6 +56,10 @@ export function useBondServer(serverUrl: string | null): BondServerControls { const [paused, setPaused] = useState(false) // Track if current stream has yielded events const streamHasEventsRef = useRef(false) + + // Track global block index to prevent collisions between streams + const nextBlockIndexRef = useRef(0) + const streamIndexMapRef = useRef>({}) const eventSourceRef = useRef(null) const pausedRef = useRef(paused) @@ -69,11 +73,26 @@ export function useBondServer(serverUrl: string | null): BondServerControls { const processEvent = useCallback( (event: BondEvent) => { streamHasEventsRef.current = true - history.push(event) + + // Re-index blocks to ensure unique IDs across session + const processedEvent = { ...event } + + if (processedEvent.type === "block_start") { + const newIndex = nextBlockIndexRef.current++ + streamIndexMapRef.current[processedEvent.index] = newIndex + processedEvent.index = newIndex + } else if (processedEvent.type === "block_end") { + const mapped = streamIndexMapRef.current[processedEvent.index] + if (mapped !== undefined) { + processedEvent.index = mapped + } + } + + history.push(processedEvent) if (!pausedRef.current) { - dispatch(event) + dispatch(processedEvent) } else { - pauseBufferRef.current.push(event) + pauseBufferRef.current.push(processedEvent) } }, [history] @@ -87,6 +106,7 @@ export function useBondServer(serverUrl: string | null): BondServerControls { } streamHasEventsRef.current = false + streamIndexMapRef.current = {} const fullUrl = serverUrl ? `${serverUrl}${streamUrl}` : streamUrl const es = new EventSource(fullUrl) eventSourceRef.current = es From 332bd56b74d0bc2d61c74696935f5991d39e7ea1 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 25 Jan 2026 00:22:47 +0000 Subject: [PATCH 20/23] feat(server): add GitHub toolset and fix streaming tool visualization - Add GitHub toolset to test server with BondToolDeps - Fix SSE streaming race condition by using synchronous queue operations - Fix tool event emission from PydanticAI message history - Add justfile for easy demo setup (just demo, just demo-stop, etc.) - Fix useBondServer complete event handling order - Add rate limit handling instructions to agent - Reduce max_retries to prevent rate limit loops The tool visualization now works correctly - tool blocks show arguments and results similar to the demo mode. Co-Authored-By: Claude Opus 4.5 --- justfile | 416 ++++++++++++++++++++++++++ plan.md | 471 ------------------------------ src/bond/agent.py | 168 ++++++----- src/bond/server/_routes.py | 53 ++-- src/bond/server/_session.py | 4 +- src/bond/tools/github/_adapter.py | 5 +- src/bond/utils.py | 33 ++- test_server.py | 50 +++- tests/unit/server/test_session.py | 4 +- ui/src/bond/useBondServer.ts | 13 +- 10 files changed, 634 insertions(+), 583 deletions(-) create mode 100644 justfile delete mode 100644 plan.md diff --git a/justfile b/justfile new file mode 100644 index 0000000..f960897 --- /dev/null +++ b/justfile @@ -0,0 +1,416 @@ +# Bond Agent UI - Command Runner +# Forensic timeline viewer for Bond agent traces +# Run `just` to see all available commands + +# ============================================================================= +# Configuration +# ============================================================================= + +# Default ports +ui_port := "5173" +backend_port := "8000" + +# Project paths (relative to this justfile) +project_root := justfile_directory() +ui_dir := justfile_directory() / "ui" + +# ============================================================================= +# Default & Help +# ============================================================================= + +# List all available commands +default: + @just --list + +# Show detailed help for demo setup +help: + @echo "Bond Agent UI - Forensic Timeline Viewer" + @echo "=========================================" + @echo "" + @echo "Quick Start:" + @echo " just setup Install all dependencies" + @echo " just demo Run full demo (backend + frontend)" + @echo " just demo-stop Stop all running services" + @echo "" + @echo "Development:" + @echo " just dev-ui Run frontend only (Vite)" + @echo " just dev-backend Run backend only (uvicorn)" + @echo " just dev Run both frontend and backend" + @echo "" + @echo "Ports:" + @echo " Frontend: http://localhost:{{ui_port}}" + @echo " Backend: http://localhost:{{backend_port}}" + @echo "" + @echo "Usage:" + @echo " 1. Click 'Connect' in the UI" + @echo " 2. Server URL: http://localhost:8000" + @echo " 3. Enter a prompt and chat!" + +# ============================================================================= +# Setup Commands +# ============================================================================= + +# Install all dependencies (Python + Node) +setup: setup-python setup-node setup-pre-commit + @echo "" + @echo "Setup complete! Run 'just demo' to start." + +# Install Python dependencies +setup-python: + @echo "Installing Python dependencies..." + uv sync --extra server --extra dev + @echo "Python dependencies installed." + +# Install Node dependencies (pnpm) +setup-node: + @echo "Installing Node dependencies..." + cd {{ui_dir}} && pnpm install + @echo "Node dependencies installed." + +# Install pre-commit hooks +setup-pre-commit: + @echo "Installing pre-commit hooks..." + uv run pre-commit install || true + @echo "Pre-commit hooks installed." + +# ============================================================================= +# Demo Commands +# ============================================================================= + +# Run full demo stack (backend + frontend + auto-open browser) +demo: check-env + #!/usr/bin/env bash + set -euo pipefail + + echo "Starting Bond Agent Demo..." + echo "" + + # Kill any existing processes on our ports + lsof -ti:{{ui_port}} | xargs kill -9 2>/dev/null || true + lsof -ti:{{backend_port}} | xargs kill -9 2>/dev/null || true + + # Trap to clean up on exit + trap 'kill 0 2>/dev/null' EXIT + + echo "=========================================" + echo " Bond Agent Demo" + echo "=========================================" + echo "" + echo " Frontend: http://localhost:{{ui_port}}" + echo " Backend: http://localhost:{{backend_port}}" + echo "" + echo " Press Ctrl+C to stop all services" + echo "=========================================" + echo "" + + # Start backend + (uv run python test_server.py) & + BACKEND_PID=$! + + # Wait for backend to be ready + echo "Waiting for backend to be ready..." + for i in {1..30}; do + if curl -s http://localhost:{{backend_port}}/health > /dev/null 2>&1; then + echo "Backend ready!" + break + fi + sleep 1 + done + + # Open browser after frontend is ready + ( + sleep 3 + if command -v open &> /dev/null; then + open "http://localhost:{{ui_port}}" + elif command -v xdg-open &> /dev/null; then + xdg-open "http://localhost:{{ui_port}}" & + fi + ) & + + # Start frontend (this blocks) + cd {{ui_dir}} && pnpm dev --port {{ui_port}} + +# Run demo without opening browser +demo-headless: check-env + #!/usr/bin/env bash + set -euo pipefail + + echo "Starting Bond Agent Demo (headless)..." + + # Kill any existing processes on our ports + lsof -ti:{{ui_port}} | xargs kill -9 2>/dev/null || true + lsof -ti:{{backend_port}} | xargs kill -9 2>/dev/null || true + + trap 'kill 0 2>/dev/null' EXIT + + echo "" + echo " Frontend: http://localhost:{{ui_port}}" + echo " Backend: http://localhost:{{backend_port}}" + echo "" + + # Start backend + (uv run python test_server.py) & + + # Start frontend + cd {{ui_dir}} && pnpm dev --port {{ui_port}} + +# Stop all demo services +demo-stop: + #!/usr/bin/env bash + echo "Stopping demo services..." + + # Kill by process name + pkill -f "uvicorn.*test_server" 2>/dev/null || true + pkill -f "python.*test_server" 2>/dev/null || true + pkill -f "vite" 2>/dev/null || true + pkill -f "pnpm dev" 2>/dev/null || true + + # Kill by port (fallback) + lsof -ti:{{ui_port}} | xargs kill -9 2>/dev/null || true + lsof -ti:{{backend_port}} | xargs kill -9 2>/dev/null || true + + # Kill orphaned Vite processes on nearby ports + for port in 5173 5174 5175 5176 5177 5178 5179; do + lsof -ti:$port | xargs kill -9 2>/dev/null || true + done + + echo "Done. All services stopped." + +# Show what's running on demo ports +demo-status: + #!/usr/bin/env bash + echo "Port Status:" + echo "============" + echo "" + echo "Frontend ({{ui_port}}):" + lsof -i :{{ui_port}} 2>/dev/null || echo " Not running" + echo "" + echo "Backend ({{backend_port}}):" + lsof -i :{{backend_port}} 2>/dev/null || echo " Not running" + echo "" + echo "Other Vite ports (5173-5179):" + lsof -i :5173 -i :5174 -i :5175 -i :5176 -i :5177 -i :5178 -i :5179 2>/dev/null || echo " None" + +# ============================================================================= +# Development Commands +# ============================================================================= + +# Run frontend only (Vite dev server) +dev-ui: + #!/usr/bin/env bash + # Kill any existing Vite on our port + lsof -ti:{{ui_port}} | xargs kill -9 2>/dev/null || true + echo "Starting UI on http://localhost:{{ui_port}}..." + cd {{ui_dir}} && pnpm dev --port {{ui_port}} + +# Run backend only (test server) +dev-backend: check-env + #!/usr/bin/env bash + lsof -ti:{{backend_port}} | xargs kill -9 2>/dev/null || true + echo "Starting backend on http://localhost:{{backend_port}}..." + uv run python test_server.py + +# Run both frontend and backend (alias for demo-headless) +dev: demo-headless + +# Run frontend with specific port +dev-ui-port port: + #!/usr/bin/env bash + lsof -ti:{{port}} | xargs kill -9 2>/dev/null || true + echo "Starting UI on http://localhost:{{port}}..." + cd {{ui_dir}} && pnpm dev --port {{port}} + +# ============================================================================= +# Build Commands +# ============================================================================= + +# Build production bundle +build: + @echo "Building production bundle..." + cd {{ui_dir}} && pnpm build + @echo "Build complete! Output in ui/dist/" + +# Preview production build +preview: + @echo "Starting preview server..." + cd {{ui_dir}} && pnpm preview + +# Build and preview +build-preview: build preview + +# ============================================================================= +# Code Quality Commands +# ============================================================================= + +# Run ESLint +lint: + @echo "Running ESLint..." + cd {{ui_dir}} && pnpm lint + +# Run ESLint with auto-fix +lint-fix: + @echo "Running ESLint with auto-fix..." + cd {{ui_dir}} && pnpm lint --fix + +# Type check (TypeScript) +typecheck: + @echo "Running TypeScript type check..." + cd {{ui_dir}} && pnpm exec tsc --noEmit + +# Run all checks (lint + typecheck) +check: lint typecheck + @echo "All checks passed!" + +# ============================================================================= +# Python Backend Commands +# ============================================================================= + +# Run Python tests +test-python: + @echo "Running Python tests..." + uv run pytest + +# Run Python tests with coverage +test-python-cov: + @echo "Running Python tests with coverage..." + uv run pytest --cov=src/bond --cov-report=html + @echo "Coverage report: htmlcov/index.html" + +# Run Python linting (ruff) +lint-python: + @echo "Running ruff..." + uv run ruff check src tests + +# Format Python code +format-python: + @echo "Formatting Python code..." + uv run ruff format src tests + +# Run Python type checking (mypy) +typecheck-python: + @echo "Running mypy..." + uv run mypy src/bond + +# Run all Python checks +check-python: lint-python typecheck-python + @echo "All Python checks passed!" + +# ============================================================================= +# Pre-commit & CI +# ============================================================================= + +# Run pre-commit on all files +pre-commit: + @echo "Running pre-commit hooks..." + uv run pre-commit run --all-files + +# Run full CI pipeline locally +ci: check check-python test-python build + @echo "" + @echo "CI pipeline passed!" + +# ============================================================================= +# Clean Commands +# ============================================================================= + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -rf {{ui_dir}}/dist + rm -rf {{ui_dir}}/node_modules/.cache + rm -rf {{ui_dir}}/node_modules/.vite + @echo "Clean complete." + +# Clean all (including node_modules) +clean-all: clean + @echo "Removing node_modules..." + rm -rf {{ui_dir}}/node_modules + @echo "Full clean complete. Run 'just setup-node' to reinstall." + +# Clean Python artifacts +clean-python: + @echo "Cleaning Python artifacts..." + rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist + @echo "Python clean complete." + +# ============================================================================= +# Documentation +# ============================================================================= + +# Serve docs locally +docs: + @echo "Starting docs server..." + uv run mkdocs serve + +# Build docs +docs-build: + @echo "Building docs..." + uv run mkdocs build + @echo "Docs built! Output in site/" + +# ============================================================================= +# Utility Commands +# ============================================================================= + +# Check environment is ready +check-env: + #!/usr/bin/env bash + echo "Checking environment..." + + # Check for OPENAI_API_KEY + if [ -z "${OPENAI_API_KEY:-}" ]; then + echo "" + echo "WARNING: OPENAI_API_KEY not set!" + echo "The backend will fail to generate AI responses." + echo "" + echo "Set it with:" + echo " export OPENAI_API_KEY=sk-..." + echo "" + echo "Or create a .env file:" + echo " echo 'OPENAI_API_KEY=sk-...' > .env" + echo "" + # Don't exit - allow demo to run without API key + else + echo "OPENAI_API_KEY is set." + fi + + # Check for required tools + for cmd in node pnpm uv; do + if ! command -v $cmd &> /dev/null; then + echo "ERROR: $cmd is required but not installed." + exit 1 + fi + done + + echo "Environment check passed." + +# Open project in browser +open-ui: + #!/usr/bin/env bash + if command -v open &> /dev/null; then + open "http://localhost:{{ui_port}}" + elif command -v xdg-open &> /dev/null; then + xdg-open "http://localhost:{{ui_port}}" & + else + echo "Open http://localhost:{{ui_port}} in your browser" + fi + +# Show project info +info: + @echo "Bond Agent UI" + @echo "=============" + @echo "" + @echo "Project: {{project_root}}" + @echo "UI Dir: {{ui_dir}}" + @echo "" + @echo "Node: $(node --version)" + @echo "pnpm: $(pnpm --version)" + @echo "uv: $(uv --version)" + @echo "" + @echo "Run 'just help' for usage instructions." + +# Concatenate all source files (for context/review) +concat: + @echo "Concatenating source files..." + python scripts/concat_files.py + @echo "Output: all_code_and_configs.txt" diff --git a/plan.md b/plan.md deleted file mode 100644 index fa237df..0000000 --- a/plan.md +++ /dev/null @@ -1,471 +0,0 @@ -Epic: Bond UI — Minimal, Beautiful “Forensic Timeline” Frontend - -Goal: A polished single-page web app that connects to a Bond streaming endpoint (SSE or WebSocket), renders the agent lifecycle as a live timeline (text/thinking/tool-call/tool-result), and supports “replay” (scrub + pause) so the demo lands instantly. - -Non-goals: auth, accounts, persistence, multi-run browsing, complex theming system, backend work (beyond exposing a simple stream). - -⸻ - -What “done” looks like - • You can hit Run Demo and watch blocks appear with a satisfying typing effect. - • Thinking is visually distinct (subtle, collapsible). - • Tool calls show streaming args, then flip to “executing…”, then show the result. - • You can pause the stream, scrub the timeline, and click any block to inspect details. - • The UI looks like a modern devtool: clean spacing, soft shadows, calm typography, great empty/loading/error states. - -⸻ - -Architecture (simple, future-proof) - • React + Vite + Tailwind - • shadcn/ui primitives (Card, Tabs, ScrollArea, Button, Badge) - • Framer Motion for tasteful block animations - • Event-driven store: keep a canonical list of BondEvents + derived Blocks - -You’ll implement a thin “event → block” reducer that can support both live streaming and replay. - -⸻ - -Tasks - -1) Create project scaffold + styling baseline - -Tasks - • Set up Vite React + TS - • Add Tailwind + shadcn/ui - • Add Framer Motion - • Create a minimal theme: neutral background, nice cards, monospace for tool args - -Acceptance - • App loads with a clean shell (header + sidebar + main timeline) - • Typography and spacing already feel “premium” - -Snippet: app shell layout - -// src/App.tsx -import { Card } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; - -export default function App() { - return ( -
-
-
-
-
Bond
-
Forensic timeline UI
-
-
- - -
-
-
- -
- - -
- -
-
Timeline
-
- -
- Waiting for events… -
-
-
-
-
-
- ); -} - - -⸻ - -2) Define event schema + block model - -You want one normalized internal model that feels like “blocks”, regardless of whether the backend sends raw delta events. - -Tasks - • Define BondEvent union types - • Define Block model (Text | Thinking | Tool) - • Implement reducer that ingests events and updates blocks in-place (for streaming) - -Acceptance - • Given a stream of events, UI can render stable blocks that update as deltas arrive - -Snippet: types + reducer skeleton - -// src/bond/types.ts -export type BlockKind = "text" | "thinking" | "tool_call"; - -export type BondEvent = - | { type: "block_start"; kind: BlockKind; index: number; ts: number } - | { type: "block_end"; kind: BlockKind; index: number; ts: number } - | { type: "text_delta"; delta: string; ts: number } - | { type: "thinking_delta"; delta: string; ts: number } - | { type: "tool_call_delta"; nameDelta: string; argsDelta: string; ts: number } - | { type: "tool_execute"; id: string; name: string; args: Record; ts: number } - | { type: "tool_result"; id: string; name: string; result: string; ts: number } - | { type: "complete"; ts: number }; - -export type Block = - | { - id: string; - kind: "text" | "thinking"; - index: number; - content: string; - isClosed: boolean; - } - | { - id: string; - kind: "tool_call"; - index: number; - toolNameDraft: string; - toolArgsDraft: string; - toolId?: string; - toolName?: string; - toolArgs?: Record; - status: "forming" | "executing" | "done"; - result?: string; - isClosed: boolean; - }; - -// src/bond/reducer.ts -import { BondEvent, Block } from "./types"; - -type State = { - blocks: Block[]; - activeBlockId?: string; -}; - -const mkId = (kind: string, index: number) => `${kind}:${index}`; - -export function reduce(state: State, ev: BondEvent): State { - switch (ev.type) { - case "block_start": { - const id = mkId(ev.kind, ev.index); - const block: Block = - ev.kind === "tool_call" - ? { - id, - kind: "tool_call", - index: ev.index, - toolNameDraft: "", - toolArgsDraft: "", - status: "forming", - isClosed: false, - } - : { id, kind: ev.kind, index: ev.index, content: "", isClosed: false }; - - return { ...state, blocks: [...state.blocks, block], activeBlockId: id }; - } - - case "text_delta": - case "thinking_delta": { - const blocks = state.blocks.map((b) => { - if (!state.activeBlockId || b.id !== state.activeBlockId) return b; - if (b.kind === "text" && ev.type === "text_delta") - return { ...b, content: b.content + ev.delta }; - if (b.kind === "thinking" && ev.type === "thinking_delta") - return { ...b, content: b.content + ev.delta }; - return b; - }); - return { ...state, blocks }; - } - - case "tool_call_delta": { - const blocks = state.blocks.map((b) => { - if (!state.activeBlockId || b.id !== state.activeBlockId) return b; - if (b.kind !== "tool_call") return b; - return { - ...b, - toolNameDraft: b.toolNameDraft + (ev.nameDelta ?? ""), - toolArgsDraft: b.toolArgsDraft + (ev.argsDelta ?? ""), - }; - }); - return { ...state, blocks }; - } - - case "tool_execute": { - const blocks = state.blocks.map((b) => { - if (b.kind !== "tool_call") return b; - // simplest: attach to latest tool block - // (you can improve by mapping call_id -> current tool block) - return b.status === "forming" - ? { ...b, toolId: ev.id, toolName: ev.name, toolArgs: ev.args, status: "executing" } - : b; - }); - return { ...state, blocks }; - } - - case "tool_result": { - const blocks = state.blocks.map((b) => { - if (b.kind !== "tool_call") return b; - if (b.toolId !== ev.id) return b; - return { ...b, status: "done", result: ev.result }; - }); - return { ...state, blocks }; - } - - case "block_end": { - const id = mkId(ev.kind, ev.index); - const blocks = state.blocks.map((b) => (b.id === id ? { ...b, isClosed: true } : b)); - return { ...state, blocks, activeBlockId: undefined }; - } - - default: - return state; - } -} - - -⸻ - -3) Implement streaming transport (SSE first) - -SSE is perfect for demos: simple, debuggable, works in browsers. - -Tasks - • Build useBondStream(url) hook - • Parse incoming lines as JSON events - • Reconnect with backoff - • Provide controls: connect/disconnect/pause - -Acceptance - • You can connect to /events and see blocks update live - • Disconnect works cleanly - -Snippet: SSE hook - -// src/bond/useBondStream.ts -import { useEffect, useMemo, useReducer, useRef, useState } from "react"; -import { BondEvent } from "./types"; -import { reduce } from "./reducer"; - -const initial = { blocks: [] as any[], activeBlockId: undefined as string | undefined }; - -export function useBondStream(url: string | null) { - const [state, dispatch] = useReducer(reduce as any, initial); - const [status, setStatus] = useState<"idle" | "connecting" | "live" | "error">("idle"); - const [paused, setPaused] = useState(false); - const esRef = useRef(null); - - const connect = useMemo(() => { - return () => { - if (!url) return; - setStatus("connecting"); - const es = new EventSource(url); - esRef.current = es; - - es.onopen = () => setStatus("live"); - es.onerror = () => setStatus("error"); - - es.onmessage = (msg) => { - if (paused) return; - try { - const ev = JSON.parse(msg.data) as BondEvent; - dispatch(ev); - } catch { - // ignore bad frames for demo resilience - } - }; - }; - }, [url, paused]); - - const disconnect = () => { - esRef.current?.close(); - esRef.current = null; - setStatus("idle"); - }; - - useEffect(() => () => disconnect(), []); - - return { state, status, paused, setPaused, connect, disconnect }; -} - - -⸻ - -4) Build timeline rendering (the “wow” factor) - -This is where the product sells itself. - -Tasks - • Create BlockCard component variants: - • Text block (clean prose) - • Thinking block (subtle, collapsible, slightly dim) - • Tool block (name/args streaming, status pill, result panel) - • Animations: slide/fade-in as blocks appear - • Auto-scroll: follow live events unless user scrolls up - -Acceptance - • Timeline looks like a real devtool (not a hackathon UI) - • Tool blocks feel “alive” as args stream in - -Snippet: block renderer - -// src/ui/Timeline.tsx -import { motion } from "framer-motion"; -import { Badge } from "@/components/ui/badge"; -import { Card } from "@/components/ui/card"; -import { Block } from "@/bond/types"; - -function statusBadge(status: "forming" | "executing" | "done") { - const label = status === "forming" ? "forming" : status === "executing" ? "executing" : "done"; - return {label}; -} - -export function Timeline({ blocks }: { blocks: Block[] }) { - return ( -
- {blocks.map((b) => ( - - - {b.kind === "text" && ( -
- {b.content || } -
- )} - - {b.kind === "thinking" && ( -
-
Thinking
- {b.content || } -
- )} - - {b.kind === "tool_call" && ( -
-
-
Tool
-
- {b.toolName ?? b.toolNameDraft || "…"} -
- {statusBadge(b.status)} -
- -
-
Args
-
-                    {b.toolArgs ? JSON.stringify(b.toolArgs, null, 2) : (b.toolArgsDraft || "…")}
-                  
-
- - {b.result && ( -
-
Result
-
-                      {b.result}
-                    
-
- )} -
- )} -
-
- ))} -
- ); -} - - -⸻ - -5) Add “Replay mode” (pause + scrub) - -Replay is what turns “cool” into “I need this”. - -Tasks - • Persist incoming events in memory (events[]) - • When paused: stop applying new events to blocks, but keep buffering events - • Implement scrubber: - • Slider from 0..N events - • Rebuild blocks by reducing events up to index K - • Add “Live” vs “Replay” indicator - -Acceptance - • You can pause at any time, scrub backwards, click blocks, then jump back to live - -Implementation note: easiest is “replay by re-reducing from 0..K” (fast enough for demo scale). - -⸻ - -6) “Inspector” panel (click a block → details) - -This is where devs fall in love. - -Tasks - • Click a block to select it - • Side panel shows: - • raw event fragments (optional) - • tool call id, full args, result length - • timestamps - • Add “Copy as JSON” button - -Acceptance - • You can select any block and copy evidence for a bug report / PR comment - -⸻ - -7) Demo mode: deterministic canned run (for perfect recordings) - -Live demos are fragile; you want a “movie mode”. - -Tasks - • Add /public/demo-events.ndjson (pre-recorded events) - • Implement useBondReplayFromFile() that plays events on a timer - • Playback controls: play/pause, speed (1x / 2x), jump to moment - • Button: “Load Demo Run” - -Acceptance - • You can always produce a flawless screen recording even if backend is down - -⸻ - -8) Polish pass (this is what makes it beautiful) - -Tasks - • Empty states, skeletons, subtle gradients - • Cursor/typing shimmer for active block - • Nice microcopy - • Auto-scroll behavior that doesn’t fight the user - • Keyboard shortcuts: - • Space = pause/play - • L = jump live - • J/K = step events - • Dark mode only (for now) but perfect dark mode - -Acceptance - • Feels like Linear / Vercel / Raycast quality, not “React starter” - -⸻ - -Demo checklist (so the frontend actually sells Bond) - • The first 10 seconds show: - • thinking starts - • tool args stream - • tool executes - • result returns - • You pause and scrub back to “the moment the wrong assumption appears” - • You click the thought block and highlight that exact sentence - • You jump back to live and finish the run - -That’s the “oh shit” moment. - -⸻ diff --git a/src/bond/agent.py b/src/bond/agent.py index b84aa5f..5ad15d0 100644 --- a/src/bond/agent.py +++ b/src/bond/agent.py @@ -6,18 +6,7 @@ from typing import Any, Generic, TypeVar from pydantic_ai import Agent -from pydantic_ai.messages import ( - FinalResultEvent, - FunctionToolCallEvent, - FunctionToolResultEvent, - ModelMessage, - PartDeltaEvent, - PartEndEvent, - PartStartEvent, - TextPartDelta, - ThinkingPartDelta, - ToolCallPartDelta, -) +from pydantic_ai.messages import ModelMessage from pydantic_ai.models import Model from pydantic_ai.tools import Tool @@ -180,72 +169,109 @@ async def ask( active_agent = Agent(**dynamic_kwargs) if handlers: - # Track tool call IDs to names for result lookup - tool_id_to_name: dict[str, str] = {} - # Build run_stream kwargs - only include deps if provided stream_kwargs: dict[str, Any] = {"message_history": self._history} if self.deps is not None: stream_kwargs["deps"] = self.deps async with active_agent.run_stream(prompt, **stream_kwargs) as result: - async for event in result.stream(): - # --- 1. BLOCK LIFECYCLE (Open/Close) --- - if isinstance(event, PartStartEvent): + # PydanticAI runs tools internally before streaming the final response. + # We need to emit tool events from the message history first. + + from pydantic_ai.messages import ( + ModelRequest as MsgModelRequest, + ) + from pydantic_ai.messages import ( + ModelResponse as MsgModelResponse, + ) + from pydantic_ai.messages import ( + ToolCallPart, + ToolReturnPart, + ) + + block_index = 0 + tool_id_to_block: dict[str, int] = {} + + # Wait for streaming to be ready, then check for tool calls in history + # The new_messages() contains messages from THIS run + # We need to consume at least one chunk to populate messages + first_chunk = None + async for chunk in result.stream_text(): + first_chunk = chunk + break + + # Now check new_messages for tool calls that happened + for msg in result.new_messages(): + if isinstance(msg, MsgModelResponse): + for part in msg.parts: + if isinstance(part, ToolCallPart): + # Emit tool call block + if handlers.on_block_start: + handlers.on_block_start("tool-call", block_index) + tool_id_to_block[part.tool_call_id] = block_index + + # Emit tool name/args + if handlers.on_tool_call_delta: + handlers.on_tool_call_delta(part.tool_name, "") + if isinstance(part.args, str): + args_str = part.args + else: + args_str = json.dumps(part.args) + handlers.on_tool_call_delta("", args_str) + + # Emit execute event + if handlers.on_tool_execute: + if isinstance(part.args, str): + args_dict = json.loads(part.args) + else: + args_dict = dict(part.args) if part.args else {} + handlers.on_tool_execute( + part.tool_call_id, part.tool_name, args_dict + ) + + block_index += 1 + + elif isinstance(msg, MsgModelRequest): + for req_part in msg.parts: + if isinstance(req_part, ToolReturnPart): + # Emit tool result + if handlers.on_tool_result: + if isinstance(req_part.content, str): + result_str = req_part.content + else: + result_str = str(req_part.content) + handlers.on_tool_result( + req_part.tool_call_id, + req_part.tool_name, + result_str, + ) + + # Close the tool block + tool_block = tool_id_to_block.get(req_part.tool_call_id) + if tool_block is not None and handlers.on_block_end: + handlers.on_block_end("tool-call", tool_block) + + # Now stream the text response + text_block = block_index + text_started = False + + if first_chunk: + if handlers.on_block_start: + handlers.on_block_start("text", text_block) + text_started = True + if handlers.on_text_delta: + handlers.on_text_delta(first_chunk) + + async for chunk in result.stream_text(): + if not text_started: if handlers.on_block_start: - kind = getattr(event.part, "part_kind", "unknown") - handlers.on_block_start(kind, event.index) - - elif isinstance(event, PartEndEvent): - if handlers.on_block_end: - kind = getattr(event.part, "part_kind", "unknown") - handlers.on_block_end(kind, event.index) - - # --- 2. DELTAS (Typing Effect) --- - elif isinstance(event, PartDeltaEvent): - delta = event.delta - - if isinstance(delta, TextPartDelta): - if handlers.on_text_delta: - handlers.on_text_delta(delta.content_delta) - - elif isinstance(delta, ThinkingPartDelta): - if handlers.on_thinking_delta and delta.content_delta: - handlers.on_thinking_delta(delta.content_delta) - - elif isinstance(delta, ToolCallPartDelta): - if handlers.on_tool_call_delta: - name_d = delta.tool_name_delta or "" - args_d = delta.args_delta or "" - # Handle dict args (rare but possible) - if isinstance(args_d, dict): - args_d = json.dumps(args_d) - handlers.on_tool_call_delta(name_d, args_d) - - # --- 3. EXECUTION (Tool Running/Results) --- - elif isinstance(event, FunctionToolCallEvent): - # Tool call fully formed, starting execution - tool_id_to_name[event.tool_call_id] = event.part.tool_name - if handlers.on_tool_execute: - handlers.on_tool_execute( - event.tool_call_id, - event.part.tool_name, - event.part.args_as_dict(), - ) - - elif isinstance(event, FunctionToolResultEvent): - # Tool returned data - if handlers.on_tool_result: - tool_name = tool_id_to_name.get(event.tool_call_id, "unknown") - handlers.on_tool_result( - event.tool_call_id, - tool_name, - str(event.result.content), - ) - - # --- 4. COMPLETION --- - elif isinstance(event, FinalResultEvent): - pass # Handled after stream + handlers.on_block_start("text", text_block) + text_started = True + if handlers.on_text_delta: + handlers.on_text_delta(chunk) + + if text_started and handlers.on_block_end: + handlers.on_block_end("text", text_block) # Stream finished self._history = list(result.all_messages()) diff --git a/src/bond/server/_routes.py b/src/bond/server/_routes.py index 2917325..cd58281 100644 --- a/src/bond/server/_routes.py +++ b/src/bond/server/_routes.py @@ -21,7 +21,7 @@ ServerConfig, SessionResponse, ) -from bond.utils import create_sse_handlers, create_websocket_handlers +from bond.utils import create_websocket_handlers if TYPE_CHECKING: from bond.agent import BondAgent @@ -133,18 +133,41 @@ async def stream(self, request: Request) -> Response: async def event_generator() -> Any: """Generate SSE events from agent streaming.""" try: - await self.session_manager.update_status( - session_id, SessionStatus.STREAMING - ) + await self.session_manager.update_status(session_id, SessionStatus.STREAMING) # Set up agent history self.agent.set_message_history(session.history) - # Create SSE send function - async def send_sse(event: str, data: dict[str, Any]) -> None: - await session.result_queue.put({"event": event, "data": data}) - - handlers = create_sse_handlers(send_sse) + # Create synchronous handlers that put directly to queue + # This avoids race conditions with async task scheduling + from bond.agent import StreamHandlers + + handlers = StreamHandlers( + on_block_start=lambda kind, idx: session.result_queue.put_nowait( + {"event": "block_start", "data": {"kind": kind, "idx": idx}} + ), + on_block_end=lambda kind, idx: session.result_queue.put_nowait( + {"event": "block_end", "data": {"kind": kind, "idx": idx}} + ), + on_text_delta=lambda txt: session.result_queue.put_nowait( + {"event": "text", "data": {"content": txt}} + ), + on_thinking_delta=lambda txt: session.result_queue.put_nowait( + {"event": "thinking", "data": {"content": txt}} + ), + on_tool_call_delta=lambda n, a: session.result_queue.put_nowait( + {"event": "tool_delta", "data": {"name": n, "args": a}} + ), + on_tool_execute=lambda i, n, a: session.result_queue.put_nowait( + {"event": "tool_exec", "data": {"id": i, "name": n, "args": a}} + ), + on_tool_result=lambda i, n, r: session.result_queue.put_nowait( + {"event": "tool_result", "data": {"id": i, "name": n, "result": r}} + ), + on_complete=lambda data: session.result_queue.put_nowait( + {"event": "complete", "data": {"data": data}} + ), + ) # Start agent task agent_task = asyncio.create_task( @@ -181,9 +204,7 @@ async def send_sse(event: str, data: dict[str, Any]) -> None: break except Exception as e: - await self.session_manager.update_status( - session_id, SessionStatus.ERROR, str(e) - ) + await self.session_manager.update_status(session_id, SessionStatus.ERROR, str(e)) yield { "event": "error", "data": json.dumps({"error": str(e)}), @@ -216,14 +237,10 @@ async def _run_agent( session_id, self.agent.get_message_history(), ) - await self.session_manager.update_status( - session_id, SessionStatus.COMPLETED - ) + await self.session_manager.update_status(session_id, SessionStatus.COMPLETED) except Exception as e: - await self.session_manager.update_status( - session_id, SessionStatus.ERROR, str(e) - ) + await self.session_manager.update_status(session_id, SessionStatus.ERROR, str(e)) raise finally: # Signal completion to event generator diff --git a/src/bond/server/_session.py b/src/bond/server/_session.py index 6743f03..2041164 100644 --- a/src/bond/server/_session.py +++ b/src/bond/server/_session.py @@ -115,9 +115,7 @@ async def create_session( await self._cleanup_expired_locked() if len(self._sessions) >= self._max_sessions: - raise ValueError( - f"Maximum concurrent sessions ({self._max_sessions}) reached" - ) + raise ValueError(f"Maximum concurrent sessions ({self._max_sessions}) reached") # Use provided session_id or generate new one if session_id and session_id in self._sessions: diff --git a/src/bond/tools/github/_adapter.py b/src/bond/tools/github/_adapter.py index 0387247..7ff32a9 100644 --- a/src/bond/tools/github/_adapter.py +++ b/src/bond/tools/github/_adapter.py @@ -133,7 +133,7 @@ async def _request( raise RateLimitedError(int(reset_at) if reset_at else None) # Exponential backoff - wait_time = 2 ** retries + wait_time = 2**retries await asyncio.sleep(wait_time) retries += 1 continue @@ -318,8 +318,7 @@ async def search_code( repository=item["repository"]["full_name"], html_url=item["html_url"], text_matches=tuple( - match.get("fragment", "") - for match in item.get("text_matches", []) + match.get("fragment", "") for match in item.get("text_matches", []) ), ) for item in items diff --git a/src/bond/utils.py b/src/bond/utils.py index 9eac281..6d9f672 100644 --- a/src/bond/utils.py +++ b/src/bond/utils.py @@ -146,12 +146,41 @@ async def send_sse(event: str, data: dict): ``` """ import asyncio + from collections import deque + + # Buffer events and track pending tasks to ensure ordering + pending_tasks: deque[asyncio.Task[None]] = deque() def _send_sync(event: str, data: dict[str, Any]) -> None: try: loop = asyncio.get_running_loop() coro = send(event, data) - loop.create_task(coro) # type: ignore[arg-type] + task: asyncio.Task[None] = loop.create_task(coro) # type: ignore[arg-type] + pending_tasks.append(task) + # Clean up completed tasks + while pending_tasks and pending_tasks[0].done(): + pending_tasks.popleft() + except RuntimeError: + pass + + async def _flush_pending() -> None: + """Wait for all pending tasks to complete.""" + while pending_tasks: + task = pending_tasks.popleft() + if not task.done(): + await task + + def _send_complete(data: Any) -> None: + """Send complete event after flushing pending tasks.""" + try: + loop = asyncio.get_running_loop() + + # Schedule flush + complete as a single task to ensure ordering + async def flush_and_complete() -> None: + await _flush_pending() + await send("complete", {"data": data}) + + loop.create_task(flush_and_complete()) except RuntimeError: pass @@ -163,7 +192,7 @@ def _send_sync(event: str, data: dict[str, Any]) -> None: on_tool_call_delta=lambda n, a: _send_sync("tool_delta", {"name": n, "args": a}), on_tool_execute=lambda i, n, a: _send_sync("tool_exec", {"id": i, "name": n, "args": a}), on_tool_result=lambda i, n, r: _send_sync("tool_result", {"id": i, "name": n, "result": r}), - on_complete=lambda data: _send_sync("complete", {"data": data}), + on_complete=_send_complete, ) diff --git a/test_server.py b/test_server.py index 6ce94e5..20feaab 100644 --- a/test_server.py +++ b/test_server.py @@ -1,12 +1,19 @@ import os from bond import BondAgent from bond.server import create_bond_server, ServerConfig +from bond.tools import BondToolDeps, github_toolset -# Check for API key +# Check for API keys if "OPENAI_API_KEY" not in os.environ: print("WARNING: OPENAI_API_KEY not found in environment variables.") print("The server may fail to generate responses.") +if "GITHUB_TOKEN" not in os.environ: + print("WARNING: GITHUB_TOKEN not found in environment variables.") + print("GitHub tools will not work without a token.") + print("Set it with: export GITHUB_TOKEN=ghp_...") + + def list_files(directory: str = ".") -> str: """List files in the given directory.""" try: @@ -15,21 +22,48 @@ def list_files(directory: str = ".") -> str: except Exception as e: return f"Error: {e}" + +# Create composite deps for GitHub tools +deps = BondToolDeps(github_token=os.environ.get("GITHUB_TOKEN")) + agent = BondAgent( name="test-assistant", - instructions="You are a helpful test assistant. You can list files using the list_files tool.", + instructions="""You are a helpful assistant with access to GitHub. + +You can: +- Browse any GitHub repository (list files, read files, get repo info) +- Search code in repositories +- View commits and pull requests +- List local files using the list_files tool + +When asked about GitHub repositories, use the github tools to fetch real data. + +IMPORTANT: If a tool returns a rate limit error, do NOT retry. Just explain to the user that you hit the rate limit and they should try again later.""", model="openai:gpt-4o-mini", - toolsets=[[list_files]], + toolsets=[[list_files], github_toolset], + deps=deps, + max_retries=1, # Reduce retries to prevent rate limit loops ) # Configure server with explicit CORS origins +# Include multiple Vite ports since it auto-increments when ports are busy config = ServerConfig( cors_origins=[ - "http://localhost:5173", + "http://localhost:5173", + "http://localhost:5174", + "http://localhost:5175", + "http://localhost:5176", + "http://localhost:5177", + "http://localhost:5178", + "http://localhost:5179", "http://127.0.0.1:5173", + "http://127.0.0.1:5174", + "http://127.0.0.1:5175", + "http://127.0.0.1:5176", + "http://127.0.0.1:5177", + "http://127.0.0.1:5178", + "http://127.0.0.1:5179", "http://localhost:8000", - "http://localhost:5175", - "http://127.0.0.1:5175" ] ) @@ -37,6 +71,8 @@ def list_files(directory: str = ".") -> str: if __name__ == "__main__": import uvicorn + print("Starting Bond Test Server on http://0.0.0.0:8000") + print(f"GitHub Token: {'configured' if os.environ.get('GITHUB_TOKEN') else 'NOT SET'}") print(f"Allowed Origins: {config.cors_origins}") - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/tests/unit/server/test_session.py b/tests/unit/server/test_session.py index 11c6bc3..4e4bd67 100644 --- a/tests/unit/server/test_session.py +++ b/tests/unit/server/test_session.py @@ -109,9 +109,7 @@ async def test_update_status_with_error(self) -> None: manager = SessionManager() session = await manager.create_session("test") - await manager.update_status( - session.session_id, SessionStatus.ERROR, "Something went wrong" - ) + await manager.update_status(session.session_id, SessionStatus.ERROR, "Something went wrong") updated = await manager.get_session(session.session_id) assert updated is not None diff --git a/ui/src/bond/useBondServer.ts b/ui/src/bond/useBondServer.ts index 01e9cce..2df77b9 100644 --- a/ui/src/bond/useBondServer.ts +++ b/ui/src/bond/useBondServer.ts @@ -138,11 +138,8 @@ export function useBondServer(serverUrl: string | null): BondServerControls { try { console.log(`BondServer: Received ${eventType}`, e.data) const data = JSON.parse(e.data) - const bondEvent = normalizeSSEEvent(eventType, data) - if (bondEvent) { - processEvent(bondEvent) - } - // On complete, close the stream + + // On complete, handle non-streaming fallback BEFORE processing if (eventType === "complete") { const d = data as { data: string } // If we only got a complete event (no streaming), treat it as a text response @@ -154,6 +151,12 @@ export function useBondServer(serverUrl: string | null): BondServerControls { } es.close() setStatus("live") + return + } + + const bondEvent = normalizeSSEEvent(eventType, data) + if (bondEvent) { + processEvent(bondEvent) } } catch (err) { console.warn(`Failed to parse SSE event: ${eventType}`, err) From f5c50ab23a75c1874ca2fc86d6e1f3648554358c Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 25 Jan 2026 00:23:42 +0000 Subject: [PATCH 21/23] chore: update pre-commit to auto-fix instead of just check - ruff format: now auto-formats instead of --check - ruff lint: now uses --fix to auto-fix fixable issues - mypy: unchanged (cannot auto-fix type errors) Co-Authored-By: Claude Opus 4.5 --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bd9710d..3670077 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,25 +5,25 @@ repos: - repo: local hooks: - # Ruff format check (matches CI: uv run ruff format --check src tests) + # Ruff format - auto-fixes formatting issues - id: ruff-format name: ruff format - entry: uv run ruff format --check + entry: uv run ruff format language: system types: [python] pass_filenames: false args: [src, tests] - # Ruff lint (matches CI: uv run ruff check src tests) + # Ruff lint - auto-fixes what it can, fails on unfixable issues - id: ruff-lint name: ruff lint - entry: uv run ruff check + entry: uv run ruff check --fix language: system types: [python] pass_filenames: false args: [src, tests] - # Mypy type check (matches CI: uv run mypy src/bond) + # Mypy type check (cannot auto-fix, but necessary for safety) - id: mypy name: mypy entry: uv run mypy From 2d08a19a93552f3e8f3d18cb62ca638503dbaf05 Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 25 Jan 2026 00:31:58 +0000 Subject: [PATCH 22/23] docs: add missing server and GitHub API reference docs Fix mkdocs --strict warnings: - Create docs/api/server.md for server module API reference - Add GitHub Toolset section to docs/api/tools.md - Add Server to mkdocs.yml navigation Co-Authored-By: Claude Opus 4.5 --- docs/api/server.md | 61 +++++++++++++++++++++++++++++++++ docs/api/tools.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 146 insertions(+) create mode 100644 docs/api/server.md diff --git a/docs/api/server.md b/docs/api/server.md new file mode 100644 index 0000000..5acf44e --- /dev/null +++ b/docs/api/server.md @@ -0,0 +1,61 @@ +# Server Module + +The Bond server module provides production-ready SSE and WebSocket streaming for any Bond agent. + +## Factory Function + +::: bond.server.create_bond_server + options: + show_source: false + +--- + +## Configuration + +::: bond.server.ServerConfig + options: + show_source: true + +--- + +## Request/Response Types + +### AskRequest + +::: bond.server.AskRequest + options: + show_source: true + +### SessionResponse + +::: bond.server.SessionResponse + options: + show_source: true + +### HealthResponse + +::: bond.server.HealthResponse + options: + show_source: true + +--- + +## Session Management + +### Session + +::: bond.server.Session + options: + show_source: true + +### SessionStatus + +::: bond.server.SessionStatus + options: + show_source: true + +### SessionManager + +::: bond.server.SessionManager + options: + show_source: false diff --git a/docs/api/tools.md b/docs/api/tools.md index 4046974..1a7a5ca 100644 --- a/docs/api/tools.md +++ b/docs/api/tools.md @@ -78,6 +78,90 @@ The schema toolset provides database schema lookup capabilities. --- +## GitHub Toolset + +The GitHub toolset provides tools to browse and analyze any GitHub repository. + +### Protocol + +::: bond.tools.github.GitHubProtocol + options: + show_source: true + +### Adapter + +::: bond.tools.github.GitHubAdapter + options: + show_source: false + +### Types + +::: bond.tools.github.RepoInfo + options: + show_source: true + +::: bond.tools.github.TreeEntry + options: + show_source: true + +::: bond.tools.github.FileContent + options: + show_source: true + +::: bond.tools.github.CodeSearchResult + options: + show_source: true + +::: bond.tools.github.Commit + options: + show_source: true + +::: bond.tools.github.CommitAuthor + options: + show_source: true + +::: bond.tools.github.PullRequest + options: + show_source: true + +::: bond.tools.github.PullRequestUser + options: + show_source: true + +### Exceptions + +::: bond.tools.github.GitHubError + options: + show_source: true + +::: bond.tools.github.RepoNotFoundError + options: + show_source: true + +::: bond.tools.github.FileNotFoundError + options: + show_source: true + +::: bond.tools.github.PRNotFoundError + options: + show_source: true + +::: bond.tools.github.RateLimitedError + options: + show_source: true + +::: bond.tools.github.AuthenticationError + options: + show_source: true + +### Toolset + +::: bond.tools.github.github_toolset + options: + show_source: false + +--- + ## GitHunter Toolset The GitHunter toolset provides forensic code ownership analysis tools. diff --git a/mkdocs.yml b/mkdocs.yml index f2cc224..436f353 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,6 +104,7 @@ nav: - API Reference: - Overview: api/index.md - Agent: api/agent.md + - Server: api/server.md - Utilities: api/utils.md - Tools: api/tools.md - Trace: api/trace.md From ffa7445ffc64aff20e40bacff85115e9d8333ada Mon Sep 17 00:00:00 2001 From: bordumb Date: Sun, 25 Jan 2026 01:45:11 +0000 Subject: [PATCH 23/23] chore: config changes --- .gitignore | 3 +++ pyproject.toml | 2 +- uv.lock | 14 ++++++++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index b7faf40..c0c0025 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,6 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# project +all_code_and_configs.txt diff --git a/pyproject.toml b/pyproject.toml index 258dca7..3b1d6c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bond-agent" -version = "0.1.1" +version = "0.1.2" description = "The Forensic Runtime for AI agents - full-spectrum streaming with complete observability" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 4dd7461..b173b7f 100644 --- a/uv.lock +++ b/uv.lock @@ -324,11 +324,12 @@ wheels = [ [[package]] name = "bond-agent" -version = "0.1.0" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "aiofiles" }, { name = "asyncpg" }, + { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-ai" }, { name = "qdrant-client" }, @@ -352,6 +353,11 @@ docs = [ { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, ] +server = [ + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "uvicorn" }, +] [package.dev-dependencies] dev = [ @@ -368,6 +374,7 @@ dev = [ requires-dist = [ { name = "aiofiles", specifier = ">=24.0.0" }, { name = "asyncpg", specifier = ">=0.29.0" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6.0" }, { name = "mkdocs-autorefs", marker = "extra == 'docs'", specifier = ">=0.5.0" }, { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.0" }, @@ -383,9 +390,12 @@ 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 = "sse-starlette", marker = "extra == 'server'", specifier = ">=2.0.0" }, + { name = "starlette", marker = "extra == 'server'", specifier = ">=0.40.0" }, { name = "types-aiofiles", marker = "extra == 'dev'", specifier = ">=24.0.0" }, + { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.30.0" }, ] -provides-extras = ["dev", "docs"] +provides-extras = ["dev", "docs", "server"] [package.metadata.requires-dev] dev = [