From 9b7853c2ec5ef95518616d97203af87cf677165c Mon Sep 17 00:00:00 2001 From: Rohan Awhad Date: Sat, 30 May 2026 23:17:35 -0400 Subject: [PATCH 01/17] feat: port BuzzLLM from Python to Rust rust/ - Cargo.toml with all deps (clap, reqwest, tokio, bollard, scraper, etc.) - src/types.rs: LlmOptions, RequestArgs, StreamResponse, ToolCallData - src/providers/: 4 providers (openai-chat, openai-responses, anthropic, vertexai-anthropic) - src/llm.rs: invoke_llm loop with SSE streaming, tool execution, iteration cap - src/output.rs: print_to_stdout with ANSI colors, SSE mode, brief mode - src/tools/: Tool trait, ToolRegistry with cleanup, codesearch/websearch/pythonexec - src/prompts/: 6 prompt templates via include_str!, get_prompt() + prompt_names() - src/main.rs: clap CLI matching Python interface, logging to /tmp/buzzllm.logs - .gitignore for target/ .dingllm/prds/v2/ - 10 PRDs (000-009) documenting full migration plan AGENTS.md - Repo-specific agent instructions --- .dingllm/prds/v2/000_overview.md | 95 + .dingllm/prds/v2/001_core_types_and_cli.md | 132 + .dingllm/prds/v2/002_provider_system.md | 98 + .../prds/v2/003_streaming_and_llm_loop.md | 123 + .dingllm/prds/v2/004_tool_system.md | 157 + .dingllm/prds/v2/005_codesearch.md | 138 + .dingllm/prds/v2/006_websearch.md | 140 + .dingllm/prds/v2/007_pythonexec.md | 163 + .dingllm/prds/v2/008_prompts_and_logging.md | 191 ++ .dingllm/prds/v2/009_testing.md | 385 +++ AGENTS.md | 61 + rust/.gitignore | 1 + rust/Cargo.lock | 2843 +++++++++++++++++ rust/Cargo.toml | 26 + rust/src/llm.rs | 188 ++ rust/src/main.rs | 144 + rust/src/output.rs | 42 + rust/src/prompts/codesearch.txt | 49 + rust/src/prompts/generate.txt | 1 + rust/src/prompts/hackhub.txt | 196 ++ rust/src/prompts/helpful.txt | 1 + rust/src/prompts/mod.rs | 15 + rust/src/prompts/replace.txt | 1 + rust/src/prompts/websearch.txt | 3 + rust/src/providers/anthropic.rs | 156 + rust/src/providers/mod.rs | 71 + rust/src/providers/openai_chat.rs | 185 ++ rust/src/providers/openai_responses.rs | 75 + rust/src/providers/vertexai_anthropic.rs | 37 + rust/src/tools/codesearch.rs | 363 +++ rust/src/tools/mod.rs | 61 + rust/src/tools/pythonexec.rs | 261 ++ rust/src/tools/websearch.rs | 424 +++ rust/src/types.rs | 105 + 34 files changed, 6931 insertions(+) create mode 100644 .dingllm/prds/v2/000_overview.md create mode 100644 .dingllm/prds/v2/001_core_types_and_cli.md create mode 100644 .dingllm/prds/v2/002_provider_system.md create mode 100644 .dingllm/prds/v2/003_streaming_and_llm_loop.md create mode 100644 .dingllm/prds/v2/004_tool_system.md create mode 100644 .dingllm/prds/v2/005_codesearch.md create mode 100644 .dingllm/prds/v2/006_websearch.md create mode 100644 .dingllm/prds/v2/007_pythonexec.md create mode 100644 .dingllm/prds/v2/008_prompts_and_logging.md create mode 100644 .dingllm/prds/v2/009_testing.md create mode 100644 AGENTS.md create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/src/llm.rs create mode 100644 rust/src/main.rs create mode 100644 rust/src/output.rs create mode 100644 rust/src/prompts/codesearch.txt create mode 100644 rust/src/prompts/generate.txt create mode 100644 rust/src/prompts/hackhub.txt create mode 100644 rust/src/prompts/helpful.txt create mode 100644 rust/src/prompts/mod.rs create mode 100644 rust/src/prompts/replace.txt create mode 100644 rust/src/prompts/websearch.txt create mode 100644 rust/src/providers/anthropic.rs create mode 100644 rust/src/providers/mod.rs create mode 100644 rust/src/providers/openai_chat.rs create mode 100644 rust/src/providers/openai_responses.rs create mode 100644 rust/src/providers/vertexai_anthropic.rs create mode 100644 rust/src/tools/codesearch.rs create mode 100644 rust/src/tools/mod.rs create mode 100644 rust/src/tools/pythonexec.rs create mode 100644 rust/src/tools/websearch.rs create mode 100644 rust/src/types.rs diff --git a/.dingllm/prds/v2/000_overview.md b/.dingllm/prds/v2/000_overview.md new file mode 100644 index 0000000..b841797 --- /dev/null +++ b/.dingllm/prds/v2/000_overview.md @@ -0,0 +1,95 @@ +# BuzzLLM v2 — Python to Rust Migration + +## Goal + +Rewrite BuzzLLM from Python to Rust. Produce a single static binary (`buzzllm`) with identical CLI interface, identical provider support, and identical tool capabilities. The Rust code lives in `rust/` within this repo. + +## Current Python codebase (~1600 SLOC) + +``` +src/buzzllm/ + main.py 195 lines CLI + orchestration + llm.py 576 lines types, 4 providers, SSE parsing, invoke_llm loop + prompts/ ~255 lines 6 static prompt templates + tools/ + utils.py 136 lines tool registry + schema gen + websearch.py 235 lines DDG + Brave + crawl4ai scraping + codesearch.py 256 lines rg/find subprocess wrappers + pythonexec.py 176 lines Docker container + socket protocol +``` + +## Architecture (Rust) + +``` +rust/ + Cargo.toml + src/ + main.rs CLI (clap) + async main + types.rs LlmOptions, RequestArgs, StreamResponse, ToolCall + providers/ + mod.rs Provider trait + dispatch enum + openai_chat.rs request builder + SSE parser + message assembler + openai_responses.rs + anthropic.rs + vertexai_anthropic.rs + tools/ + mod.rs Tool trait + registry + schema.rs OpenAI/Anthropic JSON schema generation + codesearch.rs bash_find, bash_ripgrep, bash_read + websearch.rs search_web (DDG + Brave), scrape_webpage (chromiumoxide) + pythonexec.rs Docker container lifecycle + socket protocol + prompts/ + mod.rs prompt registry (HashMap or match) + *.txt prompt templates loaded via include_str! + output.rs print_to_stdout (ANSI, SSE, brief modes) +``` + +## Phases + +| Phase | PRD | Delivers | Depends on | +|-------|-----|----------|------------| +| 1 | [001_core_types_and_cli.md](001_core_types_and_cli.md) | Cargo project, all types, clap CLI, stubs | nothing | +| 2 | [002_provider_system.md](002_provider_system.md) | Provider trait, all 4 request builders + SSE parsers | Phase 1 | +| 3 | [003_streaming_and_llm_loop.md](003_streaming_and_llm_loop.md) | `invoke_llm` loop, reqwest streaming, stdout output | Phase 2 | +| 4 | [004_tool_system.md](004_tool_system.md) | Tool trait, registry, schema gen | Phase 1 | +| 5 | [005_codesearch.md](005_codesearch.md) | codesearch tools (rg, find, read) | Phase 4 | +| 6 | [006_websearch.md](006_websearch.md) | websearch tools (DDG, Brave, chromiumoxide) | Phase 4 | +| 7 | [007_pythonexec.md](007_pythonexec.md) | pythonexec tool (Docker + socket) | Phase 4 | +| 8 | [008_prompts_and_logging.md](008_prompts_and_logging.md) | Prompt templates, tracing, error handling, final wiring | Phase 3 + 4 | +| 9 | [009_testing.md](009_testing.md) | Test infrastructure, fixtures, unit/integration/e2e tests | All phases | + +Phases 5, 6, 7 are independent of each other (all depend on Phase 4 only). +Phase 9 (testing) is written alongside each phase — the PRD defines the full test matrix. + +## Crate dependencies + +| Crate | Purpose | +|-------|---------| +| `clap` (derive) | CLI arg parsing | +| `serde`, `serde_json` | JSON serialization for all types and API payloads | +| `reqwest` (stream feature) | HTTP client with SSE streaming | +| `tokio` (full) | async runtime | +| `futures` | stream combinators | +| `tracing`, `tracing-subscriber`, `tracing-appender` | logging to `/tmp/buzzllm.logs` | +| `anyhow` | error propagation | +| `bollard` | Docker Engine API (pythonexec) | +| `chromiumoxide` | headless Chrome for webpage scraping | +| `scraper` | HTML parsing (DuckDuckGo results) | +| `tokio-retry` | retry with exponential backoff (websearch) | + +## Acceptance criteria + +1. `cargo run -- --help` output matches Python `buzzllm -h` +2. All 4 providers stream responses identically (text, reasoning, tool calls) +3. `--system-prompt websearch|codesearch|pythonexec` activates correct tools +4. `--sse`, `--brief`, `--think` flags behave identically +5. Tool results feed back into conversation loop correctly +6. Logs write to `/tmp/buzzllm.logs` +7. Single binary, no Python runtime required + +## Non-goals + +- No new features beyond current Python parity +- No GUI or TUI +- No config file system (args-only, like current Python) +- No plugin system beyond the existing tool registration pattern diff --git a/.dingllm/prds/v2/001_core_types_and_cli.md b/.dingllm/prds/v2/001_core_types_and_cli.md new file mode 100644 index 0000000..d1de493 --- /dev/null +++ b/.dingllm/prds/v2/001_core_types_and_cli.md @@ -0,0 +1,132 @@ +# Phase 1: Core Types and CLI + +## Goal + +Set up the Cargo project in `rust/`, define all core data types, and implement the CLI argument parser. The binary should compile and print parsed args. + +## Source reference + +- `src/buzzllm/llm.py:15-68` — `LLMOptions`, `RequestArgs`, `StreamResponse`, `ToolCall` +- `src/buzzllm/main.py:26-62` — `parse_args()` + +## Deliverables + +### 1. Cargo project scaffold + +``` +rust/ + Cargo.toml + src/ + main.rs + types.rs +``` + +`Cargo.toml` dependencies for this phase: +- `clap` (derive feature) +- `serde`, `serde_json` +- `tokio` (full) + +### 2. Types (`types.rs`) + +Port these Python dataclasses to Rust structs with `serde::Serialize` + `Deserialize`: + +#### `LlmOptions` + +```rust +pub struct LlmOptions { + pub model: String, + pub url: String, + pub api_key_name: Option, + pub max_tokens: Option, // default 8192 + pub temperature: f64, // default 0.8 + pub think: bool, // default false + pub tools: Option>, + pub max_infer_iters: u32, // default 10 +} +``` + +#### `RequestArgs` + +```rust +pub struct RequestArgs { + pub data: serde_json::Value, // JSON body + pub headers: HashMap, +} +``` + +#### `StreamResponse` + +```rust +pub enum StreamResponseType { + ResponseStart, + OutputText, + ReasoningContent, + ToolCall, + ToolResult, + BlockEnd, + ResponseEnd, +} + +pub struct StreamResponse { + pub id: String, + pub delta: String, + pub response_type: StreamResponseType, +} +``` + +Must implement `to_json(&self) -> String` for SSE output mode. + +#### `ToolCall` + +```rust +pub struct ToolCall { + pub id: String, + pub name: String, + pub arguments: String, // raw JSON string, parsed at execution time + pub executed: bool, + pub result: Option, +} +``` + +### 3. CLI (`main.rs`) + +Use `clap` derive to mirror the exact Python interface: + +``` +buzzllm \ + --provider \ + --api-key-name \ + [--system-prompt ] \ + [--max-tokens ] \ + [--temperature ] \ + [--think] \ + [-S | --sse] \ + [-b | --brief] +``` + +Positional args: `model`, `url`, `prompt` (in that order). + +Defaults to match Python: +- `--system-prompt`: `"scream at mee for not setting your system prompt"` +- `--max-tokens`: `8192` +- `--temperature`: `0.8` + +Provider is required. `--api-key-name` is required. + +### 4. Stub main + +```rust +#[tokio::main] +async fn main() { + let args = Cli::parse(); + // TODO: wire to chat() + println!("parsed: {:?}", args); +} +``` + +## Verification + +- `cargo build` compiles with no errors +- `cargo run -- --help` prints usage matching Python's `buzzllm -h` +- `cargo run -- "gpt-4o-mini" "https://api.openai.com/v1/chat/completions" "hello" --provider openai-chat --api-key-name OPENAI_API_KEY` parses without error +- Unit test: construct each type, serialize to JSON, verify fields diff --git a/.dingllm/prds/v2/002_provider_system.md b/.dingllm/prds/v2/002_provider_system.md new file mode 100644 index 0000000..9d548f1 --- /dev/null +++ b/.dingllm/prds/v2/002_provider_system.md @@ -0,0 +1,98 @@ +# Phase 2: Provider System + +## Goal + +Implement all 4 LLM providers as pure functions: request builders, SSE line parsers, and tool-response message assemblers. No networking yet — this phase is fully unit-testable with captured SSE samples. + +## Source reference + +- `src/buzzllm/llm.py:204-236` — OpenAI Chat request builder +- `src/buzzllm/llm.py:239-309` — OpenAI Chat SSE parser +- `src/buzzllm/llm.py:312-335` — OpenAI Chat tool message assembler +- `src/buzzllm/llm.py:343-369` — Anthropic request builder +- `src/buzzllm/llm.py:372-438` — Anthropic SSE parser +- `src/buzzllm/llm.py:441-468` — Anthropic tool message assembler +- `src/buzzllm/llm.py:476-502` — OpenAI Responses request builder +- `src/buzzllm/llm.py:505-538` — OpenAI Responses SSE parser +- `src/buzzllm/llm.py:554-576` — VertexAI Anthropic request builder + +## Deliverables + +### File structure + +``` +rust/src/providers/ + mod.rs Provider enum + dispatch + openai_chat.rs + openai_responses.rs + anthropic.rs + vertexai_anthropic.rs +``` + +### Provider trait (or function triple) + +Each provider must supply 3 functions. Use an enum with methods rather than a trait (simpler, no dyn dispatch needed): + +```rust +pub enum Provider { + OpenaiChat, + OpenaiResponses, + Anthropic, + VertexaiAnthropic, +} + +impl Provider { + pub fn make_request_args(&self, opts: &LlmOptions, prompt: &str, system_prompt: &str) -> RequestArgs; + pub fn parse_sse_line(&self, line: &str, message_started: bool) -> Vec; + pub fn assemble_tool_messages(&self, messages: &mut Vec, tool_calls: &HashMap); +} +``` + +### Provider-specific details to preserve + +#### OpenAI Chat +- Reasoning models list: `gpt-5.2, gpt-5.1, gpt-5, gpt-5-mini, o4-mini, o3, o3-pro, gpt-5-pro` + any model starting with `gpt-5` +- For reasoning models: system role becomes `developer`, add `response_format: {type: "text"}`, add `reasoning_effort` field +- `gpt-5.1` without `--think`: `reasoning_effort: "none"`, all others: `"high"` +- Non-reasoning models get `temperature` and `max_tokens` +- Auth header: `Authorization: Bearer ` +- SSE format: `data: {json}\n` lines, terminated by `data: [DONE]` +- Tool calls arrive incrementally: first chunk has `id` + `function.name`, subsequent chunks append to `function.arguments` +- Tracks `current_tool_call_id` across chunks + +#### Anthropic +- System prompt goes in top-level `system` field (not in messages) +- Think mode: `max_tokens: 32000`, `thinking: {type: "enabled", budget_tokens: 24000}` +- Auth headers: `x-api-key` + `anthropic-version: 2023-06-01` +- SSE format: `event:` lines (ignored) + `data:` lines with `type` field +- Tool calls: `content_block_start` with `type: "tool_use"` gives id+name, `content_block_delta` with `partial_json` appends arguments +- Tool results: assistant message has `content: [{type: "tool_use", ...}]`, user message has `content: [{type: "tool_result", ...}]` + +#### OpenAI Responses +- Uses `input` instead of `messages`, `instructions` instead of system message +- Always sends `reasoning: {effort: "high", summary: "detailed"}` and `store: false` +- Tools: `NotImplementedError` in Python — port this as returning an error +- SSE events: `response.created`, `response.output_text.delta`, `response.reasoning_summary_text.delta`, `response.completed` + +#### VertexAI Anthropic +- Same SSE parser and message assembler as Anthropic +- Different request builder: adds `anthropic_version: "vertex-2023-10-16"` +- Auth: calls `gcloud auth print-access-token` via subprocess +- Uses `Authorization: Bearer ` (not `x-api-key`) + +### Global state migration + +Python uses module-level `TOOL_CALLS: dict` and `current_tool_call_id: str`. In Rust: +- Pass `&mut HashMap` and `&mut String` (current_tool_call_id) into the SSE parser +- No global mutable state + +## Verification + +For each provider, create unit tests with real captured SSE lines: + +1. **Request builder test**: call `make_request_args()`, assert JSON body structure, headers, and conditional fields (think mode, reasoning models, tools) +2. **SSE parser test**: feed captured lines from a real API response, assert correct sequence of `StreamResponse` types and deltas +3. **Tool call accumulation test**: feed multi-chunk tool call SSE lines, assert `ToolCall` is assembled correctly with complete name + arguments +4. **Message assembler test**: given a populated `tool_calls` map, assert output messages match expected provider format + +Capture real SSE samples by running the Python version with `--sse` and saving output to `rust/tests/fixtures/`. diff --git a/.dingllm/prds/v2/003_streaming_and_llm_loop.md b/.dingllm/prds/v2/003_streaming_and_llm_loop.md new file mode 100644 index 0000000..45a9ddc --- /dev/null +++ b/.dingllm/prds/v2/003_streaming_and_llm_loop.md @@ -0,0 +1,123 @@ +# Phase 3: Streaming HTTP and invoke_llm Loop + +## Goal + +Implement the core LLM invocation loop: make a streaming HTTP request, parse SSE lines through the provider, execute tool calls, feed results back, repeat until done. Wire up stdout printing with ANSI colors, SSE mode, and brief mode. + +## Source reference + +- `src/buzzllm/llm.py:84-166` — `invoke_llm()` +- `src/buzzllm/llm.py:74-81` — `run_tools()` +- `src/buzzllm/llm.py:170-194` — `print_to_stdout()` +- `src/buzzllm/main.py:65-171` — `chat()` orchestrator + +## Deliverables + +### File structure + +``` +rust/src/ + llm.rs invoke_llm() + run_tools() + output.rs print_to_stdout() + main.rs chat() wiring (update from Phase 1) +``` + +### `invoke_llm()` loop + +Port the Python loop. Key behavior: + +``` +loop { + 1. POST request to opts.url with request_args (streaming) + 2. Read response line-by-line + 3. For each line: parse via provider.parse_sse_line() + 4. For each StreamResponse: print_to_stdout() + 5. After stream ends: if no tool_calls → return + 6. Execute all pending tool calls concurrently + 7. Print tool results + 8. Assemble tool messages via provider.assemble_tool_messages() + 9. Clear tool_calls, loop +} +``` + +Important details: +- HTTP timeout: 900 seconds (Python: `timeout=900`) +- Max iterations: `opts.max_infer_iters` (default 10) — Python doesn't enforce this currently but the field exists +- On HTTP error: log error body, raise/propagate +- On any exception: print error as `StreamResponse(type: BlockEnd)`, then print `ResponseEnd` +- Finally block: call `pythonexec::cleanup()` (ignore errors), print `ResponseEnd` + +### HTTP streaming with reqwest + +```rust +let response = client + .post(&opts.url) + .headers(request_args.headers) + .json(&request_args.data) + .timeout(Duration::from_secs(900)) + .send() + .await?; + +let mut stream = response.bytes_stream(); +let mut buffer = String::new(); + +// Process byte chunks into lines (split on \n) +// Feed complete lines to provider.parse_sse_line() +``` + +Handle partial lines: buffer bytes until `\n` is seen, then process the complete line. + +### `run_tools()` + +Execute all unexecuted tool calls concurrently using `tokio::join!` or `futures::future::join_all`: + +```rust +async fn run_tools( + tool_calls: &mut HashMap, + registry: &ToolRegistry, +) { + let futures: Vec<_> = tool_calls.values_mut() + .filter(|tc| !tc.executed) + .map(|tc| tc.execute(registry)) + .collect(); + futures::future::join_all(futures).await; +} +``` + +### `print_to_stdout()` (`output.rs`) + +Three modes, checked in order: + +1. **Brief mode** (`--brief`): skip `ToolCall` and `ToolResult` types entirely +2. **SSE mode** (`-S`): print `event: {type}\ndata: {json}\n\n` +3. **Default**: ANSI-colored output + - `ToolCall` → cyan (`\x1b[96m`) + - `ToolResult` → green (`\x1b[92m`) + - `ReasoningContent` → yellow (`\x1b[93m`) + - `BlockEnd` → newline + - `ResponseEnd` → `\n\n=== [ DONE ] ===` + - `OutputText` → raw text, no color + +All prints use `flush=true` equivalent (`stdout().flush()` or `print!` with explicit flush). + +### `chat()` wiring in `main.rs` + +```rust +async fn chat(args: Cli) { + let provider = Provider::from_str(&args.provider); + let system_prompt = resolve_prompt(&args.system_prompt); // lookup or passthrough + let tools = register_tools(&args.system_prompt, &provider); // Phase 4+ stub: returns None + + let opts = LlmOptions { ... }; + invoke_llm(opts, &args.prompt, &system_prompt, provider, tools).await; +} +``` + +## Verification + +1. **No-tools streaming**: `cargo run -- "gpt-4o-mini" "https://api.openai.com/v1/chat/completions" "say hello" --provider openai-chat --api-key-name OPENAI_API_KEY` — streams colored text, ends with `=== [ DONE ] ===` +2. **SSE mode**: same with `-S` — outputs `event:/data:` format +3. **Brief mode**: same with `-b` — identical to default for no-tool calls +4. **Anthropic provider**: test with Claude model +5. **Think mode**: `--think` with Anthropic — yellow reasoning content appears +6. **Error handling**: use a bad URL — prints error and `=== [ DONE ] ===` diff --git a/.dingllm/prds/v2/004_tool_system.md b/.dingllm/prds/v2/004_tool_system.md new file mode 100644 index 0000000..5a85bd1 --- /dev/null +++ b/.dingllm/prds/v2/004_tool_system.md @@ -0,0 +1,157 @@ +# Phase 4: Tool System + +## Goal + +Define the `Tool` trait, tool registry, and JSON schema generation for both OpenAI and Anthropic formats. This phase provides the infrastructure that Phases 5-7 plug into. + +## Source reference + +- `src/buzzllm/tools/utils.py:1-8` — `AVAILABLE_TOOLS` dict, `add_tool()` +- `src/buzzllm/tools/utils.py:11-42` — `callable_to_openai_schema()` +- `src/buzzllm/tools/utils.py:45-73` — `callable_to_anthropic_schema()` +- `src/buzzllm/tools/utils.py:76-136` — `_python_type_to_json_schema()` +- `src/buzzllm/main.py:120-147` — tool registration per system-prompt +- `src/buzzllm/llm.py:60-67` — `ToolCall.execute()` + +## Deliverables + +### File structure + +``` +rust/src/tools/ + mod.rs Tool trait + ToolRegistry + schema.rs schema generation (not needed if schema is per-tool) +``` + +### Tool trait + +```rust +#[async_trait] +pub trait Tool: Send + Sync { + /// Tool name as it appears in the API schema + fn name(&self) -> &str; + + /// JSON schema for this tool in OpenAI function-calling format + fn openai_schema(&self) -> serde_json::Value; + + /// JSON schema for this tool in Anthropic tool format + fn anthropic_schema(&self) -> serde_json::Value; + + /// Execute the tool with JSON arguments, return JSON result + async fn execute(&self, args: serde_json::Value) -> serde_json::Value; +} +``` + +### Why not reflection-based schema gen + +Python generates schemas from docstrings + type hints at runtime. Rust has no runtime reflection. Two options: + +1. **Hardcode schemas per tool** — each tool struct returns its schema as a `serde_json::json!()` literal +2. **Derive macro** — overkill for 6 tools + +Use option 1. Each tool implementation defines its own schema. This is explicit and avoids macro complexity. + +### ToolRegistry + +```rust +pub struct ToolRegistry { + tools: HashMap>, +} + +impl ToolRegistry { + pub fn new() -> Self; + pub fn register(&mut self, tool: Box); + pub fn get(&self, name: &str) -> Option<&dyn Tool>; + + /// Get schemas for all registered tools in the provider's format + pub fn openai_schemas(&self) -> Vec; + pub fn anthropic_schemas(&self) -> Vec; +} +``` + +### Tool registration dispatch + +In `main.rs` `chat()`, mirror the Python pattern: + +```rust +let mut registry = ToolRegistry::new(); +let tools = match system_prompt_name { + "websearch" => { + registry.register(Box::new(SearchWeb)); + registry.register(Box::new(ScrapeWebpage)); + Some(match provider { + Provider::OpenaiChat | Provider::OpenaiResponses => registry.openai_schemas(), + Provider::Anthropic | Provider::VertexaiAnthropic => registry.anthropic_schemas(), + }) + } + "codesearch" => { + registry.register(Box::new(BashFind)); + registry.register(Box::new(BashRipgrep)); + registry.register(Box::new(BashRead)); + // same schema dispatch + } + "pythonexec" => { + registry.register(Box::new(PythonExecute)); + // same + } + _ => None, +}; +``` + +### ToolCall execution + +Update `ToolCall` to work with the registry: + +```rust +impl ToolCall { + pub async fn execute(&mut self, registry: &ToolRegistry) -> Result<()> { + let tool = registry.get(&self.name) + .ok_or_else(|| anyhow!("unknown tool: {}", self.name))?; + let args: Value = serde_json::from_str(&self.arguments)?; + self.result = Some(tool.execute(args).await); + self.executed = true; + Ok(()) + } +} +``` + +### Schema format reference + +**OpenAI format** (from `callable_to_openai_schema`): +```json +{ + "type": "function", + "function": { + "name": "search_web", + "description": "...", + "parameters": { + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"] + } + } +} +``` + +**Anthropic format** (from `callable_to_anthropic_schema`): +```json +{ + "name": "search_web", + "description": "...", + "input_schema": { + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"] + } +} +``` + +Only difference: OpenAI wraps in `{type: "function", function: {...}}` and uses `parameters`. Anthropic uses `input_schema` at top level. + +## Verification + +1. Register a mock tool, get OpenAI schema, verify JSON structure +2. Register a mock tool, get Anthropic schema, verify JSON structure +3. Execute a mock tool with JSON args, verify result comes back +4. Registry lookup for unknown tool returns None +5. Integration: wire into `invoke_llm` from Phase 3, verify tool call → execute → result → feed back cycle works with a real LLM + a simple mock tool diff --git a/.dingllm/prds/v2/005_codesearch.md b/.dingllm/prds/v2/005_codesearch.md new file mode 100644 index 0000000..7b87527 --- /dev/null +++ b/.dingllm/prds/v2/005_codesearch.md @@ -0,0 +1,138 @@ +# Phase 5: Codesearch Tools + +## Goal + +Port the three codesearch tools (`bash_find`, `bash_ripgrep`, `bash_read`) to Rust. These run `rg` and `find` as subprocesses with pagination, path validation, and timeout. + +## Source reference + +- `src/buzzllm/tools/codesearch.py:6` — `CWD = Path.cwd().resolve()` +- `src/buzzllm/tools/codesearch.py:9-14` — `_validate_path()` +- `src/buzzllm/tools/codesearch.py:17-36` — `_paginate_results()` +- `src/buzzllm/tools/codesearch.py:125-179` — `bash_find()` +- `src/buzzllm/tools/codesearch.py:182-226` — `bash_ripgrep()` +- `src/buzzllm/tools/codesearch.py:229-256` — `bash_read()` + +## Tools to implement + +### Shared utilities + +#### Path validation +Resolve the path and confirm it's within `CWD`. Return an error if the path escapes CWD (no `..` traversal out of workspace). + +```rust +fn validate_path(path: &str, cwd: &Path) -> Result { + let resolved = cwd.join(path).canonicalize()?; + if !resolved.starts_with(cwd) { + return Err(anyhow!("path outside CWD not allowed: {}", resolved.display())); + } + Ok(resolved) +} +``` + +#### Pagination +Apply offset/limit to a `Vec` and return a JSON object: + +```json +{ + "results": ["line1", "line2"], + "total": 150, + "offset": 0, + "limit": 20, + "has_more": true, + "returned": 20 +} +``` + +`limit=0` means return all results from offset onward. + +### Tool 1: `BashFind` + +**Name**: `bash_find` + +**Parameters**: +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `path` | string | `"."` | Directory to search in | +| `name` | string | `""` | Glob pattern for filename filtering | +| `type_filter` | string | `""` | `"d"` for directories only | +| `extra_args` | string | `""` | Additional CLI args (space-split) | +| `limit` | integer | `20` | Max results (0 = all) | +| `offset` | integer | `0` | Skip N results | + +**Behavior**: +- Default: run `rg --files [path]` — fast, respects `.gitignore` +- If `name` is set: `rg --files --glob "{name}"` +- If `type_filter == "d"`: fall back to `find {path} -type d [-name {name}]` +- `extra_args`: split on space, append to command +- Timeout: 30 seconds +- Paginate results + +**Schema description**: Use the content from `bash_find_tool_desc` in the Python source (the multi-line string explaining rg --files usage). This becomes the `description` field in the tool schema. + +### Tool 2: `BashRipgrep` + +**Name**: `bash_ripgrep` + +**Parameters**: +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `pattern` | string | (required) | Regex pattern to search for | +| `files` | string | `"."` | Path to search in | +| `extra_args` | string | `""` | Additional CLI args | +| `limit` | integer | `20` | Max results (0 = all) | +| `offset` | integer | `0` | Skip N results | + +**Behavior**: +- Run `rg {pattern} [{files}]` +- `extra_args`: split on space, append +- Timeout: 30 seconds +- On returncode != 0: return `{"error": "No matches found or command failed: {stderr}"}` +- Paginate results + +**Schema description**: Use `bash_ripgrep_tool_desc` content. + +### Tool 3: `BashRead` + +**Name**: `bash_read` + +**Parameters**: +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `filepath` | string | (required) | Path to file to read | +| `limit` | integer | `0` | Max lines (0 = all) | +| `offset` | integer | `0` | Skip N lines | + +**Behavior**: +- Read file contents as UTF-8 +- Split into lines, paginate +- Return pagination object + `content` field (joined lines) + +```json +{ + "results": ["line1", "line2"], + "content": "line1\nline2", + "total": 50, + ... +} +``` + +### Implementation + +All three implement the `Tool` trait from Phase 4: +- `execute()` receives JSON args, deserializes into a params struct, runs the subprocess/reads the file, returns JSON result +- Use `tokio::process::Command` for async subprocess execution +- CWD is captured once at startup (or passed in) + +## Verification + +1. `bash_find` with no args lists files in CWD (matches `rg --files` output) +2. `bash_find` with `name: "*.rs"` filters correctly +3. `bash_find` with `type_filter: "d"` falls back to `find` +4. `bash_ripgrep` finds a known pattern in the repo +5. `bash_ripgrep` with no matches returns error object +6. `bash_read` reads a known file, returns correct line count and content +7. Path validation rejects `../../../etc/passwd` +8. Pagination: 100 results with limit=20, offset=40 returns items 40-59 with `has_more: true` +9. Timeout: a pathological command (if constructible) returns timeout error +10. End-to-end: `cargo run -- "gpt-4o-mini" ... --system-prompt codesearch` with a codesearch question diff --git a/.dingllm/prds/v2/006_websearch.md b/.dingllm/prds/v2/006_websearch.md new file mode 100644 index 0000000..b3ec98e --- /dev/null +++ b/.dingllm/prds/v2/006_websearch.md @@ -0,0 +1,140 @@ +# Phase 6: Websearch Tools + +## Goal + +Port the two websearch tools (`search_web`, `scrape_webpage`) to Rust. `search_web` uses DuckDuckGo HTML scraping with Brave API fallback. `scrape_webpage` uses headless Chrome (chromiumoxide) to render pages and extract markdown. + +## Source reference + +- `src/buzzllm/tools/websearch.py:42-49` — `WebSearchResults` model, `DuckDuckGoBotDetectedError` +- `src/buzzllm/tools/websearch.py:52-135` — `_search_duckduckgo()` +- `src/buzzllm/tools/websearch.py:138-170` — `_search_brave()` +- `src/buzzllm/tools/websearch.py:173-197` — `search_web()` (fallback logic) +- `src/buzzllm/tools/websearch.py:200-235` — `scrape_webpage()` + `_fetch_markdown()` + +## Tools to implement + +### Tool 1: `SearchWeb` + +**Name**: `search_web` + +**Parameters**: +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `query` | string | (required) | Search query string | + +**Returns**: JSON array of `{title, url, description}` objects. + +**Behavior — DuckDuckGo primary**: +1. GET `https://lite.duckduckgo.com/lite/` with `q={query}` +2. Headers: mimic Firefox browser (User-Agent, Accept, etc. — copy from Python source) +3. Bot detection: if status 202 or response contains `"anomaly.js"` → throw `DuckDuckGoBotDetected` error (do NOT retry DDG, fall through to Brave) +4. Parse HTML with `scraper` crate: + - Find last `` in page + - Iterate rows: look for cells matching `^\d+\.\s*$` pattern + - Extract title + URL from link in second cell + - Description from next row's second cell (skip if it looks like a URL) + - Collect up to 10 results +5. Retry: up to 3 attempts, exponential backoff (2s, 4s, 8s) — but NOT on bot detection + +**Behavior — Brave fallback**: +1. Triggered when DDG fails (bot detection or retry exhaustion) +2. GET `https://api.search.brave.com/res/v1/web/search` with `q={query}&count=10` +3. Header: `X-Subscription-Token` from `BRAVE_SEARCH_AI_API_KEY` env var +4. Parse JSON response: `response.web.results[]` → extract `title`, `url`, `description` +5. Retry: up to 3 attempts, exponential backoff + +**Fallback chain**: DDG → Brave → empty array + +### Tool 2: `ScrapeWebpage` + +**Name**: `scrape_webpage` + +**Parameters**: +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `url` | string | (required) | URL to scrape | + +**Returns**: String — markdown content of the page. + +**Behavior**: +1. Launch headless Chromium via `chromiumoxide` +2. Navigate to URL, wait for page load +3. Extract page content as text/HTML +4. Convert to markdown (strip nav, header, footer elements) +5. Retry: up to 5 attempts, exponential backoff (4s min, 10s max) +6. On failure: return `"Error scraping {url}: {error}"` + +**chromiumoxide implementation plan**: + +```rust +use chromiumoxide::browser::{Browser, BrowserConfig}; + +async fn fetch_markdown(url: &str) -> Result { + let (browser, mut handler) = Browser::launch( + BrowserConfig::builder() + .no_sandbox() + .build()? + ).await?; + + // Spawn browser event handler + tokio::spawn(async move { while let Some(_) = handler.next().await {} }); + + let page = browser.new_page(url).await?; + page.wait_for_navigation().await?; + + // Get page content + let html = page.content().await?; + + // Parse with scraper, strip nav/header/footer, convert to simple markdown + let markdown = html_to_markdown(&html); + + browser.close().await?; + Ok(markdown) +} +``` + +**HTML to markdown conversion**: +The Python version uses `crawl4ai`'s `PruningContentFilter` and `DefaultMarkdownGenerator`. For parity, implement a basic converter: +- Parse HTML with `scraper` +- Remove `