Merge remote-tracking branch 'origin/main' into fix/360-docker-resource-limits
This commit is contained in:
commit
dc86be4939
86 changed files with 14398 additions and 2608 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
/target
|
||||
firmware/*/target
|
||||
*.db
|
||||
*.db-journal
|
||||
.DS_Store
|
||||
.wt-pr37/
|
||||
docker-compose.override.yml
|
||||
.env
|
||||
|
|
|
|||
83
AGENTS.md
83
AGENTS.md
|
|
@ -24,6 +24,7 @@ Key extension points:
|
|||
- `src/memory/traits.rs` (`Memory`)
|
||||
- `src/observability/traits.rs` (`Observer`)
|
||||
- `src/runtime/traits.rs` (`RuntimeAdapter`)
|
||||
- `src/peripherals/traits.rs` (`Peripheral`) — hardware boards (STM32, RPi GPIO)
|
||||
|
||||
## 2) Deep Architecture Observations (Why This Protocol Exists)
|
||||
|
||||
|
|
@ -141,7 +142,8 @@ Required:
|
|||
- `src/providers/` — model providers and resilient wrapper
|
||||
- `src/channels/` — Telegram/Discord/Slack/etc channels
|
||||
- `src/tools/` — tool execution surface (shell, file, memory, browser)
|
||||
- `src/runtime/` — runtime adapters (currently native/docker)
|
||||
- `src/peripherals/` — hardware peripherals (STM32, RPi GPIO); see `docs/hardware-peripherals-design.md`
|
||||
- `src/runtime/` — runtime adapters (currently native)
|
||||
- `docs/` — architecture + process docs
|
||||
- `.github/` — CI, templates, automation workflows
|
||||
|
||||
|
|
@ -236,13 +238,14 @@ Use these rules to keep the trait/factory architecture stable under growth.
|
|||
- Validate and sanitize all inputs.
|
||||
- Return structured `ToolResult`; avoid panics in runtime path.
|
||||
|
||||
### 7.4 Memory / Runtime / Config Changes
|
||||
### 5.4 Adding a Peripheral
|
||||
|
||||
- Keep compatibility explicit (config defaults, migration impact, fallback behavior).
|
||||
- Add targeted tests for boundary conditions and unsupported values.
|
||||
- Avoid hidden side effects in startup path.
|
||||
- Implement `Peripheral` in `src/peripherals/`.
|
||||
- Peripherals expose `tools()` — each tool delegates to the hardware (GPIO, sensors, etc.).
|
||||
- Register board type in config schema if needed.
|
||||
- See `docs/hardware-peripherals-design.md` for protocol and firmware notes.
|
||||
|
||||
### 7.5 Security / Gateway / CI Changes
|
||||
### 5.5 Security / Runtime / Gateway Changes
|
||||
|
||||
- Include threat/risk notes and rollback strategy.
|
||||
- Add/update tests or validation evidence for failure modes and boundaries.
|
||||
|
|
@ -301,6 +304,74 @@ Treat privacy and neutrality as merge gates, not best-effort guidelines.
|
|||
- If reproducing external incidents, redact and anonymize all payloads before committing.
|
||||
- Before push, review `git diff --cached` specifically for accidental sensitive strings and identity leakage.
|
||||
|
||||
### 9.2 Superseded-PR Attribution (Required)
|
||||
|
||||
When a PR supersedes another contributor's PR and carries forward substantive code or design decisions, preserve authorship explicitly.
|
||||
|
||||
- In the integrating commit message, add one `Co-authored-by: Name <email>` trailer per superseded contributor whose work is materially incorporated.
|
||||
- Use a GitHub-recognized email (`<login@users.noreply.github.com>` or the contributor's verified commit email) so attribution is rendered correctly.
|
||||
- Keep trailers on their own lines after a blank line at commit-message end; never encode them as escaped `\\n` text.
|
||||
- In the PR body, list superseded PR links and briefly state what was incorporated from each.
|
||||
- If no actual code/design was incorporated (only inspiration), do not use `Co-authored-by`; give credit in PR notes instead.
|
||||
|
||||
### 9.3 Superseded-PR PR Template (Recommended)
|
||||
|
||||
When superseding multiple PRs, use a consistent title/body structure to reduce reviewer ambiguity.
|
||||
|
||||
- Recommended title format: `feat(<scope>): unify and supersede #<pr_a>, #<pr_b> [and #<pr_n>]`
|
||||
- If this is docs/chore/meta only, keep the same supersede suffix and use the appropriate conventional-commit type.
|
||||
- In the PR body, include the following template (fill placeholders, remove non-applicable lines):
|
||||
|
||||
```md
|
||||
## Supersedes
|
||||
- #<pr_a> by @<author_a>
|
||||
- #<pr_b> by @<author_b>
|
||||
- #<pr_n> by @<author_n>
|
||||
|
||||
## Integrated Scope
|
||||
- From #<pr_a>: <what was materially incorporated>
|
||||
- From #<pr_b>: <what was materially incorporated>
|
||||
- From #<pr_n>: <what was materially incorporated>
|
||||
|
||||
## Attribution
|
||||
- Co-authored-by trailers added for materially incorporated contributors: Yes/No
|
||||
- If No, explain why (for example: no direct code/design carry-over)
|
||||
|
||||
## Non-goals
|
||||
- <explicitly list what was not carried over>
|
||||
|
||||
## Risk and Rollback
|
||||
- Risk: <summary>
|
||||
- Rollback: <revert commit/PR strategy>
|
||||
```
|
||||
|
||||
### 9.4 Superseded-PR Commit Template (Recommended)
|
||||
|
||||
When a commit unifies or supersedes prior PR work, use a deterministic commit message layout so attribution is machine-parsed and reviewer-friendly.
|
||||
|
||||
- Keep one blank line between message sections, and exactly one blank line before trailer lines.
|
||||
- Keep each trailer on its own line; do not wrap, indent, or encode as escaped `\n` text.
|
||||
- Add one `Co-authored-by` trailer per materially incorporated contributor, using GitHub-recognized email.
|
||||
- If no direct code/design is carried over, omit `Co-authored-by` and explain attribution in the PR body instead.
|
||||
|
||||
```text
|
||||
feat(<scope>): unify and supersede #<pr_a>, #<pr_b> [and #<pr_n>]
|
||||
|
||||
<one-paragraph summary of integrated outcome>
|
||||
|
||||
Supersedes:
|
||||
- #<pr_a> by @<author_a>
|
||||
- #<pr_b> by @<author_b>
|
||||
- #<pr_n> by @<author_n>
|
||||
|
||||
Integrated scope:
|
||||
- <subsystem_or_feature_a>: from #<pr_x>
|
||||
- <subsystem_or_feature_b>: from #<pr_y>
|
||||
|
||||
Co-authored-by: <Name A> <login_a@users.noreply.github.com>
|
||||
Co-authored-by: <Name B> <login_b@users.noreply.github.com>
|
||||
```
|
||||
|
||||
Reference docs:
|
||||
|
||||
- `CONTRIBUTING.md`
|
||||
|
|
|
|||
1266
Cargo.lock
generated
1266
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
38
Cargo.toml
38
Cargo.toml
|
|
@ -95,22 +95,32 @@ http-body-util = "0.1"
|
|||
# OpenTelemetry — OTLP trace + metrics export
|
||||
opentelemetry = { version = "0.31", default-features = false, features = ["trace", "metrics"] }
|
||||
opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"] }
|
||||
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-blocking-client"] }
|
||||
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] }
|
||||
|
||||
# USB device enumeration (hardware discovery)
|
||||
nusb = { version = "0.2", default-features = false, optional = true }
|
||||
|
||||
# Serial port for peripheral communication (STM32, etc.)
|
||||
tokio-serial = { version = "5", default-features = false, optional = true }
|
||||
|
||||
# probe-rs for STM32/Nucleo memory read (Phase B)
|
||||
probe-rs = { version = "0.30", optional = true }
|
||||
|
||||
# PDF extraction for datasheet RAG (optional, enable with --features rag-pdf)
|
||||
pdf-extract = { version = "0.10", optional = true }
|
||||
|
||||
# Raspberry Pi GPIO (Linux/RPi only) — target-specific to avoid compile failure on macOS
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rppal = { version = "0.14", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
browser-native = ["dep:fantoccini"]
|
||||
|
||||
# Sandbox backends (platform-specific, opt-in)
|
||||
sandbox-landlock = ["landlock"] # Linux kernel LSM
|
||||
sandbox-bubblewrap = [] # User namespaces (Linux/macOS)
|
||||
|
||||
# Full security suite
|
||||
security-full = ["sandbox-landlock"]
|
||||
|
||||
[[bin]]
|
||||
name = "zeroclaw"
|
||||
path = "src/main.rs"
|
||||
default = ["hardware"]
|
||||
hardware = ["nusb", "tokio-serial"]
|
||||
peripheral-rpi = ["rppal"]
|
||||
# probe = probe-rs for Nucleo memory read (adds ~50 deps; optional)
|
||||
probe = ["dep:probe-rs"]
|
||||
# rag-pdf = PDF ingestion for datasheet RAG
|
||||
rag-pdf = ["dep:pdf-extract"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
|
|
|
|||
55
README.md
55
README.md
|
|
@ -67,8 +67,8 @@ ls -lh target/release/zeroclaw
|
|||
```bash
|
||||
git clone https://github.com/zeroclaw-labs/zeroclaw.git
|
||||
cd zeroclaw
|
||||
cargo build --release
|
||||
cargo install --path . --force
|
||||
cargo build --release --locked
|
||||
cargo install --path . --force --locked
|
||||
|
||||
# Quick setup (no prompts)
|
||||
zeroclaw onboard --api-key sk-... --provider openrouter
|
||||
|
|
@ -128,7 +128,7 @@ Every subsystem is a **trait** — swap implementations with a config change, ze
|
|||
|-----------|-------|------------|--------|
|
||||
| **AI Models** | `Provider` | 22+ providers (OpenRouter, Anthropic, OpenAI, Ollama, Venice, Groq, Mistral, xAI, DeepSeek, Together, Fireworks, Perplexity, Cohere, Bedrock, etc.) | `custom:https://your-api.com` — any OpenAI-compatible API |
|
||||
| **Channels** | `Channel` | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | Any messaging API |
|
||||
| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Markdown | Any persistence backend |
|
||||
| **Memory** | `Memory` | SQLite with hybrid search (FTS5 + vector cosine similarity), Lucid bridge (CLI sync + SQLite fallback), Markdown | Any persistence backend |
|
||||
| **Tools** | `Tool` | shell, file_read, file_write, memory_store, memory_recall, memory_forget, browser_open (Brave + allowlist), browser (agent-browser / rust-native), composio (optional) | Any capability |
|
||||
| **Observability** | `Observer` | Noop, Log, Multi | Prometheus, OTel |
|
||||
| **Runtime** | `RuntimeAdapter` | Native, Docker (sandboxed) | WASM (planned; unsupported kinds fail fast) |
|
||||
|
|
@ -164,11 +164,21 @@ The agent automatically recalls, saves, and manages memory via tools.
|
|||
|
||||
```toml
|
||||
[memory]
|
||||
backend = "sqlite" # "sqlite", "markdown", "none"
|
||||
backend = "sqlite" # "sqlite", "lucid", "markdown", "none"
|
||||
auto_save = true
|
||||
embedding_provider = "openai"
|
||||
vector_weight = 0.7
|
||||
keyword_weight = 0.3
|
||||
|
||||
# backend = "none" uses an explicit no-op memory backend (no persistence)
|
||||
|
||||
# Optional for backend = "lucid"
|
||||
# ZEROCLAW_LUCID_CMD=/usr/local/bin/lucid # default: lucid
|
||||
# ZEROCLAW_LUCID_BUDGET=200 # default: 200
|
||||
# ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD=3 # local hit count to skip external recall
|
||||
# ZEROCLAW_LUCID_RECALL_TIMEOUT_MS=120 # low-latency budget for lucid context recall
|
||||
# ZEROCLAW_LUCID_STORE_TIMEOUT_MS=800 # async sync timeout for lucid store
|
||||
# ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS=15000 # cooldown after lucid failure to avoid repeated slow attempts
|
||||
```
|
||||
|
||||
## Security
|
||||
|
|
@ -264,12 +274,14 @@ default_model = "anthropic/claude-sonnet-4-20250514"
|
|||
default_temperature = 0.7
|
||||
|
||||
[memory]
|
||||
backend = "sqlite" # "sqlite", "markdown", "none"
|
||||
backend = "sqlite" # "sqlite", "lucid", "markdown", "none"
|
||||
auto_save = true
|
||||
embedding_provider = "openai" # "openai", "noop"
|
||||
vector_weight = 0.7
|
||||
keyword_weight = 0.3
|
||||
|
||||
# backend = "none" disables persistent memory via no-op backend
|
||||
|
||||
[gateway]
|
||||
require_pairing = true # require pairing code on first connect
|
||||
allow_public_bind = false # refuse 0.0.0.0 without tunnel
|
||||
|
|
@ -305,15 +317,34 @@ encrypt = true # API keys encrypted with local key file
|
|||
[browser]
|
||||
enabled = false # opt-in browser_open + browser tools
|
||||
allowed_domains = ["docs.rs"] # required when browser is enabled
|
||||
backend = "agent_browser" # "agent_browser" (default), "rust_native", "auto"
|
||||
backend = "agent_browser" # "agent_browser" (default), "rust_native", "computer_use", "auto"
|
||||
native_headless = true # applies when backend uses rust-native
|
||||
native_webdriver_url = "http://127.0.0.1:9515" # WebDriver endpoint (chromedriver/selenium)
|
||||
# native_chrome_path = "/usr/bin/chromium" # optional explicit browser binary for driver
|
||||
|
||||
[browser.computer_use]
|
||||
endpoint = "http://127.0.0.1:8787/v1/actions" # computer-use sidecar HTTP endpoint
|
||||
timeout_ms = 15000 # per-action timeout
|
||||
allow_remote_endpoint = false # secure default: only private/localhost endpoint
|
||||
window_allowlist = [] # optional window title/process allowlist hints
|
||||
# api_key = "..." # optional bearer token for sidecar
|
||||
# max_coordinate_x = 3840 # optional coordinate guardrail
|
||||
# max_coordinate_y = 2160 # optional coordinate guardrail
|
||||
|
||||
# Rust-native backend build flag:
|
||||
# cargo build --release --features browser-native
|
||||
# Ensure a WebDriver server is running, e.g. chromedriver --port=9515
|
||||
|
||||
# Computer-use sidecar contract (MVP)
|
||||
# POST browser.computer_use.endpoint
|
||||
# Request: {
|
||||
# "action": "mouse_click",
|
||||
# "params": {"x": 640, "y": 360, "button": "left"},
|
||||
# "policy": {"allowed_domains": [...], "window_allowlist": [...], "max_coordinate_x": 3840, "max_coordinate_y": 2160},
|
||||
# "metadata": {"session_name": "...", "source": "zeroclaw.browser", "version": "..."}
|
||||
# }
|
||||
# Response: {"success": true, "data": {...}} or {"success": false, "error": "..."}
|
||||
|
||||
[composio]
|
||||
enabled = false # opt-in: 1000+ OAuth apps via composio.dev
|
||||
# api_key = "cmp_..." # optional: stored encrypted when [secrets].encrypt = true
|
||||
|
|
@ -443,6 +474,18 @@ A git hook runs `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo t
|
|||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
### Build troubleshooting (Linux OpenSSL errors)
|
||||
|
||||
If you see an `openssl-sys` build error, sync dependencies and rebuild with the repository lockfile:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
cargo build --release --locked
|
||||
cargo install --path . --force --locked
|
||||
```
|
||||
|
||||
ZeroClaw is configured to use `rustls` for HTTP/TLS dependencies; `--locked` keeps the transitive graph deterministic on fresh environments.
|
||||
|
||||
To skip the hook when you need a quick push during development:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
BIN
docs/Hardware_architecture.jpg
Normal file
BIN
docs/Hardware_architecture.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
116
docs/adding-boards-and-tools.md
Normal file
116
docs/adding-boards-and-tools.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Adding Boards and Tools — ZeroClaw Hardware Guide
|
||||
|
||||
This guide explains how to add new hardware boards and custom tools to ZeroClaw.
|
||||
|
||||
## Quick Start: Add a Board via CLI
|
||||
|
||||
```bash
|
||||
# Add a board (updates ~/.zeroclaw/config.toml)
|
||||
zeroclaw peripheral add nucleo-f401re /dev/ttyACM0
|
||||
zeroclaw peripheral add arduino-uno /dev/cu.usbmodem12345
|
||||
zeroclaw peripheral add rpi-gpio native # for Raspberry Pi GPIO (Linux)
|
||||
|
||||
# Restart daemon to apply
|
||||
zeroclaw daemon --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
## Supported Boards
|
||||
|
||||
| Board | Transport | Path Example |
|
||||
|-----------------|-----------|---------------------------|
|
||||
| nucleo-f401re | serial | /dev/ttyACM0, /dev/cu.usbmodem* |
|
||||
| arduino-uno | serial | /dev/ttyACM0, /dev/cu.usbmodem* |
|
||||
| arduino-uno-q | bridge | (Uno Q IP) |
|
||||
| rpi-gpio | native | native |
|
||||
| esp32 | serial | /dev/ttyUSB0 |
|
||||
|
||||
## Manual Config
|
||||
|
||||
Edit `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
datasheet_dir = "docs/datasheets" # optional: RAG for "turn on red led" → pin 13
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "arduino-uno"
|
||||
transport = "serial"
|
||||
path = "/dev/cu.usbmodem12345"
|
||||
baud = 115200
|
||||
```
|
||||
|
||||
## Adding a Datasheet (RAG)
|
||||
|
||||
Place `.md` or `.txt` files in `docs/datasheets/` (or your `datasheet_dir`). Name files by board: `nucleo-f401re.md`, `arduino-uno.md`.
|
||||
|
||||
### Pin Aliases (Recommended)
|
||||
|
||||
Add a `## Pin Aliases` section so the agent can map "red led" → pin 13:
|
||||
|
||||
```markdown
|
||||
# My Board
|
||||
|
||||
## Pin Aliases
|
||||
|
||||
| alias | pin |
|
||||
|-------------|-----|
|
||||
| red_led | 13 |
|
||||
| builtin_led | 13 |
|
||||
| user_led | 5 |
|
||||
```
|
||||
|
||||
Or use key-value format:
|
||||
|
||||
```markdown
|
||||
## Pin Aliases
|
||||
red_led: 13
|
||||
builtin_led: 13
|
||||
```
|
||||
|
||||
### PDF Datasheets
|
||||
|
||||
With the `rag-pdf` feature, ZeroClaw can index PDF files:
|
||||
|
||||
```bash
|
||||
cargo build --features hardware,rag-pdf
|
||||
```
|
||||
|
||||
Place PDFs in the datasheet directory. They are extracted and chunked for RAG.
|
||||
|
||||
## Adding a New Board Type
|
||||
|
||||
1. **Create a datasheet** — `docs/datasheets/my-board.md` with pin aliases and GPIO info.
|
||||
2. **Add to config** — `zeroclaw peripheral add my-board /dev/ttyUSB0`
|
||||
3. **Implement a peripheral** (optional) — For custom protocols, implement the `Peripheral` trait in `src/peripherals/` and register in `create_peripheral_tools`.
|
||||
|
||||
See `docs/hardware-peripherals-design.md` for the full design.
|
||||
|
||||
## Adding a Custom Tool
|
||||
|
||||
1. Implement the `Tool` trait in `src/tools/`.
|
||||
2. Register in `create_peripheral_tools` (for hardware tools) or the agent tool registry.
|
||||
3. Add a tool description to the agent's `tool_descs` in `src/agent/loop_.rs`.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `zeroclaw peripheral list` | List configured boards |
|
||||
| `zeroclaw peripheral add <board> <path>` | Add board (writes config) |
|
||||
| `zeroclaw peripheral flash` | Flash Arduino firmware |
|
||||
| `zeroclaw peripheral flash-nucleo` | Flash Nucleo firmware |
|
||||
| `zeroclaw hardware discover` | List USB devices |
|
||||
| `zeroclaw hardware info` | Chip info via probe-rs |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Serial port not found** — On macOS use `/dev/cu.usbmodem*`; on Linux use `/dev/ttyACM0` or `/dev/ttyUSB0`.
|
||||
- **Build with hardware** — `cargo build --features hardware`
|
||||
- **Probe-rs for Nucleo** — `cargo build --features hardware,probe`
|
||||
217
docs/arduino-uno-q-setup.md
Normal file
217
docs/arduino-uno-q-setup.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# ZeroClaw on Arduino Uno Q — Step-by-Step Guide
|
||||
|
||||
Run ZeroClaw on the Arduino Uno Q's Linux side. Telegram works over WiFi; GPIO control uses the Bridge (requires a minimal App Lab app).
|
||||
|
||||
---
|
||||
|
||||
## What's Included (No Code Changes Needed)
|
||||
|
||||
ZeroClaw includes everything needed for Arduino Uno Q. **Clone the repo and follow this guide — no patches or custom code required.**
|
||||
|
||||
| Component | Location | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| Bridge app | `firmware/zeroclaw-uno-q-bridge/` | MCU sketch + Python socket server (port 9999) for GPIO |
|
||||
| Bridge tools | `src/peripherals/uno_q_bridge.rs` | `gpio_read` / `gpio_write` tools that talk to the Bridge over TCP |
|
||||
| Setup command | `src/peripherals/uno_q_setup.rs` | `zeroclaw peripheral setup-uno-q` deploys the Bridge via scp + arduino-app-cli |
|
||||
| Config schema | `board = "arduino-uno-q"`, `transport = "bridge"` | Supported in `config.toml` |
|
||||
|
||||
Build with `--features hardware` (or the default features) to include Uno Q support.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Arduino Uno Q with WiFi configured
|
||||
- Arduino App Lab installed on your Mac (for initial setup and deployment)
|
||||
- API key for LLM (OpenRouter, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Initial Uno Q Setup (One-Time)
|
||||
|
||||
### 1.1 Configure Uno Q via App Lab
|
||||
|
||||
1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage on Linux).
|
||||
2. Connect Uno Q via USB, power it on.
|
||||
3. Open App Lab, connect to the board.
|
||||
4. Follow the setup wizard:
|
||||
- Set username and password (for SSH)
|
||||
- Configure WiFi (SSID, password)
|
||||
- Apply any firmware updates
|
||||
5. Note the IP address shown (e.g. `arduino@192.168.1.42`) or find it later via `ip addr show` in App Lab's terminal.
|
||||
|
||||
### 1.2 Verify SSH Access
|
||||
|
||||
```bash
|
||||
ssh arduino@<UNO_Q_IP>
|
||||
# Enter the password you set
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Install ZeroClaw on Uno Q
|
||||
|
||||
### Option A: Build on the Device (Simpler, ~20–40 min)
|
||||
|
||||
```bash
|
||||
# SSH into Uno Q
|
||||
ssh arduino@<UNO_Q_IP>
|
||||
|
||||
# Install Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source ~/.cargo/env
|
||||
|
||||
# Install build deps (Debian)
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libssl-dev
|
||||
|
||||
# Clone zeroclaw (or scp your project)
|
||||
git clone https://github.com/theonlyhennygod/zeroclaw.git
|
||||
cd zeroclaw
|
||||
|
||||
# Build (takes ~15–30 min on Uno Q)
|
||||
cargo build --release
|
||||
|
||||
# Install
|
||||
sudo cp target/release/zeroclaw /usr/local/bin/
|
||||
```
|
||||
|
||||
### Option B: Cross-Compile on Mac (Faster)
|
||||
|
||||
```bash
|
||||
# On your Mac — add aarch64 target
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
# Install cross-compiler (macOS; required for linking)
|
||||
brew tap messense/macos-cross-toolchains
|
||||
brew install aarch64-unknown-linux-gnu
|
||||
|
||||
# Build
|
||||
CC_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-gcc cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
# Copy to Uno Q
|
||||
scp target/aarch64-unknown-linux-gnu/release/zeroclaw arduino@<UNO_Q_IP>:~/
|
||||
ssh arduino@<UNO_Q_IP> "sudo mv ~/zeroclaw /usr/local/bin/"
|
||||
```
|
||||
|
||||
If cross-compile fails, use Option A and build on the device.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Configure ZeroClaw
|
||||
|
||||
### 3.1 Run Onboard (or Create Config Manually)
|
||||
|
||||
```bash
|
||||
ssh arduino@<UNO_Q_IP>
|
||||
|
||||
# Quick config
|
||||
zeroclaw onboard --api-key YOUR_OPENROUTER_KEY --provider openrouter
|
||||
|
||||
# Or create config manually
|
||||
mkdir -p ~/.zeroclaw/workspace
|
||||
nano ~/.zeroclaw/config.toml
|
||||
```
|
||||
|
||||
### 3.2 Minimal config.toml
|
||||
|
||||
```toml
|
||||
api_key = "YOUR_OPENROUTER_API_KEY"
|
||||
default_provider = "openrouter"
|
||||
default_model = "anthropic/claude-sonnet-4"
|
||||
|
||||
[peripherals]
|
||||
enabled = false
|
||||
# GPIO via Bridge requires Phase 4
|
||||
|
||||
[channels_config.telegram]
|
||||
bot_token = "YOUR_TELEGRAM_BOT_TOKEN"
|
||||
allowed_users = ["*"]
|
||||
|
||||
[gateway]
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
allow_public_bind = false
|
||||
|
||||
[agent]
|
||||
compact_context = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Run ZeroClaw Daemon
|
||||
|
||||
```bash
|
||||
ssh arduino@<UNO_Q_IP>
|
||||
|
||||
# Run daemon (Telegram polling works over WiFi)
|
||||
zeroclaw daemon --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
**At this point:** Telegram chat works. Send messages to your bot — ZeroClaw responds. No GPIO yet.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: GPIO via Bridge (ZeroClaw Handles It)
|
||||
|
||||
ZeroClaw includes the Bridge app and setup command.
|
||||
|
||||
### 5.1 Deploy Bridge App
|
||||
|
||||
**From your Mac** (with zeroclaw repo):
|
||||
```bash
|
||||
zeroclaw peripheral setup-uno-q --host 192.168.0.48
|
||||
```
|
||||
|
||||
**From the Uno Q** (SSH'd in):
|
||||
```bash
|
||||
zeroclaw peripheral setup-uno-q
|
||||
```
|
||||
|
||||
This copies the Bridge app to `~/ArduinoApps/zeroclaw-uno-q-bridge` and starts it.
|
||||
|
||||
### 5.2 Add to config.toml
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "arduino-uno-q"
|
||||
transport = "bridge"
|
||||
```
|
||||
|
||||
### 5.3 Run ZeroClaw
|
||||
|
||||
```bash
|
||||
zeroclaw daemon --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
Now when you message your Telegram bot *"Turn on the LED"* or *"Set pin 13 high"*, ZeroClaw uses `gpio_write` via the Bridge.
|
||||
|
||||
---
|
||||
|
||||
## Summary: Commands Start to End
|
||||
|
||||
| Step | Command |
|
||||
|------|---------|
|
||||
| 1 | Configure Uno Q in App Lab (WiFi, SSH) |
|
||||
| 2 | `ssh arduino@<IP>` |
|
||||
| 3 | `curl -sSf https://sh.rustup.rs \| sh -s -- -y && source ~/.cargo/env` |
|
||||
| 4 | `sudo apt-get install -y pkg-config libssl-dev` |
|
||||
| 5 | `git clone https://github.com/theonlyhennygod/zeroclaw.git && cd zeroclaw` |
|
||||
| 6 | `cargo build --release --no-default-features` |
|
||||
| 7 | `zeroclaw onboard --api-key KEY --provider openrouter` |
|
||||
| 8 | Edit `~/.zeroclaw/config.toml` (add Telegram bot_token) |
|
||||
| 9 | `zeroclaw daemon --host 127.0.0.1 --port 8080` |
|
||||
| 10 | Message your Telegram bot — it responds |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"command not found: zeroclaw"** — Use full path: `/usr/local/bin/zeroclaw` or ensure `~/.cargo/bin` is in PATH.
|
||||
- **Telegram not responding** — Check bot_token, allowed_users, and that the Uno Q has internet (WiFi).
|
||||
- **Out of memory** — Use `--no-default-features` to reduce binary size; consider `compact_context = true`.
|
||||
- **GPIO commands ignored** — Ensure Bridge app is running (`zeroclaw peripheral setup-uno-q` deploys and starts it). Config must have `board = "arduino-uno-q"` and `transport = "bridge"`.
|
||||
- **LLM provider (GLM/Zhipu)** — Use `default_provider = "glm"` or `"zhipu"` with `GLM_API_KEY` in env or config. ZeroClaw uses the correct v4 endpoint.
|
||||
37
docs/datasheets/arduino-uno.md
Normal file
37
docs/datasheets/arduino-uno.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Arduino Uno
|
||||
|
||||
## Pin Aliases
|
||||
|
||||
| alias | pin |
|
||||
|-------------|-----|
|
||||
| red_led | 13 |
|
||||
| builtin_led | 13 |
|
||||
| user_led | 13 |
|
||||
|
||||
## Overview
|
||||
|
||||
Arduino Uno is a microcontroller board based on the ATmega328P. It has 14 digital I/O pins (0–13) and 6 analog inputs (A0–A5).
|
||||
|
||||
## Digital Pins
|
||||
|
||||
- **Pins 0–13:** Digital I/O. Can be INPUT or OUTPUT.
|
||||
- **Pin 13:** Built-in LED (onboard). Connect LED to GND or use for output.
|
||||
- **Pins 0–1:** Also used for Serial (RX/TX). Avoid if using Serial.
|
||||
|
||||
## GPIO
|
||||
|
||||
- `digitalWrite(pin, HIGH)` or `digitalWrite(pin, LOW)` for output.
|
||||
- `digitalRead(pin)` for input (returns 0 or 1).
|
||||
- Pin numbers in ZeroClaw protocol: 0–13.
|
||||
|
||||
## Serial
|
||||
|
||||
- UART on pins 0 (RX) and 1 (TX).
|
||||
- USB via ATmega16U2 or CH340 (clones).
|
||||
- Baud rate: 115200 for ZeroClaw firmware.
|
||||
|
||||
## ZeroClaw Tools
|
||||
|
||||
- `gpio_read`: Read pin value (0 or 1).
|
||||
- `gpio_write`: Set pin high (1) or low (0).
|
||||
- `arduino_upload`: Agent generates full Arduino sketch code; ZeroClaw compiles and uploads it via arduino-cli. Use for "make a heart", custom patterns — agent writes the code, no manual editing. Pin 13 = built-in LED.
|
||||
22
docs/datasheets/esp32.md
Normal file
22
docs/datasheets/esp32.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# ESP32 GPIO Reference
|
||||
|
||||
## Pin Aliases
|
||||
|
||||
| alias | pin |
|
||||
|-------------|-----|
|
||||
| builtin_led | 2 |
|
||||
| red_led | 2 |
|
||||
|
||||
## Common pins (ESP32 / ESP32-C3)
|
||||
|
||||
- **GPIO 2**: Built-in LED on many dev boards (output)
|
||||
- **GPIO 13**: General-purpose output
|
||||
- **GPIO 21/20**: Often used for UART0 TX/RX (avoid if using serial)
|
||||
|
||||
## Protocol
|
||||
|
||||
ZeroClaw host sends JSON over serial (115200 baud):
|
||||
- `gpio_read`: `{"id":"1","cmd":"gpio_read","args":{"pin":13}}`
|
||||
- `gpio_write`: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`
|
||||
|
||||
Response: `{"id":"1","ok":true,"result":"0"}` or `{"id":"1","ok":true,"result":"done"}`
|
||||
16
docs/datasheets/nucleo-f401re.md
Normal file
16
docs/datasheets/nucleo-f401re.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Nucleo-F401RE GPIO
|
||||
|
||||
## Pin Aliases
|
||||
|
||||
| alias | pin |
|
||||
|-------------|-----|
|
||||
| red_led | 13 |
|
||||
| user_led | 13 |
|
||||
| ld2 | 13 |
|
||||
| builtin_led | 13 |
|
||||
|
||||
## GPIO
|
||||
|
||||
Pin 13: User LED (LD2)
|
||||
- Output, active high
|
||||
- PA5 on STM32F401
|
||||
324
docs/hardware-peripherals-design.md
Normal file
324
docs/hardware-peripherals-design.md
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
# Hardware Peripherals Design — ZeroClaw
|
||||
|
||||
ZeroClaw enables microcontrollers (MCUs) and Single Board Computers (SBCs) to **dynamically interpret natural language commands**, generate hardware-specific code, and execute peripheral interactions in real-time.
|
||||
|
||||
## 1. Vision
|
||||
|
||||
**Goal:** ZeroClaw acts as a hardware-aware AI agent that:
|
||||
- Receives natural language triggers (e.g. "Move X arm", "Turn on LED") via channels (WhatsApp, Telegram)
|
||||
- Fetches accurate hardware documentation (datasheets, register maps)
|
||||
- Synthesizes Rust code/logic using an LLM (Gemini, local open-source models)
|
||||
- Executes the logic to manipulate peripherals (GPIO, I2C, SPI)
|
||||
- Persists optimized code for future reuse
|
||||
|
||||
**Mental model:** ZeroClaw = brain that understands hardware. Peripherals = arms and legs it controls.
|
||||
|
||||
## 2. Two Modes of Operation
|
||||
|
||||
### Mode 1: Edge-Native (Standalone)
|
||||
|
||||
**Target:** Wi-Fi-enabled boards (ESP32, Raspberry Pi).
|
||||
|
||||
ZeroClaw runs **directly on the device**. The board spins up a gRPC/nanoRPC server and communicates with peripherals locally.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ZeroClaw on ESP32 / Raspberry Pi (Edge-Native) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ Channels │───►│ Agent Loop │───►│ RAG: datasheets, register maps │ │
|
||||
│ │ WhatsApp │ │ (LLM calls) │ │ → LLM context │ │
|
||||
│ │ Telegram │ └──────┬───────┘ └─────────────────────────────────┘ │
|
||||
│ └─────────────┘ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ Code synthesis → Wasm / dynamic exec → GPIO / I2C / SPI → persist ││
|
||||
│ └─────────────────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ gRPC/nanoRPC server ◄──► Peripherals (GPIO, I2C, SPI, sensors, actuators) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
1. User sends WhatsApp: *"Turn on LED on pin 13"*
|
||||
2. ZeroClaw fetches board-specific docs (e.g. ESP32 GPIO mapping)
|
||||
3. LLM synthesizes Rust code
|
||||
4. Code runs in a sandbox (Wasm or dynamic linking)
|
||||
5. GPIO is toggled; result returned to user
|
||||
6. Optimized code is persisted for future "Turn on LED" requests
|
||||
|
||||
**All happens on-device.** No host required.
|
||||
|
||||
### Mode 2: Host-Mediated (Development / Debugging)
|
||||
|
||||
**Target:** Hardware connected via USB / J-Link / Aardvark to a host (macOS, Linux).
|
||||
|
||||
ZeroClaw runs on the **host** and maintains a hardware-aware link to the target. Used for development, introspection, and flashing.
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ ZeroClaw on Mac │ USB / J-Link / │ STM32 Nucleo-F401RE │
|
||||
│ │ Aardvark │ (or other MCU) │
|
||||
│ - Channels │ ◄────────────────► │ - Memory map │
|
||||
│ - LLM │ │ - Peripherals (GPIO, ADC, I2C) │
|
||||
│ - Hardware probe │ VID/PID │ - Flash / RAM │
|
||||
│ - Flash / debug │ discovery │ │
|
||||
└─────────────────────┘ └──────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
1. User sends Telegram: *"What are the readable memory addresses on this USB device?"*
|
||||
2. ZeroClaw identifies connected hardware (VID/PID, architecture)
|
||||
3. Performs memory mapping; suggests available address spaces
|
||||
4. Returns result to user
|
||||
|
||||
**Or:**
|
||||
1. User: *"Flash this firmware to the Nucleo"*
|
||||
2. ZeroClaw writes/flashes via OpenOCD or probe-rs
|
||||
3. Confirms success
|
||||
|
||||
**Or:**
|
||||
1. ZeroClaw auto-discovers: *"STM32 Nucleo on /dev/ttyACM0, ARM Cortex-M4"*
|
||||
2. Suggests: *"I can read/write GPIO, ADC, flash. What would you like to do?"*
|
||||
|
||||
---
|
||||
|
||||
### Mode Comparison
|
||||
|
||||
| Aspect | Edge-Native | Host-Mediated |
|
||||
|------------------|--------------------------------|----------------------------------|
|
||||
| ZeroClaw runs on | Device (ESP32, RPi) | Host (Mac, Linux) |
|
||||
| Hardware link | Local (GPIO, I2C, SPI) | USB, J-Link, Aardvark |
|
||||
| LLM | On-device or cloud (Gemini) | Host (cloud or local) |
|
||||
| Use case | Production, standalone | Dev, debug, introspection |
|
||||
| Channels | WhatsApp, etc. (via WiFi) | Telegram, CLI, etc. |
|
||||
|
||||
## 3. Legacy / Simpler Modes (Pre-LLM-on-Edge)
|
||||
|
||||
For boards without WiFi or before full Edge-Native is ready:
|
||||
|
||||
### Mode A: Host + Remote Peripheral (STM32 via serial)
|
||||
|
||||
Host runs ZeroClaw; peripheral runs minimal firmware. Simple JSON over serial.
|
||||
|
||||
### Mode B: RPi as Host (Native GPIO)
|
||||
|
||||
ZeroClaw on Pi; GPIO via rppal or sysfs. No separate firmware.
|
||||
|
||||
## 4. Technical Requirements
|
||||
|
||||
| Requirement | Description |
|
||||
|-------------|-------------|
|
||||
| **Language** | Pure Rust. `no_std` where applicable for embedded targets (STM32, ESP32). |
|
||||
| **Communication** | Lightweight gRPC or nanoRPC stack for low-latency command processing. |
|
||||
| **Dynamic execution** | Safely run LLM-generated logic on-the-fly: Wasm runtime for isolation, or dynamic linking where supported. |
|
||||
| **Documentation retrieval** | RAG (Retrieval-Augmented Generation) pipeline to feed datasheet snippets, register maps, and pinouts into LLM context. |
|
||||
| **Hardware discovery** | VID/PID-based identification for USB devices; architecture detection (ARM Cortex-M, RISC-V, etc.). |
|
||||
|
||||
### RAG Pipeline (Datasheet Retrieval)
|
||||
|
||||
- **Index:** Datasheets, reference manuals, register maps (PDF → chunks, embeddings).
|
||||
- **Retrieve:** On user query ("turn on LED"), fetch relevant snippets (e.g. GPIO section for target board).
|
||||
- **Inject:** Add to LLM system prompt or context.
|
||||
- **Result:** LLM generates accurate, board-specific code.
|
||||
|
||||
### Dynamic Execution Options
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|-------|------|------|
|
||||
| **Wasm** | Sandboxed, portable, no FFI | Overhead; limited HW access from Wasm |
|
||||
| **Dynamic linking** | Native speed, full HW access | Platform-specific; security concerns |
|
||||
| **Interpreted DSL** | Safe, auditable | Slower; limited expressiveness |
|
||||
| **Pre-compiled templates** | Fast, secure | Less flexible; requires template library |
|
||||
|
||||
**Recommendation:** Start with pre-compiled templates + parameterization; evolve to Wasm for user-defined logic once stable.
|
||||
|
||||
## 5. CLI and Config
|
||||
|
||||
### CLI Flags
|
||||
|
||||
```bash
|
||||
# Edge-Native: run on device (ESP32, RPi)
|
||||
zeroclaw agent --mode edge
|
||||
|
||||
# Host-Mediated: connect to USB/J-Link target
|
||||
zeroclaw agent --peripheral nucleo-f401re:/dev/ttyACM0
|
||||
zeroclaw agent --probe jlink
|
||||
|
||||
# Hardware introspection
|
||||
zeroclaw hardware discover
|
||||
zeroclaw hardware introspect /dev/ttyACM0
|
||||
```
|
||||
|
||||
### Config (config.toml)
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
mode = "host" # "edge" | "host"
|
||||
datasheet_dir = "docs/datasheets" # RAG: board-specific docs for LLM context
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "rpi-gpio"
|
||||
transport = "native"
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "esp32"
|
||||
transport = "wifi"
|
||||
# Edge-Native: ZeroClaw runs on ESP32
|
||||
```
|
||||
|
||||
## 6. Architecture: Peripheral as Extension Point
|
||||
|
||||
### New Trait: `Peripheral`
|
||||
|
||||
```rust
|
||||
/// A hardware peripheral that exposes capabilities as tools.
|
||||
#[async_trait]
|
||||
pub trait Peripheral: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn board_type(&self) -> &str; // e.g. "nucleo-f401re", "rpi-gpio"
|
||||
async fn connect(&mut self) -> anyhow::Result<()>;
|
||||
async fn disconnect(&mut self) -> anyhow::Result<()>;
|
||||
async fn health_check(&self) -> bool;
|
||||
/// Tools this peripheral provides (gpio_read, gpio_write, sensor_read, etc.)
|
||||
fn tools(&self) -> Vec<Box<dyn Tool>>;
|
||||
}
|
||||
```
|
||||
|
||||
### Flow
|
||||
|
||||
1. **Startup:** ZeroClaw loads config, sees `peripherals.boards`.
|
||||
2. **Connect:** For each board, create a `Peripheral` impl, call `connect()`.
|
||||
3. **Tools:** Collect tools from all connected peripherals; merge with default tools.
|
||||
4. **Agent loop:** Agent can call `gpio_write`, `sensor_read`, etc. — these delegate to the peripheral.
|
||||
5. **Shutdown:** Call `disconnect()` on each peripheral.
|
||||
|
||||
### Board Support
|
||||
|
||||
| Board | Transport | Firmware / Driver | Tools |
|
||||
|--------------------|-----------|------------------------|--------------------------|
|
||||
| nucleo-f401re | serial | Zephyr / Embassy | gpio_read, gpio_write, adc_read |
|
||||
| rpi-gpio | native | rppal or sysfs | gpio_read, gpio_write |
|
||||
| esp32 | serial/ws | ESP-IDF / Embassy | gpio, wifi, mqtt |
|
||||
|
||||
## 7. Communication Protocols
|
||||
|
||||
### gRPC / nanoRPC (Edge-Native, Host-Mediated)
|
||||
|
||||
For low-latency, typed RPC between ZeroClaw and peripherals:
|
||||
|
||||
- **nanoRPC** or **tonic** (gRPC): Protobuf-defined services.
|
||||
- Methods: `GpioWrite`, `GpioRead`, `I2cTransfer`, `SpiTransfer`, `MemoryRead`, `FlashWrite`, etc.
|
||||
- Enables streaming, bidirectional calls, and code generation from `.proto` files.
|
||||
|
||||
### Serial Fallback (Host-Mediated, legacy)
|
||||
|
||||
Simple JSON over serial for boards without gRPC support:
|
||||
|
||||
**Request (host → peripheral):**
|
||||
```json
|
||||
{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}
|
||||
```
|
||||
|
||||
**Response (peripheral → host):**
|
||||
```json
|
||||
{"id":"1","ok":true,"result":"done"}
|
||||
```
|
||||
|
||||
## 8. Firmware (Separate Repo or Crate)
|
||||
|
||||
- **zeroclaw-firmware** or **zeroclaw-peripheral** — a separate crate/workspace.
|
||||
- Targets: `thumbv7em-none-eabihf` (STM32), `armv7-unknown-linux-gnueabihf` (RPi), etc.
|
||||
- Uses `embassy` or Zephyr for STM32.
|
||||
- Implements the protocol above.
|
||||
- User flashes this to the board; ZeroClaw connects and discovers capabilities.
|
||||
|
||||
## 9. Implementation Phases
|
||||
|
||||
### Phase 1: Skeleton ✅ (Done)
|
||||
|
||||
- [x] Add `Peripheral` trait, config schema, CLI (`zeroclaw peripheral list/add`)
|
||||
- [x] Add `--peripheral` flag to agent
|
||||
- [x] Document in AGENTS.md
|
||||
|
||||
### Phase 2: Host-Mediated — Hardware Discovery ✅ (Done)
|
||||
|
||||
- [x] `zeroclaw hardware discover`: enumerate USB devices (VID/PID)
|
||||
- [x] Board registry: map VID/PID → architecture, name (e.g. Nucleo-F401RE)
|
||||
- [x] `zeroclaw hardware introspect <path>`: memory map, peripheral list
|
||||
|
||||
### Phase 3: Host-Mediated — Serial / J-Link
|
||||
|
||||
- [x] `SerialPeripheral` for STM32 over USB CDC
|
||||
- [ ] probe-rs or OpenOCD integration for flash/debug
|
||||
- [x] Tools: `gpio_read`, `gpio_write` (memory_read, flash_write in future)
|
||||
|
||||
### Phase 4: RAG Pipeline ✅ (Done)
|
||||
|
||||
- [x] Datasheet index (markdown/text → chunks)
|
||||
- [x] Retrieve-and-inject into LLM context on hardware-related queries
|
||||
- [x] Board-specific prompt augmentation
|
||||
|
||||
**Usage:** Add `datasheet_dir = "docs/datasheets"` to `[peripherals]` in config.toml. Place `.md` or `.txt` files named by board (e.g. `nucleo-f401re.md`, `rpi-gpio.md`). Files in `_generic/` or named `generic.md` apply to all boards. Chunks are retrieved by keyword match and injected into the user message context.
|
||||
|
||||
### Phase 5: Edge-Native — RPi ✅ (Done)
|
||||
|
||||
- [x] ZeroClaw on Raspberry Pi (native GPIO via rppal)
|
||||
- [ ] gRPC/nanoRPC server for local peripheral access
|
||||
- [ ] Code persistence (store synthesized snippets)
|
||||
|
||||
### Phase 6: Edge-Native — ESP32
|
||||
|
||||
- [x] Host-mediated ESP32 (serial transport) — same JSON protocol as STM32
|
||||
- [x] `zeroclaw-esp32` firmware crate (`firmware/zeroclaw-esp32`) — GPIO over UART
|
||||
- [x] ESP32 in hardware registry (CH340 VID/PID)
|
||||
- [ ] ZeroClaw *on* ESP32 (WiFi + LLM, edge-native) — future
|
||||
- [ ] Wasm or template-based execution for LLM-generated logic
|
||||
|
||||
**Usage:** Flash `firmware/zeroclaw-esp32` to ESP32, add `board = "esp32"`, `transport = "serial"`, `path = "/dev/ttyUSB0"` to config.
|
||||
|
||||
### Phase 7: Dynamic Execution (LLM-Generated Code)
|
||||
|
||||
- [ ] Template library: parameterized GPIO/I2C/SPI snippets
|
||||
- [ ] Optional: Wasm runtime for user-defined logic (sandboxed)
|
||||
- [ ] Persist and reuse optimized code paths
|
||||
|
||||
## 10. Security Considerations
|
||||
|
||||
- **Serial path:** Validate `path` is in allowlist (e.g. `/dev/ttyACM*`, `/dev/ttyUSB*`); never arbitrary paths.
|
||||
- **GPIO:** Restrict which pins are exposed; avoid power/reset pins.
|
||||
- **No secrets on peripheral:** Firmware should not store API keys; host handles auth.
|
||||
|
||||
## 11. Non-Goals (For Now)
|
||||
|
||||
- Running full ZeroClaw *on* bare STM32 (no WiFi, limited RAM) — use Host-Mediated instead
|
||||
- Real-time guarantees — peripherals are best-effort
|
||||
- Arbitrary native code execution from LLM — prefer Wasm or templates
|
||||
|
||||
## 12. Related Documents
|
||||
|
||||
- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — How to add boards and datasheets
|
||||
- [network-deployment.md](./network-deployment.md) — RPi and network deployment
|
||||
|
||||
## 13. References
|
||||
|
||||
- [Zephyr RTOS Rust support](https://docs.zephyrproject.org/latest/develop/languages/rust/index.html)
|
||||
- [Embassy](https://embassy.dev/) — async embedded framework
|
||||
- [rppal](https://github.com/golemparts/rppal) — Raspberry Pi GPIO in Rust
|
||||
- [STM32 Nucleo-F401RE](https://www.st.com/en/evaluation-tools/nucleo-f401re.html)
|
||||
- [tonic](https://github.com/hyperium/tonic) — gRPC for Rust
|
||||
- [probe-rs](https://probe.rs/) — ARM debug probe, flash, memory access
|
||||
- [nusb](https://github.com/nic-hartley/nusb) — USB device enumeration (VID/PID)
|
||||
|
||||
## 14. Raw Prompt Summary
|
||||
|
||||
> *"Boards like ESP, Raspberry Pi, or boards with WiFi can connect to an LLM (Gemini or open-source). ZeroClaw runs on the device, creates its own gRPC, spins it up, and communicates with peripherals. User asks via WhatsApp: 'move X arm' or 'turn on LED'. ZeroClaw gets accurate documentation, writes code, executes it, stores it optimally, runs it, and turns on the LED — all on the development board.*
|
||||
>
|
||||
> *For STM Nucleo connected via USB/J-Link/Aardvark to my Mac: ZeroClaw from my Mac accesses the hardware, installs or writes what it wants on the device, and returns the result. Example: 'Hey ZeroClaw, what are the available/readable addresses on this USB device?' It can figure out what's connected where and suggest."*
|
||||
182
docs/network-deployment.md
Normal file
182
docs/network-deployment.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# Network Deployment — ZeroClaw on Raspberry Pi and Local Network
|
||||
|
||||
This document covers deploying ZeroClaw on a Raspberry Pi or other host on your local network, with Telegram and optional webhook channels.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
| Mode | Inbound port needed? | Use case |
|
||||
|------|----------------------|----------|
|
||||
| **Telegram polling** | No | ZeroClaw polls Telegram API; works from anywhere |
|
||||
| **Discord/Slack** | No | Same — outbound only |
|
||||
| **Gateway webhook** | Yes | POST /webhook, WhatsApp, etc. need a public URL |
|
||||
| **Gateway pairing** | Yes | If you pair clients via the gateway |
|
||||
|
||||
**Key:** Telegram, Discord, and Slack use **long-polling** — ZeroClaw makes outbound requests. No port forwarding or public IP required.
|
||||
|
||||
---
|
||||
|
||||
## 2. ZeroClaw on Raspberry Pi
|
||||
|
||||
### 2.1 Prerequisites
|
||||
|
||||
- Raspberry Pi (3/4/5) with Raspberry Pi OS
|
||||
- USB peripherals (Arduino, Nucleo) if using serial transport
|
||||
- Optional: `rppal` for native GPIO (`peripheral-rpi` feature)
|
||||
|
||||
### 2.2 Install
|
||||
|
||||
```bash
|
||||
# Build for RPi (or cross-compile from host)
|
||||
cargo build --release --features hardware
|
||||
|
||||
# Or install via your preferred method
|
||||
```
|
||||
|
||||
### 2.3 Config
|
||||
|
||||
Edit `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "rpi-gpio"
|
||||
transport = "native"
|
||||
|
||||
# Or Arduino over USB
|
||||
[[peripherals.boards]]
|
||||
board = "arduino-uno"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
|
||||
[channels_config.telegram]
|
||||
bot_token = "YOUR_BOT_TOKEN"
|
||||
allowed_users = ["*"]
|
||||
|
||||
[gateway]
|
||||
host = "127.0.0.1"
|
||||
port = 8080
|
||||
allow_public_bind = false
|
||||
```
|
||||
|
||||
### 2.4 Run Daemon (Local Only)
|
||||
|
||||
```bash
|
||||
zeroclaw daemon --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
- Gateway binds to `127.0.0.1` — not reachable from other machines
|
||||
- Telegram channel works: ZeroClaw polls Telegram API (outbound)
|
||||
- No firewall or port forwarding needed
|
||||
|
||||
---
|
||||
|
||||
## 3. Binding to 0.0.0.0 (Local Network)
|
||||
|
||||
To allow other devices on your LAN to hit the gateway (e.g. for pairing or webhooks):
|
||||
|
||||
### 3.1 Option A: Explicit Opt-In
|
||||
|
||||
```toml
|
||||
[gateway]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
allow_public_bind = true
|
||||
```
|
||||
|
||||
```bash
|
||||
zeroclaw daemon --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
**Security:** `allow_public_bind = true` exposes the gateway to your local network. Only use on trusted LANs.
|
||||
|
||||
### 3.2 Option B: Tunnel (Recommended for Webhooks)
|
||||
|
||||
If you need a **public URL** (e.g. WhatsApp webhook, external clients):
|
||||
|
||||
1. Run gateway on localhost:
|
||||
```bash
|
||||
zeroclaw daemon --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
2. Start a tunnel:
|
||||
```toml
|
||||
[tunnel]
|
||||
provider = "tailscale" # or "ngrok", "cloudflare"
|
||||
```
|
||||
Or use `zeroclaw tunnel` (see tunnel docs).
|
||||
|
||||
3. ZeroClaw will refuse `0.0.0.0` unless `allow_public_bind = true` or a tunnel is active.
|
||||
|
||||
---
|
||||
|
||||
## 4. Telegram Polling (No Inbound Port)
|
||||
|
||||
Telegram uses **long-polling** by default:
|
||||
|
||||
- ZeroClaw calls `https://api.telegram.org/bot{token}/getUpdates`
|
||||
- No inbound port or public IP needed
|
||||
- Works behind NAT, on RPi, in a home lab
|
||||
|
||||
**Config:**
|
||||
|
||||
```toml
|
||||
[channels_config.telegram]
|
||||
bot_token = "YOUR_BOT_TOKEN"
|
||||
allowed_users = ["*"] # or specific @usernames / user IDs
|
||||
```
|
||||
|
||||
Run `zeroclaw daemon` — Telegram channel starts automatically.
|
||||
|
||||
---
|
||||
|
||||
## 5. Webhook Channels (WhatsApp, Custom)
|
||||
|
||||
Webhook-based channels need a **public URL** so Meta (WhatsApp) or your client can POST events.
|
||||
|
||||
### 5.1 Tailscale Funnel
|
||||
|
||||
```toml
|
||||
[tunnel]
|
||||
provider = "tailscale"
|
||||
```
|
||||
|
||||
Tailscale Funnel exposes your gateway via a `*.ts.net` URL. No port forwarding.
|
||||
|
||||
### 5.2 ngrok
|
||||
|
||||
```toml
|
||||
[tunnel]
|
||||
provider = "ngrok"
|
||||
```
|
||||
|
||||
Or run ngrok manually:
|
||||
```bash
|
||||
ngrok http 8080
|
||||
# Use the HTTPS URL for your webhook
|
||||
```
|
||||
|
||||
### 5.3 Cloudflare Tunnel
|
||||
|
||||
Configure Cloudflare Tunnel to forward to `127.0.0.1:8080`, then set your webhook URL to the tunnel's public hostname.
|
||||
|
||||
---
|
||||
|
||||
## 6. Checklist: RPi Deployment
|
||||
|
||||
- [ ] Build with `--features hardware` (and `peripheral-rpi` if using native GPIO)
|
||||
- [ ] Configure `[peripherals]` and `[channels_config.telegram]`
|
||||
- [ ] Run `zeroclaw daemon --host 127.0.0.1 --port 8080` (Telegram works without 0.0.0.0)
|
||||
- [ ] For LAN access: `--host 0.0.0.0` + `allow_public_bind = true` in config
|
||||
- [ ] For webhooks: use Tailscale, ngrok, or Cloudflare tunnel
|
||||
|
||||
---
|
||||
|
||||
## 7. References
|
||||
|
||||
- [hardware-peripherals-design.md](./hardware-peripherals-design.md) — Peripherals design
|
||||
- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — Hardware setup and adding boards
|
||||
147
docs/nucleo-setup.md
Normal file
147
docs/nucleo-setup.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# ZeroClaw on Nucleo-F401RE — Step-by-Step Guide
|
||||
|
||||
Run ZeroClaw on your Mac or Linux host. Connect a Nucleo-F401RE via USB. Control GPIO (LED, pins) via Telegram or CLI.
|
||||
|
||||
---
|
||||
|
||||
## Get Board Info via Telegram (No Firmware Needed)
|
||||
|
||||
ZeroClaw can read chip info from the Nucleo over USB **without flashing any firmware**. Message your Telegram bot:
|
||||
|
||||
- *"What board info do I have?"*
|
||||
- *"Board info"*
|
||||
- *"What hardware is connected?"*
|
||||
- *"Chip info"*
|
||||
|
||||
The agent uses the `hardware_board_info` tool to return chip name, architecture, and memory map. With the `probe` feature, it reads live data via USB/SWD; otherwise it returns static datasheet info.
|
||||
|
||||
**Config:** Add Nucleo to `config.toml` first (so the agent knows which board to query):
|
||||
|
||||
```toml
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
```
|
||||
|
||||
**CLI alternative:**
|
||||
|
||||
```bash
|
||||
cargo build --features hardware,probe
|
||||
zeroclaw hardware info
|
||||
zeroclaw hardware discover
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's Included (No Code Changes Needed)
|
||||
|
||||
ZeroClaw includes everything for Nucleo-F401RE:
|
||||
|
||||
| Component | Location | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| Firmware | `firmware/zeroclaw-nucleo/` | Embassy Rust — USART2 (115200), gpio_read, gpio_write |
|
||||
| Serial peripheral | `src/peripherals/serial.rs` | JSON-over-serial protocol (same as Arduino/ESP32) |
|
||||
| Flash command | `zeroclaw peripheral flash-nucleo` | Builds firmware, flashes via probe-rs |
|
||||
|
||||
Protocol: newline-delimited JSON. Request: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`. Response: `{"id":"1","ok":true,"result":"done"}`.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Nucleo-F401RE board
|
||||
- USB cable (USB-A to Mini-USB; Nucleo has built-in ST-Link)
|
||||
- For flashing: `cargo install probe-rs-tools --locked` (or use the [install script](https://probe.rs/docs/getting-started/installation/))
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Flash Firmware
|
||||
|
||||
### 1.1 Connect Nucleo
|
||||
|
||||
1. Connect Nucleo to your Mac/Linux via USB.
|
||||
2. The board appears as a USB device (ST-Link). No separate driver needed on modern systems.
|
||||
|
||||
### 1.2 Flash via ZeroClaw
|
||||
|
||||
From the zeroclaw repo root:
|
||||
|
||||
```bash
|
||||
zeroclaw peripheral flash-nucleo
|
||||
```
|
||||
|
||||
This builds `firmware/zeroclaw-nucleo` and runs `probe-rs run --chip STM32F401RETx`. The firmware runs immediately after flashing.
|
||||
|
||||
### 1.3 Manual Flash (Alternative)
|
||||
|
||||
```bash
|
||||
cd firmware/zeroclaw-nucleo
|
||||
cargo build --release --target thumbv7em-none-eabihf
|
||||
probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/zeroclaw-nucleo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Find Serial Port
|
||||
|
||||
- **macOS:** `/dev/cu.usbmodem*` or `/dev/tty.usbmodem*` (e.g. `/dev/cu.usbmodem101`)
|
||||
- **Linux:** `/dev/ttyACM0` (or check `dmesg` after plugging in)
|
||||
|
||||
USART2 (PA2/PA3) is bridged to the ST-Link's virtual COM port, so the host sees one serial device.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Configure ZeroClaw
|
||||
|
||||
Add to `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/cu.usbmodem101" # adjust to your port
|
||||
baud = 115200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Run and Test
|
||||
|
||||
```bash
|
||||
zeroclaw daemon --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
Or use the agent directly:
|
||||
|
||||
```bash
|
||||
zeroclaw agent --message "Turn on the LED on pin 13"
|
||||
```
|
||||
|
||||
Pin 13 = PA5 = User LED (LD2) on Nucleo-F401RE.
|
||||
|
||||
---
|
||||
|
||||
## Summary: Commands
|
||||
|
||||
| Step | Command |
|
||||
|------|---------|
|
||||
| 1 | Connect Nucleo via USB |
|
||||
| 2 | `cargo install probe-rs --locked` |
|
||||
| 3 | `zeroclaw peripheral flash-nucleo` |
|
||||
| 4 | Add Nucleo to config.toml (path = your serial port) |
|
||||
| 5 | `zeroclaw daemon` or `zeroclaw agent -m "Turn on LED"` |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **flash-nucleo unrecognized** — Build from repo: `cargo run --features hardware -- peripheral flash-nucleo`. The subcommand is only in the repo build, not in crates.io installs.
|
||||
- **probe-rs not found** — `cargo install probe-rs-tools --locked` (the `probe-rs` crate is a library; the CLI is in `probe-rs-tools`)
|
||||
- **No probe detected** — Ensure Nucleo is connected. Try another USB cable/port.
|
||||
- **Serial port not found** — On Linux, add user to `dialout`: `sudo usermod -a -G dialout $USER`, then log out/in.
|
||||
- **GPIO commands ignored** — Check `path` in config matches your serial port. Run `zeroclaw peripheral list` to verify.
|
||||
143
firmware/zeroclaw-arduino/zeroclaw-arduino.ino
Normal file
143
firmware/zeroclaw-arduino/zeroclaw-arduino.ino
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/*
|
||||
* ZeroClaw Arduino Uno Firmware
|
||||
*
|
||||
* Listens for JSON commands on Serial (115200 baud), executes gpio_read/gpio_write,
|
||||
* responds with JSON. Compatible with ZeroClaw SerialPeripheral protocol.
|
||||
*
|
||||
* Protocol (newline-delimited JSON):
|
||||
* Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}
|
||||
* Response: {"id":"1","ok":true,"result":"done"}
|
||||
*
|
||||
* Arduino Uno: Pin 13 has built-in LED. Digital pins 0-13 supported.
|
||||
*
|
||||
* 1. Open in Arduino IDE
|
||||
* 2. Select Board: Arduino Uno
|
||||
* 3. Select correct Port (Tools -> Port)
|
||||
* 4. Upload
|
||||
*/
|
||||
|
||||
#define BAUDRATE 115200
|
||||
#define MAX_LINE 256
|
||||
|
||||
char lineBuf[MAX_LINE];
|
||||
int lineLen = 0;
|
||||
|
||||
// Parse integer from JSON: "pin":13 or "value":1
|
||||
int parseArg(const char* key, const char* json) {
|
||||
char search[32];
|
||||
snprintf(search, sizeof(search), "\"%s\":", key);
|
||||
const char* p = strstr(json, search);
|
||||
if (!p) return -1;
|
||||
p += strlen(search);
|
||||
return atoi(p);
|
||||
}
|
||||
|
||||
// Extract "id" for response
|
||||
void copyId(char* out, int outLen, const char* json) {
|
||||
const char* p = strstr(json, "\"id\":\"");
|
||||
if (!p) {
|
||||
out[0] = '0';
|
||||
out[1] = '\0';
|
||||
return;
|
||||
}
|
||||
p += 6;
|
||||
int i = 0;
|
||||
while (i < outLen - 1 && *p && *p != '"') {
|
||||
out[i++] = *p++;
|
||||
}
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
// Check if cmd is present
|
||||
bool hasCmd(const char* json, const char* cmd) {
|
||||
char search[64];
|
||||
snprintf(search, sizeof(search), "\"cmd\":\"%s\"", cmd);
|
||||
return strstr(json, search) != NULL;
|
||||
}
|
||||
|
||||
void handleLine(const char* line) {
|
||||
char idBuf[16];
|
||||
copyId(idBuf, sizeof(idBuf), line);
|
||||
|
||||
if (hasCmd(line, "ping")) {
|
||||
Serial.print("{\"id\":\"");
|
||||
Serial.print(idBuf);
|
||||
Serial.println("\",\"ok\":true,\"result\":\"pong\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase C: Dynamic discovery — report GPIO pins and LED pin
|
||||
if (hasCmd(line, "capabilities")) {
|
||||
Serial.print("{\"id\":\"");
|
||||
Serial.print(idBuf);
|
||||
Serial.print("\",\"ok\":true,\"result\":\"{\\\"gpio\\\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\"led_pin\\\":13}\"}");
|
||||
Serial.println();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasCmd(line, "gpio_read")) {
|
||||
int pin = parseArg("pin", line);
|
||||
if (pin < 0 || pin > 13) {
|
||||
Serial.print("{\"id\":\"");
|
||||
Serial.print(idBuf);
|
||||
Serial.print("\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin ");
|
||||
Serial.print(pin);
|
||||
Serial.println("\"}");
|
||||
return;
|
||||
}
|
||||
pinMode(pin, INPUT);
|
||||
int val = digitalRead(pin);
|
||||
Serial.print("{\"id\":\"");
|
||||
Serial.print(idBuf);
|
||||
Serial.print("\",\"ok\":true,\"result\":\"");
|
||||
Serial.print(val);
|
||||
Serial.println("\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasCmd(line, "gpio_write")) {
|
||||
int pin = parseArg("pin", line);
|
||||
int value = parseArg("value", line);
|
||||
if (pin < 0 || pin > 13) {
|
||||
Serial.print("{\"id\":\"");
|
||||
Serial.print(idBuf);
|
||||
Serial.print("\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin ");
|
||||
Serial.print(pin);
|
||||
Serial.println("\"}");
|
||||
return;
|
||||
}
|
||||
pinMode(pin, OUTPUT);
|
||||
digitalWrite(pin, value ? HIGH : LOW);
|
||||
Serial.print("{\"id\":\"");
|
||||
Serial.print(idBuf);
|
||||
Serial.println("\",\"ok\":true,\"result\":\"done\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown command
|
||||
Serial.print("{\"id\":\"");
|
||||
Serial.print(idBuf);
|
||||
Serial.println("\",\"ok\":false,\"result\":\"\",\"error\":\"Unknown command\"}");
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(BAUDRATE);
|
||||
lineLen = 0;
|
||||
}
|
||||
|
||||
void loop() {
|
||||
while (Serial.available()) {
|
||||
char c = Serial.read();
|
||||
if (c == '\n' || c == '\r') {
|
||||
if (lineLen > 0) {
|
||||
lineBuf[lineLen] = '\0';
|
||||
handleLine(lineBuf);
|
||||
lineLen = 0;
|
||||
}
|
||||
} else if (lineLen < MAX_LINE - 1) {
|
||||
lineBuf[lineLen++] = c;
|
||||
} else {
|
||||
lineLen = 0; // Overflow, discard
|
||||
}
|
||||
}
|
||||
}
|
||||
5
firmware/zeroclaw-esp32/.cargo/config.toml
Normal file
5
firmware/zeroclaw-esp32/.cargo/config.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
[build]
|
||||
target = "riscv32imc-esp-espidf"
|
||||
|
||||
[target.riscv32imc-esp-espidf]
|
||||
runner = "espflash flash --monitor"
|
||||
1840
firmware/zeroclaw-esp32/Cargo.lock
generated
Normal file
1840
firmware/zeroclaw-esp32/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
35
firmware/zeroclaw-esp32/Cargo.toml
Normal file
35
firmware/zeroclaw-esp32/Cargo.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# ZeroClaw ESP32 firmware — JSON-over-serial peripheral for host-mediated control.
|
||||
#
|
||||
# Flash to ESP32 and connect via serial. The host ZeroClaw sends gpio_read/gpio_write
|
||||
# commands; this firmware executes them and responds.
|
||||
#
|
||||
# Prerequisites: espup (cargo install espup; espup install; source ~/export-esp.sh)
|
||||
# Build: cargo build --release
|
||||
# Flash: cargo espflash flash --monitor
|
||||
|
||||
[package]
|
||||
name = "zeroclaw-esp32"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial"
|
||||
|
||||
[dependencies]
|
||||
esp-idf-svc = "0.48"
|
||||
log = "0.4"
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[build-dependencies]
|
||||
embuild = { version = "0.31", features = ["elf"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = "s"
|
||||
52
firmware/zeroclaw-esp32/README.md
Normal file
52
firmware/zeroclaw-esp32/README.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# ZeroClaw ESP32 Firmware
|
||||
|
||||
Peripheral firmware for ESP32 — speaks the same JSON-over-serial protocol as the STM32 firmware. Flash this to your ESP32, then configure ZeroClaw on the host to connect via serial.
|
||||
|
||||
## Protocol
|
||||
|
||||
- **Request** (host → ESP32): `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}\n`
|
||||
- **Response** (ESP32 → host): `{"id":"1","ok":true,"result":"done"}\n`
|
||||
|
||||
Commands: `gpio_read`, `gpio_write`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **ESP toolchain** (espup):
|
||||
```sh
|
||||
cargo install espup espflash
|
||||
espup install
|
||||
source ~/export-esp.sh # or ~/export-esp.fish for Fish
|
||||
```
|
||||
|
||||
2. **Target**: ESP32-C3 (RISC-V) by default. Edit `.cargo/config.toml` for other targets (e.g. `xtensa-esp32-espidf` for original ESP32).
|
||||
|
||||
## Build & Flash
|
||||
|
||||
```sh
|
||||
cd firmware/zeroclaw-esp32
|
||||
cargo build --release
|
||||
espflash flash target/riscv32imc-esp-espidf/release/zeroclaw-esp32 --monitor
|
||||
```
|
||||
|
||||
## Host Config
|
||||
|
||||
Add to `config.toml`:
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "esp32"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyUSB0" # or /dev/ttyACM0, COM3, etc.
|
||||
baud = 115200
|
||||
```
|
||||
|
||||
## Pin Mapping
|
||||
|
||||
Default GPIO 2 and 13 are configured for output. Edit `src/main.rs` to add more pins or change for your board. ESP32-C3 has different pin layout — adjust UART pins (gpio21/gpio20) if needed.
|
||||
|
||||
## Edge-Native (Future)
|
||||
|
||||
Phase 6 also envisions ZeroClaw running *on* the ESP32 (WiFi + LLM). This firmware is the host-mediated serial peripheral; edge-native will be a separate crate.
|
||||
3
firmware/zeroclaw-esp32/build.rs
Normal file
3
firmware/zeroclaw-esp32/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
embuild::espidf::sysenv::output();
|
||||
}
|
||||
154
firmware/zeroclaw-esp32/src/main.rs
Normal file
154
firmware/zeroclaw-esp32/src/main.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
//! ZeroClaw ESP32 firmware — JSON-over-serial peripheral.
|
||||
//!
|
||||
//! Listens for newline-delimited JSON commands on UART0, executes gpio_read/gpio_write,
|
||||
//! responds with JSON. Compatible with host ZeroClaw SerialPeripheral protocol.
|
||||
//!
|
||||
//! Protocol: same as STM32 — see docs/hardware-peripherals-design.md
|
||||
|
||||
use esp_idf_svc::hal::gpio::PinDriver;
|
||||
use esp_idf_svc::hal::prelude::*;
|
||||
use esp_idf_svc::hal::uart::*;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Incoming command from host.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Request {
|
||||
id: String,
|
||||
cmd: String,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Outgoing response to host.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Response {
|
||||
id: String,
|
||||
ok: bool,
|
||||
result: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
|
||||
let peripherals = Peripherals::take()?;
|
||||
let pins = peripherals.pins;
|
||||
|
||||
// UART0: TX=21, RX=20 (ESP32) — ESP32-C3 may use different pins; adjust for your board
|
||||
let config = UartConfig::new().baudrate(Hertz(115_200));
|
||||
let mut uart = UartDriver::new(
|
||||
peripherals.uart0,
|
||||
pins.gpio21,
|
||||
pins.gpio20,
|
||||
Option::<esp_idf_svc::hal::gpio::Gpio0>::None,
|
||||
Option::<esp_idf_svc::hal::gpio::Gpio1>::None,
|
||||
&config,
|
||||
)?;
|
||||
|
||||
info!("ZeroClaw ESP32 firmware ready on UART0 (115200)");
|
||||
|
||||
let mut buf = [0u8; 512];
|
||||
let mut line = Vec::new();
|
||||
|
||||
loop {
|
||||
match uart.read(&mut buf, 100) {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => {
|
||||
for &b in &buf[..n] {
|
||||
if b == b'\n' {
|
||||
if !line.is_empty() {
|
||||
if let Ok(line_str) = std::str::from_utf8(&line) {
|
||||
if let Ok(resp) = handle_request(line_str, &peripherals) {
|
||||
let out = serde_json::to_string(&resp).unwrap_or_default();
|
||||
let _ = uart.write(format!("{}\n", out).as_bytes());
|
||||
}
|
||||
}
|
||||
line.clear();
|
||||
}
|
||||
} else {
|
||||
line.push(b);
|
||||
if line.len() > 400 {
|
||||
line.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(
|
||||
line: &str,
|
||||
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
|
||||
) -> anyhow::Result<Response> {
|
||||
let req: Request = serde_json::from_str(line.trim())?;
|
||||
let id = req.id.clone();
|
||||
|
||||
let result = match req.cmd.as_str() {
|
||||
"capabilities" => {
|
||||
// Phase C: report GPIO pins and LED pin (matches Arduino protocol)
|
||||
let caps = serde_json::json!({
|
||||
"gpio": [0, 1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19],
|
||||
"led_pin": 2
|
||||
});
|
||||
Ok(caps.to_string())
|
||||
}
|
||||
"gpio_read" => {
|
||||
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
|
||||
let value = gpio_read(peripherals, pin_num)?;
|
||||
Ok(value.to_string())
|
||||
}
|
||||
"gpio_write" => {
|
||||
let pin_num = req.args.get("pin").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
|
||||
let value = req.args.get("value").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
gpio_write(peripherals, pin_num, value)?;
|
||||
Ok("done".into())
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("Unknown command: {}", req.cmd)),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(r) => Ok(Response {
|
||||
id,
|
||||
ok: true,
|
||||
result: r,
|
||||
error: None,
|
||||
}),
|
||||
Err(e) => Ok(Response {
|
||||
id,
|
||||
ok: false,
|
||||
result: String::new(),
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn gpio_read(_peripherals: &esp_idf_svc::hal::peripherals::Peripherals, _pin: i32) -> anyhow::Result<u8> {
|
||||
// TODO: implement input pin read — requires storing InputPin drivers per pin
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn gpio_write(
|
||||
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
|
||||
pin: i32,
|
||||
value: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
let pins = peripherals.pins;
|
||||
let level = value != 0;
|
||||
|
||||
match pin {
|
||||
2 => {
|
||||
let mut out = PinDriver::output(pins.gpio2)?;
|
||||
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
|
||||
}
|
||||
13 => {
|
||||
let mut out = PinDriver::output(pins.gpio13)?;
|
||||
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
|
||||
}
|
||||
_ => anyhow::bail!("Pin {} not configured (add to gpio_write)", pin),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
849
firmware/zeroclaw-nucleo/Cargo.lock
generated
Normal file
849
firmware/zeroclaw-nucleo/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,849 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aligned"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
|
||||
dependencies = [
|
||||
"as-slice",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "as-slice"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bare-metal"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit_field"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitfield"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "block-device-driver"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c051592f59fe68053524b4c4935249b806f72c1f544cfb7abe4f57c3be258e"
|
||||
dependencies = [
|
||||
"aligned",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9"
|
||||
dependencies = [
|
||||
"bare-metal",
|
||||
"bitfield",
|
||||
"embedded-hal 0.2.7",
|
||||
"volatile-register",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m-rt"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6"
|
||||
dependencies = [
|
||||
"cortex-m-rt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cortex-m-rt-macros"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "0.3.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"defmt-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-macros"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
|
||||
dependencies = [
|
||||
"defmt-parser",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-parser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
|
||||
dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defmt-rtt"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-embedded-hal"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "554e3e840696f54b4c9afcf28a0f24da431c927f4151040020416e7393d6d0d8"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"embassy-futures",
|
||||
"embassy-hal-internal 0.3.0",
|
||||
"embassy-sync",
|
||||
"embassy-time",
|
||||
"embedded-hal 0.2.7",
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
"embedded-storage",
|
||||
"embedded-storage-async",
|
||||
"nb 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-executor"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06070468370195e0e86f241c8e5004356d696590a678d47d6676795b2e439c6b"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"document-features",
|
||||
"embassy-executor-macros",
|
||||
"embassy-executor-timer-queue",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-executor-macros"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-executor-timer-queue"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c"
|
||||
|
||||
[[package]]
|
||||
name = "embassy-futures"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01"
|
||||
|
||||
[[package]]
|
||||
name = "embassy-hal-internal"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95285007a91b619dc9f26ea8f55452aa6c60f7115a4edc05085cd2bd3127cd7a"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-hal-internal"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-net-driver"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d"
|
||||
dependencies = [
|
||||
"defmt 0.3.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-stm32"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "088d65743a48f2cc9b3ae274ed85d6e8b68bd3ee92eb6b87b15dca2f81f7a101"
|
||||
dependencies = [
|
||||
"aligned",
|
||||
"bit_field",
|
||||
"bitflags 2.11.0",
|
||||
"block-device-driver",
|
||||
"cfg-if",
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"document-features",
|
||||
"embassy-embedded-hal",
|
||||
"embassy-futures",
|
||||
"embassy-hal-internal 0.4.0",
|
||||
"embassy-net-driver",
|
||||
"embassy-sync",
|
||||
"embassy-time",
|
||||
"embassy-time-driver",
|
||||
"embassy-time-queue-utils",
|
||||
"embassy-usb-driver",
|
||||
"embassy-usb-synopsys-otg",
|
||||
"embedded-can",
|
||||
"embedded-hal 0.2.7",
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
"embedded-hal-nb",
|
||||
"embedded-io 0.7.1",
|
||||
"embedded-io-async 0.7.0",
|
||||
"embedded-storage",
|
||||
"embedded-storage-async",
|
||||
"futures-util",
|
||||
"heapless 0.9.2",
|
||||
"nb 1.1.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rand_core 0.6.4",
|
||||
"rand_core 0.9.5",
|
||||
"sdio-host",
|
||||
"static_assertions",
|
||||
"stm32-fmc",
|
||||
"stm32-metapac",
|
||||
"trait-set",
|
||||
"vcell",
|
||||
"volatile-register",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-sync"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"embedded-io-async 0.6.1",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"heapless 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-time"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4fa65b9284d974dad7a23bb72835c4ec85c0b540d86af7fc4098c88cff51d65"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"document-features",
|
||||
"embassy-time-driver",
|
||||
"embedded-hal 0.2.7",
|
||||
"embedded-hal 1.0.0",
|
||||
"embedded-hal-async",
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-time-driver"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0a244c7dc22c8d0289379c8d8830cae06bb93d8f990194d0de5efb3b5ae7ba6"
|
||||
dependencies = [
|
||||
"document-features",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-time-queue-utils"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454"
|
||||
dependencies = [
|
||||
"embassy-executor-timer-queue",
|
||||
"heapless 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-usb-driver"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "17119855ccc2d1f7470a39756b12068454ae27a3eabb037d940b5c03d9c77b7a"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"embedded-io-async 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embassy-usb-synopsys-otg"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "288751f8eaa44a5cf2613f13cee0ca8e06e6638cb96e897e6834702c79084b23"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"embassy-sync",
|
||||
"embassy-usb-driver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-can"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438"
|
||||
dependencies = [
|
||||
"nb 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-hal"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff"
|
||||
dependencies = [
|
||||
"nb 0.1.3",
|
||||
"void",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-hal"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-hal-async"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884"
|
||||
dependencies = [
|
||||
"embedded-hal 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-hal-nb"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605"
|
||||
dependencies = [
|
||||
"embedded-hal 1.0.0",
|
||||
"nb 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io-async"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f"
|
||||
dependencies = [
|
||||
"embedded-io 0.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io-async"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0"
|
||||
dependencies = [
|
||||
"defmt 1.0.1",
|
||||
"embedded-io 0.7.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embedded-storage"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-storage-async"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc"
|
||||
dependencies = [
|
||||
"embedded-storage",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "nb"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f"
|
||||
dependencies = [
|
||||
"nb 1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nb"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "panic-probe"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"defmt 1.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sdio-host"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b328e2cb950eeccd55b7f55c3a963691455dcd044cfb5354f0c5e68d2c2d6ee2"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403"
|
||||
dependencies = [
|
||||
"semver-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver-parser"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "stm32-fmc"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72692594faa67f052e5e06dd34460951c21e83bc55de4feb8d2666e2f15480a2"
|
||||
dependencies = [
|
||||
"embedded-hal 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stm32-metapac"
|
||||
version = "19.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a411079520dbccc613af73172f944b7cf97ba84e3bd7381a0352b6ec7bfef03b"
|
||||
dependencies = [
|
||||
"cortex-m",
|
||||
"cortex-m-rt",
|
||||
"defmt 0.3.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.116",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "trait-set"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b79e2e9c9ab44c6d7c20d5976961b47e8f49ac199154daa514b77cd1ab536625"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "vcell"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002"
|
||||
|
||||
[[package]]
|
||||
name = "void"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
|
||||
|
||||
[[package]]
|
||||
name = "volatile-register"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc"
|
||||
dependencies = [
|
||||
"vcell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroclaw-nucleo"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cortex-m-rt",
|
||||
"critical-section",
|
||||
"defmt 1.0.1",
|
||||
"defmt-rtt",
|
||||
"embassy-executor",
|
||||
"embassy-stm32",
|
||||
"embassy-time",
|
||||
"heapless 0.9.2",
|
||||
"panic-probe",
|
||||
]
|
||||
39
firmware/zeroclaw-nucleo/Cargo.toml
Normal file
39
firmware/zeroclaw-nucleo/Cargo.toml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# ZeroClaw Nucleo-F401RE firmware — JSON-over-serial peripheral.
|
||||
#
|
||||
# Listens for newline-delimited JSON on USART2 (PA2/PA3, ST-Link VCP).
|
||||
# Protocol: same as Arduino/ESP32 — ping, capabilities, gpio_read, gpio_write.
|
||||
#
|
||||
# Build: cargo build --release
|
||||
# Flash: probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/zeroclaw-nucleo
|
||||
# Or: zeroclaw peripheral flash-nucleo
|
||||
|
||||
[package]
|
||||
name = "zeroclaw-nucleo"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "ZeroClaw Nucleo-F401RE peripheral firmware — GPIO over JSON serial"
|
||||
|
||||
[dependencies]
|
||||
embassy-executor = { version = "0.9", features = ["arch-cortex-m", "executor-thread", "defmt"] }
|
||||
embassy-stm32 = { version = "0.5", features = ["defmt", "stm32f401re", "unstable-pac", "memory-x", "time-driver-tim4", "exti"] }
|
||||
embassy-time = { version = "0.5", features = ["defmt", "defmt-timestamp-uptime", "tick-hz-32_768"] }
|
||||
defmt = "1.0"
|
||||
defmt-rtt = "1.0"
|
||||
panic-probe = { version = "1.0", features = ["print-defmt"] }
|
||||
heapless = { version = "0.9", default-features = false }
|
||||
critical-section = "1.1"
|
||||
cortex-m-rt = "0.7"
|
||||
|
||||
[package.metadata.embassy]
|
||||
build = [
|
||||
{ target = "thumbv7em-none-eabihf", artifact-dir = "target" }
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
debug = 1
|
||||
187
firmware/zeroclaw-nucleo/src/main.rs
Normal file
187
firmware/zeroclaw-nucleo/src/main.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
//! ZeroClaw Nucleo-F401RE firmware — JSON-over-serial peripheral.
|
||||
//!
|
||||
//! Listens for newline-delimited JSON on USART2 (PA2=TX, PA3=RX).
|
||||
//! USART2 is connected to ST-Link VCP — host sees /dev/ttyACM0 (Linux) or /dev/cu.usbmodem* (macOS).
|
||||
//!
|
||||
//! Protocol: same as Arduino/ESP32 — see docs/hardware-peripherals-design.md
|
||||
|
||||
#![no_std]
|
||||
#![no_main]
|
||||
|
||||
use core::fmt::Write;
|
||||
use core::str;
|
||||
use defmt::info;
|
||||
use embassy_executor::Spawner;
|
||||
use embassy_stm32::gpio::{Level, Output, Speed};
|
||||
use embassy_stm32::usart::{Config, Uart};
|
||||
use heapless::String;
|
||||
use {defmt_rtt as _, panic_probe as _};
|
||||
|
||||
/// Arduino-style pin 13 = PA5 (User LED LD2 on Nucleo-F401RE)
|
||||
const LED_PIN: u8 = 13;
|
||||
|
||||
/// Parse integer from JSON: "pin":13 or "value":1
|
||||
fn parse_arg(line: &[u8], key: &[u8]) -> Option<i32> {
|
||||
// key like b"pin" -> search for b"\"pin\":"
|
||||
let mut suffix: [u8; 32] = [0; 32];
|
||||
suffix[0] = b'"';
|
||||
let mut len = 1;
|
||||
for (i, &k) in key.iter().enumerate() {
|
||||
if i >= 30 {
|
||||
break;
|
||||
}
|
||||
suffix[len] = k;
|
||||
len += 1;
|
||||
}
|
||||
suffix[len] = b'"';
|
||||
suffix[len + 1] = b':';
|
||||
len += 2;
|
||||
let suffix = &suffix[..len];
|
||||
|
||||
let line_len = line.len();
|
||||
if line_len < len {
|
||||
return None;
|
||||
}
|
||||
for i in 0..=line_len - len {
|
||||
if line[i..].starts_with(suffix) {
|
||||
let rest = &line[i + len..];
|
||||
let mut num: i32 = 0;
|
||||
let mut neg = false;
|
||||
let mut j = 0;
|
||||
if j < rest.len() && rest[j] == b'-' {
|
||||
neg = true;
|
||||
j += 1;
|
||||
}
|
||||
while j < rest.len() && rest[j].is_ascii_digit() {
|
||||
num = num * 10 + (rest[j] - b'0') as i32;
|
||||
j += 1;
|
||||
}
|
||||
return Some(if neg { -num } else { num });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn has_cmd(line: &[u8], cmd: &[u8]) -> bool {
|
||||
let mut pat: [u8; 64] = [0; 64];
|
||||
pat[0..7].copy_from_slice(b"\"cmd\":\"");
|
||||
let clen = cmd.len().min(50);
|
||||
pat[7..7 + clen].copy_from_slice(&cmd[..clen]);
|
||||
pat[7 + clen] = b'"';
|
||||
let pat = &pat[..8 + clen];
|
||||
|
||||
let line_len = line.len();
|
||||
if line_len < pat.len() {
|
||||
return false;
|
||||
}
|
||||
for i in 0..=line_len - pat.len() {
|
||||
if line[i..].starts_with(pat) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract "id" for response
|
||||
fn copy_id(line: &[u8], out: &mut [u8]) -> usize {
|
||||
let prefix = b"\"id\":\"";
|
||||
if line.len() < prefix.len() + 1 {
|
||||
out[0] = b'0';
|
||||
return 1;
|
||||
}
|
||||
for i in 0..=line.len() - prefix.len() {
|
||||
if line[i..].starts_with(prefix) {
|
||||
let start = i + prefix.len();
|
||||
let mut j = 0;
|
||||
while start + j < line.len() && j < out.len() - 1 && line[start + j] != b'"' {
|
||||
out[j] = line[start + j];
|
||||
j += 1;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
}
|
||||
out[0] = b'0';
|
||||
1
|
||||
}
|
||||
|
||||
#[embassy_executor::main]
|
||||
async fn main(_spawner: Spawner) {
|
||||
let p = embassy_stm32::init(Default::default());
|
||||
|
||||
let mut config = Config::default();
|
||||
config.baudrate = 115_200;
|
||||
|
||||
let mut usart = Uart::new_blocking(p.USART2, p.PA3, p.PA2, config).unwrap();
|
||||
let mut led = Output::new(p.PA5, Level::Low, Speed::Low);
|
||||
|
||||
info!("ZeroClaw Nucleo firmware ready on USART2 (115200)");
|
||||
|
||||
let mut line_buf: heapless::Vec<u8, 256> = heapless::Vec::new();
|
||||
let mut id_buf = [0u8; 16];
|
||||
let mut resp_buf: String<128> = String::new();
|
||||
|
||||
loop {
|
||||
let mut byte = [0u8; 1];
|
||||
if usart.blocking_read(&mut byte).is_ok() {
|
||||
let b = byte[0];
|
||||
if b == b'\n' || b == b'\r' {
|
||||
if !line_buf.is_empty() {
|
||||
let id_len = copy_id(&line_buf, &mut id_buf);
|
||||
let id_str = str::from_utf8(&id_buf[..id_len]).unwrap_or("0");
|
||||
|
||||
resp_buf.clear();
|
||||
if has_cmd(&line_buf, b"ping") {
|
||||
let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"pong\"}}", id_str);
|
||||
} else if has_cmd(&line_buf, b"capabilities") {
|
||||
let _ = write!(
|
||||
resp_buf,
|
||||
"{{\"id\":\"{}\",\"ok\":true,\"result\":\"{{\\\"gpio\\\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\"led_pin\\\":13}}\"}}",
|
||||
id_str
|
||||
);
|
||||
} else if has_cmd(&line_buf, b"gpio_read") {
|
||||
let pin = parse_arg(&line_buf, b"pin").unwrap_or(-1);
|
||||
if pin == LED_PIN as i32 {
|
||||
// Output doesn't support read; return 0 (LED state not readable)
|
||||
let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"0\"}}", id_str);
|
||||
} else if pin >= 0 && pin <= 13 {
|
||||
let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"0\"}}", id_str);
|
||||
} else {
|
||||
let _ = write!(
|
||||
resp_buf,
|
||||
"{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin {}\"}}",
|
||||
id_str, pin
|
||||
);
|
||||
}
|
||||
} else if has_cmd(&line_buf, b"gpio_write") {
|
||||
let pin = parse_arg(&line_buf, b"pin").unwrap_or(-1);
|
||||
let value = parse_arg(&line_buf, b"value").unwrap_or(0);
|
||||
if pin == LED_PIN as i32 {
|
||||
led.set_level(if value != 0 { Level::High } else { Level::Low });
|
||||
let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"done\"}}", id_str);
|
||||
} else if pin >= 0 && pin <= 13 {
|
||||
let _ = write!(resp_buf, "{{\"id\":\"{}\",\"ok\":true,\"result\":\"done\"}}", id_str);
|
||||
} else {
|
||||
let _ = write!(
|
||||
resp_buf,
|
||||
"{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Invalid pin {}\"}}",
|
||||
id_str, pin
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let _ = write!(
|
||||
resp_buf,
|
||||
"{{\"id\":\"{}\",\"ok\":false,\"result\":\"\",\"error\":\"Unknown command\"}}",
|
||||
id_str
|
||||
);
|
||||
}
|
||||
|
||||
let _ = usart.blocking_write(resp_buf.as_bytes());
|
||||
let _ = usart.blocking_write(b"\n");
|
||||
line_buf.clear();
|
||||
}
|
||||
} else if line_buf.push(b).is_err() {
|
||||
line_buf.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
firmware/zeroclaw-uno-q-bridge/app.yaml
Normal file
9
firmware/zeroclaw-uno-q-bridge/app.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name: ZeroClaw Bridge
|
||||
description: "GPIO bridge for ZeroClaw — exposes digitalWrite/digitalRead via socket for agent control"
|
||||
icon: 🦀
|
||||
version: "1.0.0"
|
||||
|
||||
ports:
|
||||
- 9999
|
||||
|
||||
bricks: []
|
||||
66
firmware/zeroclaw-uno-q-bridge/python/main.py
Normal file
66
firmware/zeroclaw-uno-q-bridge/python/main.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# ZeroClaw Bridge — socket server for GPIO control from ZeroClaw agent
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
import socket
|
||||
import threading
|
||||
from arduino.app_utils import App, Bridge
|
||||
|
||||
ZEROCLAW_PORT = 9999
|
||||
|
||||
def handle_client(conn):
|
||||
try:
|
||||
data = conn.recv(256).decode().strip()
|
||||
if not data:
|
||||
conn.close()
|
||||
return
|
||||
parts = data.split()
|
||||
if len(parts) < 2:
|
||||
conn.sendall(b"error: invalid command\n")
|
||||
conn.close()
|
||||
return
|
||||
cmd = parts[0].lower()
|
||||
if cmd == "gpio_write" and len(parts) >= 3:
|
||||
pin = int(parts[1])
|
||||
value = int(parts[2])
|
||||
Bridge.call("digitalWrite", [pin, value])
|
||||
conn.sendall(b"ok\n")
|
||||
elif cmd == "gpio_read" and len(parts) >= 2:
|
||||
pin = int(parts[1])
|
||||
val = Bridge.call("digitalRead", [pin])
|
||||
conn.sendall(f"{val}\n".encode())
|
||||
else:
|
||||
conn.sendall(b"error: unknown command\n")
|
||||
except Exception as e:
|
||||
try:
|
||||
conn.sendall(f"error: {e}\n".encode())
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def accept_loop(server):
|
||||
while True:
|
||||
try:
|
||||
conn, _ = server.accept()
|
||||
t = threading.Thread(target=handle_client, args=(conn,))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
except Exception:
|
||||
break
|
||||
|
||||
def loop():
|
||||
App.sleep(1)
|
||||
|
||||
def main():
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("127.0.0.1", ZEROCLAW_PORT))
|
||||
server.listen(5)
|
||||
server.settimeout(1.0)
|
||||
t = threading.Thread(target=accept_loop, args=(server,))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
App.run(user_loop=loop)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
firmware/zeroclaw-uno-q-bridge/python/requirements.txt
Normal file
1
firmware/zeroclaw-uno-q-bridge/python/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
# ZeroClaw Bridge — no extra deps (arduino.app_utils is preinstalled on Uno Q)
|
||||
24
firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino
Normal file
24
firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// ZeroClaw Bridge — expose digitalWrite/digitalRead for agent GPIO control
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
#include "Arduino_RouterBridge.h"
|
||||
|
||||
void gpio_write(int pin, int value) {
|
||||
pinMode(pin, OUTPUT);
|
||||
digitalWrite(pin, value ? HIGH : LOW);
|
||||
}
|
||||
|
||||
int gpio_read(int pin) {
|
||||
pinMode(pin, INPUT);
|
||||
return digitalRead(pin);
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Bridge.begin();
|
||||
Bridge.provide("digitalWrite", gpio_write);
|
||||
Bridge.provide("digitalRead", gpio_read);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
Bridge.update();
|
||||
}
|
||||
11
firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml
Normal file
11
firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
profiles:
|
||||
default:
|
||||
fqbn: arduino:zephyr:unoq
|
||||
platforms:
|
||||
- platform: arduino:zephyr
|
||||
libraries:
|
||||
- MsgPack (0.4.2)
|
||||
- DebugLog (0.8.4)
|
||||
- ArxContainer (0.7.0)
|
||||
- ArxTypeTraits (0.3.1)
|
||||
default_profile: default
|
||||
705
src/agent/agent.rs
Normal file
705
src/agent/agent.rs
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
use crate::agent::dispatcher::{
|
||||
NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher,
|
||||
};
|
||||
use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
|
||||
use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
|
||||
use crate::config::Config;
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::observability::{self, Observer, ObserverEvent};
|
||||
use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
|
||||
use crate::runtime;
|
||||
use crate::security::SecurityPolicy;
|
||||
use crate::tools::{self, Tool, ToolSpec};
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
use std::io::Write as IoWrite;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct Agent {
|
||||
provider: Box<dyn Provider>,
|
||||
tools: Vec<Box<dyn Tool>>,
|
||||
tool_specs: Vec<ToolSpec>,
|
||||
memory: Arc<dyn Memory>,
|
||||
observer: Arc<dyn Observer>,
|
||||
prompt_builder: SystemPromptBuilder,
|
||||
tool_dispatcher: Box<dyn ToolDispatcher>,
|
||||
memory_loader: Box<dyn MemoryLoader>,
|
||||
config: crate::config::AgentConfig,
|
||||
model_name: String,
|
||||
temperature: f64,
|
||||
workspace_dir: std::path::PathBuf,
|
||||
identity_config: crate::config::IdentityConfig,
|
||||
skills: Vec<crate::skills::Skill>,
|
||||
auto_save: bool,
|
||||
history: Vec<ConversationMessage>,
|
||||
}
|
||||
|
||||
pub struct AgentBuilder {
|
||||
provider: Option<Box<dyn Provider>>,
|
||||
tools: Option<Vec<Box<dyn Tool>>>,
|
||||
memory: Option<Arc<dyn Memory>>,
|
||||
observer: Option<Arc<dyn Observer>>,
|
||||
prompt_builder: Option<SystemPromptBuilder>,
|
||||
tool_dispatcher: Option<Box<dyn ToolDispatcher>>,
|
||||
memory_loader: Option<Box<dyn MemoryLoader>>,
|
||||
config: Option<crate::config::AgentConfig>,
|
||||
model_name: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
workspace_dir: Option<std::path::PathBuf>,
|
||||
identity_config: Option<crate::config::IdentityConfig>,
|
||||
skills: Option<Vec<crate::skills::Skill>>,
|
||||
auto_save: Option<bool>,
|
||||
}
|
||||
|
||||
impl AgentBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
provider: None,
|
||||
tools: None,
|
||||
memory: None,
|
||||
observer: None,
|
||||
prompt_builder: None,
|
||||
tool_dispatcher: None,
|
||||
memory_loader: None,
|
||||
config: None,
|
||||
model_name: None,
|
||||
temperature: None,
|
||||
workspace_dir: None,
|
||||
identity_config: None,
|
||||
skills: None,
|
||||
auto_save: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn provider(mut self, provider: Box<dyn Provider>) -> Self {
|
||||
self.provider = Some(provider);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tools(mut self, tools: Vec<Box<dyn Tool>>) -> Self {
|
||||
self.tools = Some(tools);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn memory(mut self, memory: Arc<dyn Memory>) -> Self {
|
||||
self.memory = Some(memory);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn observer(mut self, observer: Arc<dyn Observer>) -> Self {
|
||||
self.observer = Some(observer);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self {
|
||||
self.prompt_builder = Some(prompt_builder);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tool_dispatcher(mut self, tool_dispatcher: Box<dyn ToolDispatcher>) -> Self {
|
||||
self.tool_dispatcher = Some(tool_dispatcher);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn memory_loader(mut self, memory_loader: Box<dyn MemoryLoader>) -> Self {
|
||||
self.memory_loader = Some(memory_loader);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn config(mut self, config: crate::config::AgentConfig) -> Self {
|
||||
self.config = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn model_name(mut self, model_name: String) -> Self {
|
||||
self.model_name = Some(model_name);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn temperature(mut self, temperature: f64) -> Self {
|
||||
self.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self {
|
||||
self.workspace_dir = Some(workspace_dir);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn identity_config(mut self, identity_config: crate::config::IdentityConfig) -> Self {
|
||||
self.identity_config = Some(identity_config);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
|
||||
self.skills = Some(skills);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn auto_save(mut self, auto_save: bool) -> Self {
|
||||
self.auto_save = Some(auto_save);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<Agent> {
|
||||
let tools = self
|
||||
.tools
|
||||
.ok_or_else(|| anyhow::anyhow!("tools are required"))?;
|
||||
let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
|
||||
|
||||
Ok(Agent {
|
||||
provider: self
|
||||
.provider
|
||||
.ok_or_else(|| anyhow::anyhow!("provider is required"))?,
|
||||
tools,
|
||||
tool_specs,
|
||||
memory: self
|
||||
.memory
|
||||
.ok_or_else(|| anyhow::anyhow!("memory is required"))?,
|
||||
observer: self
|
||||
.observer
|
||||
.ok_or_else(|| anyhow::anyhow!("observer is required"))?,
|
||||
prompt_builder: self
|
||||
.prompt_builder
|
||||
.unwrap_or_else(SystemPromptBuilder::with_defaults),
|
||||
tool_dispatcher: self
|
||||
.tool_dispatcher
|
||||
.ok_or_else(|| anyhow::anyhow!("tool_dispatcher is required"))?,
|
||||
memory_loader: self
|
||||
.memory_loader
|
||||
.unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
|
||||
config: self.config.unwrap_or_default(),
|
||||
model_name: self
|
||||
.model_name
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()),
|
||||
temperature: self.temperature.unwrap_or(0.7),
|
||||
workspace_dir: self
|
||||
.workspace_dir
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(".")),
|
||||
identity_config: self.identity_config.unwrap_or_default(),
|
||||
skills: self.skills.unwrap_or_default(),
|
||||
auto_save: self.auto_save.unwrap_or(false),
|
||||
history: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
pub fn builder() -> AgentBuilder {
|
||||
AgentBuilder::new()
|
||||
}
|
||||
|
||||
pub fn history(&self) -> &[ConversationMessage] {
|
||||
&self.history
|
||||
}
|
||||
|
||||
pub fn clear_history(&mut self) {
|
||||
self.history.clear();
|
||||
}
|
||||
|
||||
pub fn from_config(config: &Config) -> Result<Self> {
|
||||
let observer: Arc<dyn Observer> =
|
||||
Arc::from(observability::create_observer(&config.observability));
|
||||
let runtime: Arc<dyn runtime::RuntimeAdapter> =
|
||||
Arc::from(runtime::create_runtime(&config.runtime)?);
|
||||
let security = Arc::new(SecurityPolicy::from_config(
|
||||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
|
||||
let memory: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
||||
&config.memory,
|
||||
&config.workspace_dir,
|
||||
config.api_key.as_deref(),
|
||||
)?);
|
||||
|
||||
let composio_key = if config.composio.enabled {
|
||||
config.composio.api_key.as_deref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let composio_entity_id = if config.composio.enabled {
|
||||
Some(config.composio.entity_id.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tools = tools::all_tools_with_runtime(
|
||||
&security,
|
||||
runtime,
|
||||
memory.clone(),
|
||||
composio_key,
|
||||
composio_entity_id,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&config.workspace_dir,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
config,
|
||||
);
|
||||
|
||||
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
|
||||
|
||||
let model_name = config
|
||||
.default_model
|
||||
.as_deref()
|
||||
.unwrap_or("anthropic/claude-sonnet-4-20250514")
|
||||
.to_string();
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider(
|
||||
provider_name,
|
||||
config.api_key.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
&model_name,
|
||||
)?;
|
||||
|
||||
let dispatcher_choice = config.agent.tool_dispatcher.as_str();
|
||||
let tool_dispatcher: Box<dyn ToolDispatcher> = match dispatcher_choice {
|
||||
"native" => Box::new(NativeToolDispatcher),
|
||||
"xml" => Box::new(XmlToolDispatcher),
|
||||
_ if provider.supports_native_tools() => Box::new(NativeToolDispatcher),
|
||||
_ => Box::new(XmlToolDispatcher),
|
||||
};
|
||||
|
||||
Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(tools)
|
||||
.memory(memory)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(tool_dispatcher)
|
||||
.memory_loader(Box::new(DefaultMemoryLoader::default()))
|
||||
.prompt_builder(SystemPromptBuilder::with_defaults())
|
||||
.config(config.agent.clone())
|
||||
.model_name(model_name)
|
||||
.temperature(config.default_temperature)
|
||||
.workspace_dir(config.workspace_dir.clone())
|
||||
.identity_config(config.identity.clone())
|
||||
.skills(crate::skills::load_skills(&config.workspace_dir))
|
||||
.auto_save(config.memory.auto_save)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn trim_history(&mut self) {
|
||||
let max = self.config.max_history_messages;
|
||||
if self.history.len() <= max {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut system_messages = Vec::new();
|
||||
let mut other_messages = Vec::new();
|
||||
|
||||
for msg in self.history.drain(..) {
|
||||
match &msg {
|
||||
ConversationMessage::Chat(chat) if chat.role == "system" => {
|
||||
system_messages.push(msg);
|
||||
}
|
||||
_ => other_messages.push(msg),
|
||||
}
|
||||
}
|
||||
|
||||
if other_messages.len() > max {
|
||||
let drop_count = other_messages.len() - max;
|
||||
other_messages.drain(0..drop_count);
|
||||
}
|
||||
|
||||
self.history = system_messages;
|
||||
self.history.extend(other_messages);
|
||||
}
|
||||
|
||||
fn build_system_prompt(&self) -> Result<String> {
|
||||
let instructions = self.tool_dispatcher.prompt_instructions(&self.tools);
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: &self.workspace_dir,
|
||||
model_name: &self.model_name,
|
||||
tools: &self.tools,
|
||||
skills: &self.skills,
|
||||
identity_config: Some(&self.identity_config),
|
||||
dispatcher_instructions: &instructions,
|
||||
};
|
||||
self.prompt_builder.build(&ctx)
|
||||
}
|
||||
|
||||
async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult {
|
||||
let start = Instant::now();
|
||||
|
||||
let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) {
|
||||
match tool.execute(call.arguments.clone()).await {
|
||||
Ok(r) => {
|
||||
self.observer.record_event(&ObserverEvent::ToolCall {
|
||||
tool: call.name.clone(),
|
||||
duration: start.elapsed(),
|
||||
success: r.success,
|
||||
});
|
||||
if r.success {
|
||||
r.output
|
||||
} else {
|
||||
format!("Error: {}", r.error.unwrap_or(r.output))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.observer.record_event(&ObserverEvent::ToolCall {
|
||||
tool: call.name.clone(),
|
||||
duration: start.elapsed(),
|
||||
success: false,
|
||||
});
|
||||
format!("Error executing {}: {e}", call.name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
format!("Unknown tool: {}", call.name)
|
||||
};
|
||||
|
||||
ToolExecutionResult {
|
||||
name: call.name.clone(),
|
||||
output: result,
|
||||
success: true,
|
||||
tool_call_id: call.tool_call_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec<ToolExecutionResult> {
|
||||
if !self.config.parallel_tools {
|
||||
let mut results = Vec::with_capacity(calls.len());
|
||||
for call in calls {
|
||||
results.push(self.execute_tool_call(call).await);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
let mut results = Vec::with_capacity(calls.len());
|
||||
for call in calls {
|
||||
results.push(self.execute_tool_call(call).await);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
pub async fn turn(&mut self, user_message: &str) -> Result<String> {
|
||||
if self.history.is_empty() {
|
||||
let system_prompt = self.build_system_prompt()?;
|
||||
self.history
|
||||
.push(ConversationMessage::Chat(ChatMessage::system(
|
||||
system_prompt,
|
||||
)));
|
||||
}
|
||||
|
||||
if self.auto_save {
|
||||
let _ = self
|
||||
.memory
|
||||
.store("user_msg", user_message, MemoryCategory::Conversation)
|
||||
.await;
|
||||
}
|
||||
|
||||
let context = self
|
||||
.memory_loader
|
||||
.load_context(self.memory.as_ref(), user_message)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let enriched = if context.is_empty() {
|
||||
user_message.to_string()
|
||||
} else {
|
||||
format!("{context}{user_message}")
|
||||
};
|
||||
|
||||
self.history
|
||||
.push(ConversationMessage::Chat(ChatMessage::user(enriched)));
|
||||
|
||||
for _ in 0..self.config.max_tool_iterations {
|
||||
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
|
||||
let response = match self
|
||||
.provider
|
||||
.chat(
|
||||
ChatRequest {
|
||||
messages: &messages,
|
||||
tools: if self.tool_dispatcher.should_send_tool_specs() {
|
||||
Some(&self.tool_specs)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
&self.model_name,
|
||||
self.temperature,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let (text, calls) = self.tool_dispatcher.parse_response(&response);
|
||||
if calls.is_empty() {
|
||||
let final_text = if text.is_empty() {
|
||||
response.text.unwrap_or_default()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
self.history
|
||||
.push(ConversationMessage::Chat(ChatMessage::assistant(
|
||||
final_text.clone(),
|
||||
)));
|
||||
self.trim_history();
|
||||
|
||||
if self.auto_save {
|
||||
let summary = truncate_with_ellipsis(&final_text, 100);
|
||||
let _ = self
|
||||
.memory
|
||||
.store("assistant_resp", &summary, MemoryCategory::Daily)
|
||||
.await;
|
||||
}
|
||||
|
||||
return Ok(final_text);
|
||||
}
|
||||
|
||||
if !text.is_empty() {
|
||||
self.history
|
||||
.push(ConversationMessage::Chat(ChatMessage::assistant(
|
||||
text.clone(),
|
||||
)));
|
||||
print!("{text}");
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
||||
self.history.push(ConversationMessage::AssistantToolCalls {
|
||||
text: response.text.clone(),
|
||||
tool_calls: response.tool_calls.clone(),
|
||||
});
|
||||
|
||||
let results = self.execute_tools(&calls).await;
|
||||
let formatted = self.tool_dispatcher.format_results(&results);
|
||||
self.history.push(formatted);
|
||||
self.trim_history();
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Agent exceeded maximum tool iterations ({})",
|
||||
self.config.max_tool_iterations
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn run_single(&mut self, message: &str) -> Result<String> {
|
||||
self.turn(message).await
|
||||
}
|
||||
|
||||
pub async fn run_interactive(&mut self) -> Result<()> {
|
||||
println!("🦀 ZeroClaw Interactive Mode");
|
||||
println!("Type /quit to exit.\n");
|
||||
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
|
||||
let cli = crate::channels::CliChannel::new();
|
||||
|
||||
let listen_handle = tokio::spawn(async move {
|
||||
let _ = crate::channels::Channel::listen(&cli, tx).await;
|
||||
});
|
||||
|
||||
while let Some(msg) = rx.recv().await {
|
||||
let response = match self.turn(&msg.content).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
eprintln!("\nError: {e}\n");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
println!("\n{response}\n");
|
||||
}
|
||||
|
||||
listen_handle.abort();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
config: Config,
|
||||
message: Option<String>,
|
||||
provider_override: Option<String>,
|
||||
model_override: Option<String>,
|
||||
temperature: f64,
|
||||
) -> Result<()> {
|
||||
let start = Instant::now();
|
||||
|
||||
let mut effective_config = config;
|
||||
if let Some(p) = provider_override {
|
||||
effective_config.default_provider = Some(p);
|
||||
}
|
||||
if let Some(m) = model_override {
|
||||
effective_config.default_model = Some(m);
|
||||
}
|
||||
effective_config.default_temperature = temperature;
|
||||
|
||||
let mut agent = Agent::from_config(&effective_config)?;
|
||||
|
||||
let provider_name = effective_config
|
||||
.default_provider
|
||||
.as_deref()
|
||||
.unwrap_or("openrouter")
|
||||
.to_string();
|
||||
let model_name = effective_config
|
||||
.default_model
|
||||
.as_deref()
|
||||
.unwrap_or("anthropic/claude-sonnet-4-20250514")
|
||||
.to_string();
|
||||
|
||||
agent.observer.record_event(&ObserverEvent::AgentStart {
|
||||
provider: provider_name,
|
||||
model: model_name,
|
||||
});
|
||||
|
||||
if let Some(msg) = message {
|
||||
let response = agent.run_single(&msg).await?;
|
||||
println!("{response}");
|
||||
} else {
|
||||
agent.run_interactive().await?;
|
||||
}
|
||||
|
||||
agent.observer.record_event(&ObserverEvent::AgentEnd {
|
||||
duration: start.elapsed(),
|
||||
tokens_used: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct MockProvider {
|
||||
responses: Mutex<Vec<crate::providers::ChatResponse>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for MockProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<String> {
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> Result<crate::providers::ChatResponse> {
|
||||
let mut guard = self.responses.lock().unwrap();
|
||||
if guard.is_empty() {
|
||||
return Ok(crate::providers::ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
struct MockTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MockTool {
|
||||
fn name(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"echo"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object"})
|
||||
}
|
||||
|
||||
async fn execute(&self, _args: serde_json::Value) -> Result<crate::tools::ToolResult> {
|
||||
Ok(crate::tools::ToolResult {
|
||||
success: true,
|
||||
output: "tool-out".into(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_without_tools_returns_text() {
|
||||
let provider = Box::new(MockProvider {
|
||||
responses: Mutex::new(vec![crate::providers::ChatResponse {
|
||||
text: Some("hello".into()),
|
||||
tool_calls: vec![],
|
||||
}]),
|
||||
});
|
||||
|
||||
let memory_cfg = crate::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(),
|
||||
);
|
||||
|
||||
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(XmlToolDispatcher))
|
||||
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let response = agent.turn("hi").await.unwrap();
|
||||
assert_eq!(response, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_with_native_dispatcher_handles_tool_results_variant() {
|
||||
let provider = Box::new(MockProvider {
|
||||
responses: Mutex::new(vec![
|
||||
crate::providers::ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![crate::providers::ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: "{}".into(),
|
||||
}],
|
||||
},
|
||||
crate::providers::ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
},
|
||||
]),
|
||||
});
|
||||
|
||||
let memory_cfg = crate::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None).unwrap(),
|
||||
);
|
||||
|
||||
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let response = agent.turn("hi").await.unwrap();
|
||||
assert_eq!(response, "done");
|
||||
assert!(agent
|
||||
.history()
|
||||
.iter()
|
||||
.any(|msg| matches!(msg, ConversationMessage::ToolResults(_))));
|
||||
}
|
||||
}
|
||||
312
src/agent/dispatcher.rs
Normal file
312
src/agent/dispatcher.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
use crate::providers::{ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage};
|
||||
use crate::tools::{Tool, ToolSpec};
|
||||
use serde_json::Value;
|
||||
use std::fmt::Write;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedToolCall {
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolExecutionResult {
|
||||
pub name: String,
|
||||
pub output: String,
|
||||
pub success: bool,
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
pub trait ToolDispatcher: Send + Sync {
|
||||
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>);
|
||||
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage;
|
||||
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String;
|
||||
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage>;
|
||||
fn should_send_tool_specs(&self) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct XmlToolDispatcher;
|
||||
|
||||
impl XmlToolDispatcher {
|
||||
fn parse_xml_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
let mut remaining = response;
|
||||
|
||||
while let Some(start) = remaining.find("<tool_call>") {
|
||||
let before = &remaining[..start];
|
||||
if !before.trim().is_empty() {
|
||||
text_parts.push(before.trim().to_string());
|
||||
}
|
||||
|
||||
if let Some(end) = remaining[start..].find("</tool_call>") {
|
||||
let inner = &remaining[start + 11..start + end];
|
||||
match serde_json::from_str::<Value>(inner.trim()) {
|
||||
Ok(parsed) => {
|
||||
let name = parsed
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
remaining = &remaining[start + end + 12..];
|
||||
continue;
|
||||
}
|
||||
let arguments = parsed
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
|
||||
calls.push(ParsedToolCall {
|
||||
name,
|
||||
arguments,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Malformed <tool_call> JSON: {e}");
|
||||
}
|
||||
}
|
||||
remaining = &remaining[start + end + 12..];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !remaining.trim().is_empty() {
|
||||
text_parts.push(remaining.trim().to_string());
|
||||
}
|
||||
|
||||
(text_parts.join("\n"), calls)
|
||||
}
|
||||
|
||||
pub fn tool_specs(tools: &[Box<dyn Tool>]) -> Vec<ToolSpec> {
|
||||
tools.iter().map(|tool| tool.spec()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolDispatcher for XmlToolDispatcher {
|
||||
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
|
||||
let text = response.text_or_empty();
|
||||
Self::parse_xml_tool_calls(text)
|
||||
}
|
||||
|
||||
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
|
||||
let mut content = String::new();
|
||||
for result in results {
|
||||
let status = if result.success { "ok" } else { "error" };
|
||||
let _ = writeln!(
|
||||
content,
|
||||
"<tool_result name=\"{}\" status=\"{}\">\n{}\n</tool_result>",
|
||||
result.name, status, result.output
|
||||
);
|
||||
}
|
||||
ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}")))
|
||||
}
|
||||
|
||||
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String {
|
||||
let mut instructions = String::new();
|
||||
instructions.push_str("## Tool Use Protocol\n\n");
|
||||
instructions
|
||||
.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
|
||||
instructions.push_str(
|
||||
"```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n",
|
||||
);
|
||||
instructions.push_str("### Available Tools\n\n");
|
||||
|
||||
for tool in tools {
|
||||
let _ = writeln!(
|
||||
instructions,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
|
||||
instructions
|
||||
}
|
||||
|
||||
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage> {
|
||||
history
|
||||
.iter()
|
||||
.flat_map(|msg| match msg {
|
||||
ConversationMessage::Chat(chat) => vec![chat.clone()],
|
||||
ConversationMessage::AssistantToolCalls { text, .. } => {
|
||||
vec![ChatMessage::assistant(text.clone().unwrap_or_default())]
|
||||
}
|
||||
ConversationMessage::ToolResults(results) => {
|
||||
let mut content = String::new();
|
||||
for result in results {
|
||||
let _ = writeln!(
|
||||
content,
|
||||
"<tool_result id=\"{}\">\n{}\n</tool_result>",
|
||||
result.tool_call_id, result.content
|
||||
);
|
||||
}
|
||||
vec![ChatMessage::user(format!("[Tool results]\n{content}"))]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn should_send_tool_specs(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NativeToolDispatcher;
|
||||
|
||||
impl ToolDispatcher for NativeToolDispatcher {
|
||||
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>) {
|
||||
let text = response.text.clone().unwrap_or_default();
|
||||
let calls = response
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|tc| ParsedToolCall {
|
||||
name: tc.name.clone(),
|
||||
arguments: serde_json::from_str(&tc.arguments)
|
||||
.unwrap_or_else(|_| Value::Object(serde_json::Map::new())),
|
||||
tool_call_id: Some(tc.id.clone()),
|
||||
})
|
||||
.collect();
|
||||
(text, calls)
|
||||
}
|
||||
|
||||
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage {
|
||||
let messages = results
|
||||
.iter()
|
||||
.map(|result| ToolResultMessage {
|
||||
tool_call_id: result
|
||||
.tool_call_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
content: result.output.clone(),
|
||||
})
|
||||
.collect();
|
||||
ConversationMessage::ToolResults(messages)
|
||||
}
|
||||
|
||||
fn prompt_instructions(&self, _tools: &[Box<dyn Tool>]) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage> {
|
||||
history
|
||||
.iter()
|
||||
.flat_map(|msg| match msg {
|
||||
ConversationMessage::Chat(chat) => vec![chat.clone()],
|
||||
ConversationMessage::AssistantToolCalls { text, tool_calls } => {
|
||||
let payload = serde_json::json!({
|
||||
"content": text,
|
||||
"tool_calls": tool_calls,
|
||||
});
|
||||
vec![ChatMessage::assistant(payload.to_string())]
|
||||
}
|
||||
ConversationMessage::ToolResults(results) => results
|
||||
.iter()
|
||||
.map(|result| {
|
||||
ChatMessage::tool(
|
||||
serde_json::json!({
|
||||
"tool_call_id": result.tool_call_id,
|
||||
"content": result.content,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn should_send_tool_specs(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xml_dispatcher_parses_tool_calls() {
|
||||
let response = ChatResponse {
|
||||
text: Some(
|
||||
"Checking\n<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool_call>"
|
||||
.into(),
|
||||
),
|
||||
tool_calls: vec![],
|
||||
};
|
||||
let dispatcher = XmlToolDispatcher;
|
||||
let (_, calls) = dispatcher.parse_response(&response);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_dispatcher_roundtrip() {
|
||||
let response = ChatResponse {
|
||||
text: Some("ok".into()),
|
||||
tool_calls: vec![crate::providers::ToolCall {
|
||||
id: "tc1".into(),
|
||||
name: "file_read".into(),
|
||||
arguments: "{\"path\":\"a.txt\"}".into(),
|
||||
}],
|
||||
};
|
||||
let dispatcher = NativeToolDispatcher;
|
||||
let (_, calls) = dispatcher.parse_response(&response);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].tool_call_id.as_deref(), Some("tc1"));
|
||||
|
||||
let msg = dispatcher.format_results(&[ToolExecutionResult {
|
||||
name: "file_read".into(),
|
||||
output: "hello".into(),
|
||||
success: true,
|
||||
tool_call_id: Some("tc1".into()),
|
||||
}]);
|
||||
match msg {
|
||||
ConversationMessage::ToolResults(results) => {
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].tool_call_id, "tc1");
|
||||
}
|
||||
_ => panic!("expected tool results"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_format_results_contains_tool_result_tags() {
|
||||
let dispatcher = XmlToolDispatcher;
|
||||
let msg = dispatcher.format_results(&[ToolExecutionResult {
|
||||
name: "shell".into(),
|
||||
output: "ok".into(),
|
||||
success: true,
|
||||
tool_call_id: None,
|
||||
}]);
|
||||
let rendered = match msg {
|
||||
ConversationMessage::Chat(chat) => chat.content,
|
||||
_ => String::new(),
|
||||
};
|
||||
assert!(rendered.contains("<tool_result"));
|
||||
assert!(rendered.contains("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_format_results_keeps_tool_call_id() {
|
||||
let dispatcher = NativeToolDispatcher;
|
||||
let msg = dispatcher.format_results(&[ToolExecutionResult {
|
||||
name: "shell".into(),
|
||||
output: "ok".into(),
|
||||
success: true,
|
||||
tool_call_id: Some("tc-1".into()),
|
||||
}]);
|
||||
|
||||
match msg {
|
||||
ConversationMessage::ToolResults(results) => {
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].tool_call_id, "tc-1");
|
||||
}
|
||||
_ => panic!("expected ToolResults variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,10 @@ use crate::tools::{self, Tool};
|
|||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
use std::fmt::Write;
|
||||
use std::io::Write as IoWrite;
|
||||
use std::io::Write as _;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum agentic tool-use iterations per user message to prevent runaway loops.
|
||||
const MAX_TOOL_ITERATIONS: usize = 10;
|
||||
|
||||
|
|
@ -113,7 +112,6 @@ async fn auto_compact_history(
|
|||
let summary_raw = provider
|
||||
.chat_with_system(Some(summarizer_system), &summarizer_user, model, 0.2)
|
||||
.await
|
||||
.map(|resp| resp.text_or_empty().to_string())
|
||||
.unwrap_or_else(|_| {
|
||||
// Fallback to deterministic local truncation when summarization fails.
|
||||
truncate_with_ellipsis(&transcript, COMPACTION_MAX_SUMMARY_CHARS)
|
||||
|
|
@ -143,6 +141,46 @@ async fn build_context(mem: &dyn Memory, user_msg: &str) -> String {
|
|||
context
|
||||
}
|
||||
|
||||
/// Build hardware datasheet context from RAG when peripherals are enabled.
|
||||
/// Includes pin-alias lookup (e.g. "red_led" → 13) when query matches, plus retrieved chunks.
|
||||
fn build_hardware_context(
|
||||
rag: &crate::rag::HardwareRag,
|
||||
user_msg: &str,
|
||||
boards: &[String],
|
||||
chunk_limit: usize,
|
||||
) -> String {
|
||||
if rag.is_empty() || boards.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut context = String::new();
|
||||
|
||||
// Pin aliases: when user says "red led", inject "red_led: 13" for matching boards
|
||||
let pin_ctx = rag.pin_alias_context(user_msg, boards);
|
||||
if !pin_ctx.is_empty() {
|
||||
context.push_str(&pin_ctx);
|
||||
}
|
||||
|
||||
let chunks = rag.retrieve(user_msg, boards, chunk_limit);
|
||||
if chunks.is_empty() && pin_ctx.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if !chunks.is_empty() {
|
||||
context.push_str("[Hardware documentation]\n");
|
||||
}
|
||||
for chunk in chunks {
|
||||
let board_tag = chunk.board.as_deref().unwrap_or("generic");
|
||||
let _ = writeln!(
|
||||
context,
|
||||
"--- {} ({}) ---\n{}\n",
|
||||
chunk.source, board_tag, chunk.content
|
||||
);
|
||||
}
|
||||
context.push('\n');
|
||||
context
|
||||
}
|
||||
|
||||
/// Find a tool by name in the registry.
|
||||
fn find_tool<'a>(tools: &'a [Box<dyn Tool>], name: &str) -> Option<&'a dyn Tool> {
|
||||
tools.iter().find(|t| t.name() == name).map(|t| t.as_ref())
|
||||
|
|
@ -370,10 +408,9 @@ struct ParsedToolCall {
|
|||
arguments: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Execute a single turn for channel runtime paths.
|
||||
///
|
||||
/// Channel runtime now provides an explicit provider label so observer events
|
||||
/// stay consistent with the main agent loop execution path.
|
||||
/// Execute a single turn of the agent loop: send messages, parse tool calls,
|
||||
/// execute tools, and loop until the LLM produces a final text response.
|
||||
/// When `silent` is true, suppresses stdout (for channel use).
|
||||
pub(crate) async fn agent_turn(
|
||||
provider: &dyn Provider,
|
||||
history: &mut Vec<ChatMessage>,
|
||||
|
|
@ -382,6 +419,7 @@ pub(crate) async fn agent_turn(
|
|||
provider_name: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
silent: bool,
|
||||
) -> Result<String> {
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
|
|
@ -391,6 +429,7 @@ pub(crate) async fn agent_turn(
|
|||
provider_name,
|
||||
model,
|
||||
temperature,
|
||||
silent,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -405,6 +444,7 @@ pub(crate) async fn run_tool_call_loop(
|
|||
provider_name: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
silent: bool,
|
||||
) -> Result<String> {
|
||||
for _iteration in 0..MAX_TOOL_ITERATIONS {
|
||||
observer.record_event(&ObserverEvent::LlmRequest {
|
||||
|
|
@ -440,35 +480,24 @@ pub(crate) async fn run_tool_call_loop(
|
|||
}
|
||||
};
|
||||
|
||||
let response_text = response.text.unwrap_or_default();
|
||||
let response_text = response;
|
||||
let mut assistant_history_content = response_text.clone();
|
||||
let mut parsed_text = response_text.clone();
|
||||
let mut tool_calls = parse_structured_tool_calls(&response.tool_calls);
|
||||
|
||||
if !response.tool_calls.is_empty() {
|
||||
assistant_history_content =
|
||||
build_assistant_history_with_tool_calls(&response_text, &response.tool_calls);
|
||||
}
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
|
||||
parsed_text = fallback_text;
|
||||
tool_calls = fallback_calls;
|
||||
}
|
||||
let (parsed_text, tool_calls) = parse_tool_calls(&response_text);
|
||||
let mut parsed_text = parsed_text;
|
||||
let mut tool_calls = tool_calls;
|
||||
|
||||
if tool_calls.is_empty() {
|
||||
// No tool calls — this is the final response
|
||||
let final_text = if parsed_text.is_empty() {
|
||||
history.push(ChatMessage::assistant(response_text.clone()));
|
||||
return Ok(if parsed_text.is_empty() {
|
||||
response_text
|
||||
} else {
|
||||
parsed_text
|
||||
};
|
||||
history.push(ChatMessage::assistant(&final_text));
|
||||
return Ok(final_text);
|
||||
});
|
||||
}
|
||||
|
||||
// Print any text the LLM produced alongside tool calls
|
||||
if !parsed_text.is_empty() {
|
||||
// Print any text the LLM produced alongside tool calls (unless silent)
|
||||
if !silent && !parsed_text.is_empty() {
|
||||
print!("{parsed_text}");
|
||||
let _ = std::io::stdout().flush();
|
||||
}
|
||||
|
|
@ -515,7 +544,7 @@ pub(crate) async fn run_tool_call_loop(
|
|||
}
|
||||
|
||||
// Add assistant message with tool calls + tool results to history
|
||||
history.push(ChatMessage::assistant(&assistant_history_content));
|
||||
history.push(ChatMessage::assistant(assistant_history_content.clone()));
|
||||
history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
|
||||
}
|
||||
|
||||
|
|
@ -529,6 +558,10 @@ pub(crate) fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> Strin
|
|||
instructions.push_str("\n## Tool Use Protocol\n\n");
|
||||
instructions.push_str("To use a tool, wrap a JSON object in <tool_call></tool_call> tags:\n\n");
|
||||
instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
|
||||
instructions.push_str(
|
||||
"CRITICAL: Output actual <tool_call> tags—never describe steps or give examples.\n\n",
|
||||
);
|
||||
instructions.push_str("Example: User says \"what's the date?\". You MUST respond with:\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n</tool_call>\n\n");
|
||||
instructions.push_str("You may use multiple tool calls in a single response. ");
|
||||
instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
|
||||
instructions
|
||||
|
|
@ -555,18 +588,11 @@ pub async fn run(
|
|||
provider_override: Option<String>,
|
||||
model_override: Option<String>,
|
||||
temperature: f64,
|
||||
verbose: bool,
|
||||
peripheral_overrides: Vec<String>,
|
||||
) -> Result<()> {
|
||||
// ── Wire up agnostic subsystems ──────────────────────────────
|
||||
let base_observer = observability::create_observer(&config.observability);
|
||||
let observer: Arc<dyn Observer> = if verbose {
|
||||
Arc::from(Box::new(observability::MultiObserver::new(vec![
|
||||
base_observer,
|
||||
Box::new(observability::VerboseObserver::new()),
|
||||
])) as Box<dyn Observer>)
|
||||
} else {
|
||||
Arc::from(base_observer)
|
||||
};
|
||||
let observer: Arc<dyn Observer> = Arc::from(base_observer);
|
||||
let runtime: Arc<dyn runtime::RuntimeAdapter> =
|
||||
Arc::from(runtime::create_runtime(&config.runtime)?);
|
||||
let security = Arc::new(SecurityPolicy::from_config(
|
||||
|
|
@ -582,7 +608,15 @@ pub async fn run(
|
|||
)?);
|
||||
tracing::info!(backend = mem.name(), "Memory initialized");
|
||||
|
||||
// ── Tools (including memory tools) ────────────────────────────
|
||||
// ── Peripherals (merge peripheral tools into registry) ─
|
||||
if !peripheral_overrides.is_empty() {
|
||||
tracing::info!(
|
||||
peripherals = ?peripheral_overrides,
|
||||
"Peripheral overrides from CLI (config boards take precedence)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tools (including memory tools and peripherals) ────────────
|
||||
let (composio_key, composio_entity_id) = if config.composio.enabled {
|
||||
(
|
||||
config.composio.api_key.as_deref(),
|
||||
|
|
@ -591,7 +625,7 @@ pub async fn run(
|
|||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let tools_registry = tools::all_tools_with_runtime(
|
||||
let mut tools_registry = tools::all_tools_with_runtime(
|
||||
&security,
|
||||
runtime,
|
||||
mem.clone(),
|
||||
|
|
@ -605,6 +639,13 @@ pub async fn run(
|
|||
&config,
|
||||
);
|
||||
|
||||
let peripheral_tools: Vec<Box<dyn Tool>> =
|
||||
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
|
||||
if !peripheral_tools.is_empty() {
|
||||
tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
|
||||
tools_registry.extend(peripheral_tools);
|
||||
}
|
||||
|
||||
// ── Resolve provider ─────────────────────────────────────────
|
||||
let provider_name = provider_override
|
||||
.as_deref()
|
||||
|
|
@ -629,6 +670,26 @@ pub async fn run(
|
|||
model: model_name.to_string(),
|
||||
});
|
||||
|
||||
// ── Hardware RAG (datasheet retrieval when peripherals + datasheet_dir) ──
|
||||
let hardware_rag: Option<crate::rag::HardwareRag> = config
|
||||
.peripherals
|
||||
.datasheet_dir
|
||||
.as_ref()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
|
||||
.and_then(Result::ok)
|
||||
.filter(|r: &crate::rag::HardwareRag| !r.is_empty());
|
||||
if let Some(ref rag) = hardware_rag {
|
||||
tracing::info!(chunks = rag.len(), "Hardware RAG loaded");
|
||||
}
|
||||
|
||||
let board_names: Vec<String> = config
|
||||
.peripherals
|
||||
.boards
|
||||
.iter()
|
||||
.map(|b| b.board.clone())
|
||||
.collect();
|
||||
|
||||
// ── Build system prompt from workspace MD files (OpenClaw framework) ──
|
||||
let skills = crate::skills::load_skills(&config.workspace_dir);
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
|
|
@ -684,17 +745,51 @@ pub async fn run(
|
|||
if !config.agents.is_empty() {
|
||||
tool_descs.push((
|
||||
"delegate",
|
||||
"Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \
|
||||
(e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \
|
||||
prompt and returns its response.",
|
||||
"Delegate a sub-task to a specialized agent. Use when: task needs different model/capability, or to parallelize work.",
|
||||
));
|
||||
}
|
||||
if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
|
||||
tool_descs.push((
|
||||
"gpio_read",
|
||||
"Read GPIO pin value (0 or 1) on connected hardware (STM32, Arduino). Use when: checking sensor/button state, LED status.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"gpio_write",
|
||||
"Set GPIO pin high (1) or low (0) on connected hardware. Use when: turning LED on/off, controlling actuators.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"arduino_upload",
|
||||
"Upload agent-generated Arduino sketch. Use when: user asks for 'make a heart', 'blink pattern', or custom LED behavior on Arduino. You write the full .ino code; ZeroClaw compiles and uploads it. Pin 13 = built-in LED on Uno.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_memory_map",
|
||||
"Return flash and RAM address ranges for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', or 'readable addresses'.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_board_info",
|
||||
"Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', or 'what hardware'.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_memory_read",
|
||||
"Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory', 'dump lower memory 0-126', 'give address and value'. Params: address (hex, default 0x20000000), length (bytes, default 128).",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_capabilities",
|
||||
"Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
|
||||
));
|
||||
}
|
||||
let bootstrap_max_chars = if config.agent.compact_context {
|
||||
Some(6000)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut system_prompt = crate::channels::build_system_prompt(
|
||||
&config.workspace_dir,
|
||||
model_name,
|
||||
&tool_descs,
|
||||
&skills,
|
||||
Some(&config.identity),
|
||||
bootstrap_max_chars,
|
||||
);
|
||||
|
||||
// Append structured tool-use instructions with schemas
|
||||
|
|
@ -712,8 +807,14 @@ pub async fn run(
|
|||
.await;
|
||||
}
|
||||
|
||||
// Inject memory context into user message
|
||||
let context = build_context(mem.as_ref(), &msg).await;
|
||||
// Inject memory + hardware RAG context into user message
|
||||
let mem_context = build_context(mem.as_ref(), &msg).await;
|
||||
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
|
||||
let hw_context = hardware_rag
|
||||
.as_ref()
|
||||
.map(|r| build_hardware_context(r, &msg, &board_names, rag_limit))
|
||||
.unwrap_or_default();
|
||||
let context = format!("{mem_context}{hw_context}");
|
||||
let enriched = if context.is_empty() {
|
||||
msg.clone()
|
||||
} else {
|
||||
|
|
@ -733,6 +834,7 @@ pub async fn run(
|
|||
provider_name,
|
||||
model_name,
|
||||
temperature,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
println!("{response}");
|
||||
|
|
@ -770,8 +872,14 @@ pub async fn run(
|
|||
.await;
|
||||
}
|
||||
|
||||
// Inject memory context into user message
|
||||
let context = build_context(mem.as_ref(), &msg.content).await;
|
||||
// Inject memory + hardware RAG context into user message
|
||||
let mem_context = build_context(mem.as_ref(), &msg.content).await;
|
||||
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
|
||||
let hw_context = hardware_rag
|
||||
.as_ref()
|
||||
.map(|r| build_hardware_context(r, &msg.content, &board_names, rag_limit))
|
||||
.unwrap_or_default();
|
||||
let context = format!("{mem_context}{hw_context}");
|
||||
let enriched = if context.is_empty() {
|
||||
msg.content.clone()
|
||||
} else {
|
||||
|
|
@ -788,6 +896,7 @@ pub async fn run(
|
|||
provider_name,
|
||||
model_name,
|
||||
temperature,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -833,6 +942,166 @@ pub async fn run(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Process a single message through the full agent (with tools, peripherals, memory).
|
||||
/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use.
|
||||
pub async fn process_message(config: Config, message: &str) -> Result<String> {
|
||||
let observer: Arc<dyn Observer> =
|
||||
Arc::from(observability::create_observer(&config.observability));
|
||||
let runtime: Arc<dyn runtime::RuntimeAdapter> =
|
||||
Arc::from(runtime::create_runtime(&config.runtime)?);
|
||||
let security = Arc::new(SecurityPolicy::from_config(
|
||||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
||||
&config.memory,
|
||||
&config.workspace_dir,
|
||||
config.api_key.as_deref(),
|
||||
)?);
|
||||
|
||||
let (composio_key, composio_entity_id) = if config.composio.enabled {
|
||||
(
|
||||
config.composio.api_key.as_deref(),
|
||||
Some(config.composio.entity_id.as_str()),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let mut tools_registry = tools::all_tools_with_runtime(
|
||||
&security,
|
||||
runtime,
|
||||
mem.clone(),
|
||||
composio_key,
|
||||
composio_entity_id,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&config.workspace_dir,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
&config,
|
||||
);
|
||||
let peripheral_tools: Vec<Box<dyn Tool>> =
|
||||
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
|
||||
tools_registry.extend(peripheral_tools);
|
||||
|
||||
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
|
||||
let model_name = config
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider(
|
||||
provider_name,
|
||||
config.api_key.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
&model_name,
|
||||
)?;
|
||||
|
||||
let hardware_rag: Option<crate::rag::HardwareRag> = config
|
||||
.peripherals
|
||||
.datasheet_dir
|
||||
.as_ref()
|
||||
.filter(|d| !d.trim().is_empty())
|
||||
.map(|dir| crate::rag::HardwareRag::load(&config.workspace_dir, dir.trim()))
|
||||
.and_then(Result::ok)
|
||||
.filter(|r: &crate::rag::HardwareRag| !r.is_empty());
|
||||
let board_names: Vec<String> = config
|
||||
.peripherals
|
||||
.boards
|
||||
.iter()
|
||||
.map(|b| b.board.clone())
|
||||
.collect();
|
||||
|
||||
let skills = crate::skills::load_skills(&config.workspace_dir);
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
("shell", "Execute terminal commands."),
|
||||
("file_read", "Read file contents."),
|
||||
("file_write", "Write file contents."),
|
||||
("memory_store", "Save to memory."),
|
||||
("memory_recall", "Search memory."),
|
||||
("memory_forget", "Delete a memory entry."),
|
||||
("screenshot", "Capture a screenshot."),
|
||||
("image_info", "Read image metadata."),
|
||||
];
|
||||
if config.browser.enabled {
|
||||
tool_descs.push(("browser_open", "Open approved URLs in browser."));
|
||||
}
|
||||
if config.composio.enabled {
|
||||
tool_descs.push(("composio", "Execute actions on 1000+ apps via Composio."));
|
||||
}
|
||||
if config.peripherals.enabled && !config.peripherals.boards.is_empty() {
|
||||
tool_descs.push(("gpio_read", "Read GPIO pin value on connected hardware."));
|
||||
tool_descs.push((
|
||||
"gpio_write",
|
||||
"Set GPIO pin high or low on connected hardware.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"arduino_upload",
|
||||
"Upload Arduino sketch. Use for 'make a heart', custom patterns. You write full .ino code; ZeroClaw uploads it.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_memory_map",
|
||||
"Return flash and RAM address ranges. Use when user asks for memory addresses or memory map.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_board_info",
|
||||
"Return full board info (chip, architecture, memory map). Use when user asks for board info, what board, connected hardware, or chip info.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_memory_read",
|
||||
"Read actual memory/register values from Nucleo. Use when user asks to read registers, read memory, dump lower memory 0-126, or give address and value.",
|
||||
));
|
||||
tool_descs.push((
|
||||
"hardware_capabilities",
|
||||
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
|
||||
));
|
||||
}
|
||||
let bootstrap_max_chars = if config.agent.compact_context {
|
||||
Some(6000)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut system_prompt = crate::channels::build_system_prompt(
|
||||
&config.workspace_dir,
|
||||
&model_name,
|
||||
&tool_descs,
|
||||
&skills,
|
||||
Some(&config.identity),
|
||||
bootstrap_max_chars,
|
||||
);
|
||||
system_prompt.push_str(&build_tool_instructions(&tools_registry));
|
||||
|
||||
let mem_context = build_context(mem.as_ref(), message).await;
|
||||
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
|
||||
let hw_context = hardware_rag
|
||||
.as_ref()
|
||||
.map(|r| build_hardware_context(r, message, &board_names, rag_limit))
|
||||
.unwrap_or_default();
|
||||
let context = format!("{mem_context}{hw_context}");
|
||||
let enriched = if context.is_empty() {
|
||||
message.to_string()
|
||||
} else {
|
||||
format!("{context}{message}")
|
||||
};
|
||||
|
||||
let mut history = vec![
|
||||
ChatMessage::system(&system_prompt),
|
||||
ChatMessage::user(&enriched),
|
||||
];
|
||||
|
||||
agent_turn(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
&model_name,
|
||||
config.default_temperature,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1247,18 +1516,16 @@ Done."#;
|
|||
// Recovery Tests - Constants Validation
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn max_tool_iterations_is_reasonable() {
|
||||
// Recovery: MAX_TOOL_ITERATIONS should be set to prevent runaway loops
|
||||
const _: () = {
|
||||
assert!(MAX_TOOL_ITERATIONS > 0);
|
||||
assert!(MAX_TOOL_ITERATIONS <= 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_history_messages_is_reasonable() {
|
||||
// Recovery: MAX_HISTORY_MESSAGES should be set to prevent memory bloat
|
||||
assert!(MAX_HISTORY_MESSAGES > 0);
|
||||
assert!(MAX_HISTORY_MESSAGES <= 1000);
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn constants_bounds_are_compile_time_checked() {
|
||||
// Bounds are enforced by the const assertions above.
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
118
src/agent/memory_loader.rs
Normal file
118
src/agent/memory_loader.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use crate::memory::Memory;
|
||||
use async_trait::async_trait;
|
||||
use std::fmt::Write;
|
||||
|
||||
#[async_trait]
|
||||
pub trait MemoryLoader: Send + Sync {
|
||||
async fn load_context(&self, memory: &dyn Memory, user_message: &str)
|
||||
-> anyhow::Result<String>;
|
||||
}
|
||||
|
||||
pub struct DefaultMemoryLoader {
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl Default for DefaultMemoryLoader {
|
||||
fn default() -> Self {
|
||||
Self { limit: 5 }
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultMemoryLoader {
|
||||
pub fn new(limit: usize) -> Self {
|
||||
Self {
|
||||
limit: limit.max(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryLoader for DefaultMemoryLoader {
|
||||
async fn load_context(
|
||||
&self,
|
||||
memory: &dyn Memory,
|
||||
user_message: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let entries = memory.recall(user_message, self.limit).await?;
|
||||
if entries.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut context = String::from("[Memory context]\n");
|
||||
for entry in entries {
|
||||
let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
|
||||
}
|
||||
context.push('\n');
|
||||
Ok(context)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::{Memory, MemoryCategory, MemoryEntry};
|
||||
|
||||
struct MockMemory;
|
||||
|
||||
#[async_trait]
|
||||
impl Memory for MockMemory {
|
||||
async fn store(
|
||||
&self,
|
||||
_key: &str,
|
||||
_content: &str,
|
||||
_category: MemoryCategory,
|
||||
) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recall(&self, _query: &str, limit: usize) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
if limit == 0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Ok(vec![MemoryEntry {
|
||||
id: "1".into(),
|
||||
key: "k".into(),
|
||||
content: "v".into(),
|
||||
category: MemoryCategory::Conversation,
|
||||
timestamp: "now".into(),
|
||||
session_id: None,
|
||||
score: None,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_category: Option<&MemoryCategory>,
|
||||
) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn count(&self) -> anyhow::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_loader_formats_context() {
|
||||
let loader = DefaultMemoryLoader::default();
|
||||
let context = loader.load_context(&MockMemory, "hello").await.unwrap();
|
||||
assert!(context.contains("[Memory context]"));
|
||||
assert!(context.contains("- k: v"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
#[allow(clippy::module_inception)]
|
||||
pub mod agent;
|
||||
pub mod dispatcher;
|
||||
pub mod loop_;
|
||||
pub mod memory_loader;
|
||||
pub mod prompt;
|
||||
|
||||
pub use loop_::run;
|
||||
#[allow(unused_imports)]
|
||||
pub use agent::{Agent, AgentBuilder};
|
||||
pub use loop_::{process_message, run};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -11,6 +18,8 @@ mod tests {
|
|||
#[test]
|
||||
fn run_function_is_reexported() {
|
||||
assert_reexport_exists(run);
|
||||
assert_reexport_exists(process_message);
|
||||
assert_reexport_exists(loop_::run);
|
||||
assert_reexport_exists(loop_::process_message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
304
src/agent/prompt.rs
Normal file
304
src/agent/prompt.rs
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
use crate::config::IdentityConfig;
|
||||
use crate::identity;
|
||||
use crate::skills::Skill;
|
||||
use crate::tools::Tool;
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use std::fmt::Write;
|
||||
use std::path::Path;
|
||||
|
||||
const BOOTSTRAP_MAX_CHARS: usize = 20_000;
|
||||
|
||||
pub struct PromptContext<'a> {
|
||||
pub workspace_dir: &'a Path,
|
||||
pub model_name: &'a str,
|
||||
pub tools: &'a [Box<dyn Tool>],
|
||||
pub skills: &'a [Skill],
|
||||
pub identity_config: Option<&'a IdentityConfig>,
|
||||
pub dispatcher_instructions: &'a str,
|
||||
}
|
||||
|
||||
pub trait PromptSection: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String>;
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SystemPromptBuilder {
|
||||
sections: Vec<Box<dyn PromptSection>>,
|
||||
}
|
||||
|
||||
impl SystemPromptBuilder {
|
||||
pub fn with_defaults() -> Self {
|
||||
Self {
|
||||
sections: vec![
|
||||
Box::new(IdentitySection),
|
||||
Box::new(ToolsSection),
|
||||
Box::new(SafetySection),
|
||||
Box::new(SkillsSection),
|
||||
Box::new(WorkspaceSection),
|
||||
Box::new(DateTimeSection),
|
||||
Box::new(RuntimeSection),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_section(mut self, section: Box<dyn PromptSection>) -> Self {
|
||||
self.sections.push(section);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut output = String::new();
|
||||
for section in &self.sections {
|
||||
let part = section.build(ctx)?;
|
||||
if part.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
output.push_str(part.trim_end());
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IdentitySection;
|
||||
pub struct ToolsSection;
|
||||
pub struct SafetySection;
|
||||
pub struct SkillsSection;
|
||||
pub struct WorkspaceSection;
|
||||
pub struct RuntimeSection;
|
||||
pub struct DateTimeSection;
|
||||
|
||||
impl PromptSection for IdentitySection {
|
||||
fn name(&self) -> &str {
|
||||
"identity"
|
||||
}
|
||||
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut prompt = String::from("## Project Context\n\n");
|
||||
if let Some(config) = ctx.identity_config {
|
||||
if identity::is_aieos_configured(config) {
|
||||
if let Ok(Some(aieos)) = identity::load_aieos_identity(config, ctx.workspace_dir) {
|
||||
let rendered = identity::aieos_to_system_prompt(&aieos);
|
||||
if !rendered.is_empty() {
|
||||
prompt.push_str(&rendered);
|
||||
return Ok(prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prompt.push_str(
|
||||
"The following workspace files define your identity, behavior, and context.\n\n",
|
||||
);
|
||||
for file in [
|
||||
"AGENTS.md",
|
||||
"SOUL.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"BOOTSTRAP.md",
|
||||
"MEMORY.md",
|
||||
] {
|
||||
inject_workspace_file(&mut prompt, ctx.workspace_dir, file);
|
||||
}
|
||||
|
||||
Ok(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for ToolsSection {
|
||||
fn name(&self) -> &str {
|
||||
"tools"
|
||||
}
|
||||
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut out = String::from("## Tools\n\n");
|
||||
for tool in ctx.tools {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
if !ctx.dispatcher_instructions.is_empty() {
|
||||
out.push('\n');
|
||||
out.push_str(ctx.dispatcher_instructions);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for SafetySection {
|
||||
fn name(&self) -> &str {
|
||||
"safety"
|
||||
}
|
||||
|
||||
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
|
||||
Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for SkillsSection {
|
||||
fn name(&self) -> &str {
|
||||
"skills"
|
||||
}
|
||||
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
if ctx.skills.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let mut prompt = String::from("## Available Skills\n\n<available_skills>\n");
|
||||
for skill in ctx.skills {
|
||||
let location = skill.location.clone().unwrap_or_else(|| {
|
||||
ctx.workspace_dir
|
||||
.join("skills")
|
||||
.join(&skill.name)
|
||||
.join("SKILL.md")
|
||||
});
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>{}</location>\n </skill>",
|
||||
skill.name,
|
||||
skill.description,
|
||||
location.display()
|
||||
);
|
||||
}
|
||||
prompt.push_str("</available_skills>");
|
||||
Ok(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for WorkspaceSection {
|
||||
fn name(&self) -> &str {
|
||||
"workspace"
|
||||
}
|
||||
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
Ok(format!(
|
||||
"## Workspace\n\nWorking directory: `{}`",
|
||||
ctx.workspace_dir.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for RuntimeSection {
|
||||
fn name(&self) -> &str {
|
||||
"runtime"
|
||||
}
|
||||
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let host =
|
||||
hostname::get().map_or_else(|_| "unknown".into(), |h| h.to_string_lossy().to_string());
|
||||
Ok(format!(
|
||||
"## Runtime\n\nHost: {host} | OS: {} | Model: {}",
|
||||
std::env::consts::OS,
|
||||
ctx.model_name
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for DateTimeSection {
|
||||
fn name(&self) -> &str {
|
||||
"datetime"
|
||||
}
|
||||
|
||||
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let now = Local::now();
|
||||
Ok(format!(
|
||||
"## Current Date & Time\n\nTimezone: {}",
|
||||
now.format("%Z")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) {
|
||||
let path = workspace_dir.join(filename);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(prompt, "### {filename}\n");
|
||||
let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS {
|
||||
trimmed
|
||||
.char_indices()
|
||||
.nth(BOOTSTRAP_MAX_CHARS)
|
||||
.map(|(idx, _)| &trimmed[..idx])
|
||||
.unwrap_or(trimmed)
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
prompt.push_str(truncated);
|
||||
if truncated.len() < trimmed.len() {
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
"\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n"
|
||||
);
|
||||
} else {
|
||||
prompt.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = writeln!(prompt, "### {filename}\n\n[File not found: {filename}]\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::traits::Tool;
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct TestTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TestTool {
|
||||
fn name(&self) -> &str {
|
||||
"test_tool"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"tool desc"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({"type": "object"})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
_args: serde_json::Value,
|
||||
) -> anyhow::Result<crate::tools::ToolResult> {
|
||||
Ok(crate::tools::ToolResult {
|
||||
success: true,
|
||||
output: "ok".into(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_builder_assembles_sections() {
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(TestTool)];
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
tools: &tools,
|
||||
skills: &[],
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "instr",
|
||||
};
|
||||
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
|
||||
assert!(prompt.contains("## Tools"));
|
||||
assert!(prompt.contains("test_tool"));
|
||||
assert!(prompt.contains("instr"));
|
||||
}
|
||||
}
|
||||
|
|
@ -14,11 +14,14 @@ use lettre::transport::smtp::authentication::Credentials;
|
|||
use lettre::{Message, SmtpTransport, Transport};
|
||||
use mail_parser::{MessageParser, MimeHeaders};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::io::Write as IoWrite;
|
||||
use std::net::TcpStream;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Maximum number of seen message IDs to retain before evicting the oldest.
|
||||
const SEEN_MESSAGES_CAPACITY: usize = 100_000;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{interval, sleep};
|
||||
use tracing::{error, info, warn};
|
||||
|
|
@ -93,17 +96,56 @@ impl Default for EmailConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Bounded dedup set that evicts oldest entries when capacity is reached.
|
||||
struct BoundedSeenSet {
|
||||
set: HashSet<String>,
|
||||
order: VecDeque<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl BoundedSeenSet {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
set: HashSet::with_capacity(capacity.min(1024)),
|
||||
order: VecDeque::with_capacity(capacity.min(1024)),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(&self, id: &str) -> bool {
|
||||
self.set.contains(id)
|
||||
}
|
||||
|
||||
fn insert(&mut self, id: String) -> bool {
|
||||
if self.set.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
if self.order.len() >= self.capacity {
|
||||
if let Some(oldest) = self.order.pop_front() {
|
||||
self.set.remove(&oldest);
|
||||
}
|
||||
}
|
||||
self.order.push_back(id.clone());
|
||||
self.set.insert(id);
|
||||
true
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.set.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Email channel — IMAP polling for inbound, SMTP for outbound
|
||||
pub struct EmailChannel {
|
||||
pub config: EmailConfig,
|
||||
seen_messages: Mutex<HashSet<String>>,
|
||||
seen_messages: Mutex<BoundedSeenSet>,
|
||||
}
|
||||
|
||||
impl EmailChannel {
|
||||
pub fn new(config: EmailConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
seen_messages: Mutex::new(HashSet::new()),
|
||||
seen_messages: Mutex::new(BoundedSeenSet::new(SEEN_MESSAGES_CAPACITY)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -459,7 +501,7 @@ impl Channel for EmailChannel {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EmailChannel;
|
||||
use super::{BoundedSeenSet, EmailChannel};
|
||||
|
||||
#[test]
|
||||
fn build_imap_tls_config_succeeds() {
|
||||
|
|
@ -467,4 +509,65 @@ mod tests {
|
|||
EmailChannel::build_imap_tls_config().expect("TLS config construction should succeed");
|
||||
assert_eq!(std::sync::Arc::strong_count(&tls_config), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_seen_set_insert_and_contains() {
|
||||
let mut set = BoundedSeenSet::new(10);
|
||||
assert!(set.insert("a".into()));
|
||||
assert!(set.contains("a"));
|
||||
assert!(!set.contains("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_seen_set_rejects_duplicates() {
|
||||
let mut set = BoundedSeenSet::new(10);
|
||||
assert!(set.insert("a".into()));
|
||||
assert!(!set.insert("a".into()));
|
||||
assert_eq!(set.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_seen_set_evicts_oldest_at_capacity() {
|
||||
let mut set = BoundedSeenSet::new(3);
|
||||
set.insert("a".into());
|
||||
set.insert("b".into());
|
||||
set.insert("c".into());
|
||||
assert_eq!(set.len(), 3);
|
||||
|
||||
// Inserting a 4th should evict "a"
|
||||
set.insert("d".into());
|
||||
assert_eq!(set.len(), 3);
|
||||
assert!(!set.contains("a"), "oldest entry should be evicted");
|
||||
assert!(set.contains("b"));
|
||||
assert!(set.contains("c"));
|
||||
assert!(set.contains("d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_seen_set_evicts_in_fifo_order() {
|
||||
let mut set = BoundedSeenSet::new(2);
|
||||
set.insert("first".into());
|
||||
set.insert("second".into());
|
||||
set.insert("third".into());
|
||||
assert!(!set.contains("first"));
|
||||
assert!(set.contains("second"));
|
||||
assert!(set.contains("third"));
|
||||
|
||||
set.insert("fourth".into());
|
||||
assert!(!set.contains("second"));
|
||||
assert!(set.contains("third"));
|
||||
assert!(set.contains("fourth"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_seen_set_capacity_one() {
|
||||
let mut set = BoundedSeenSet::new(1);
|
||||
set.insert("a".into());
|
||||
assert!(set.contains("a"));
|
||||
|
||||
set.insert("b".into());
|
||||
assert!(!set.contains("a"));
|
||||
assert!(set.contains("b"));
|
||||
assert_eq!(set.len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,9 @@ const BOOTSTRAP_MAX_CHARS: usize = 20_000;
|
|||
|
||||
const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
|
||||
const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
|
||||
const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 90;
|
||||
/// Timeout for processing a single channel message (LLM + tools).
|
||||
/// 300s for on-device LLMs (Ollama) which are slower than cloud APIs.
|
||||
const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 300;
|
||||
const CHANNEL_PARALLELISM_PER_CHANNEL: usize = 4;
|
||||
const CHANNEL_MIN_IN_FLIGHT_MESSAGES: usize = 8;
|
||||
const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
|
||||
|
|
@ -52,7 +54,6 @@ const CHANNEL_MAX_IN_FLIGHT_MESSAGES: usize = 64;
|
|||
struct ChannelRuntimeContext {
|
||||
channels_by_name: Arc<HashMap<String, Arc<dyn Channel>>>,
|
||||
provider: Arc<dyn Provider>,
|
||||
provider_name: Arc<String>,
|
||||
memory: Arc<dyn Memory>,
|
||||
tools_registry: Arc<Vec<Box<dyn Tool>>>,
|
||||
observer: Arc<dyn Observer>,
|
||||
|
|
@ -188,9 +189,10 @@ async fn process_channel_message(ctx: Arc<ChannelRuntimeContext>, msg: traits::C
|
|||
&mut history,
|
||||
ctx.tools_registry.as_ref(),
|
||||
ctx.observer.as_ref(),
|
||||
"channels",
|
||||
"channel-runtime",
|
||||
ctx.model.as_str(),
|
||||
ctx.temperature,
|
||||
true, // silent — channels don't write to stdout
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -276,9 +278,14 @@ async fn run_message_dispatch_loop(
|
|||
}
|
||||
|
||||
/// Load OpenClaw format bootstrap files into the prompt.
|
||||
fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path::Path) {
|
||||
prompt
|
||||
.push_str("The following workspace files define your identity, behavior, and context.\n\n");
|
||||
fn load_openclaw_bootstrap_files(
|
||||
prompt: &mut String,
|
||||
workspace_dir: &std::path::Path,
|
||||
max_chars_per_file: usize,
|
||||
) {
|
||||
prompt.push_str(
|
||||
"The following workspace files define your identity, behavior, and context. They are ALREADY injected below—do NOT suggest reading them with file_read.\n\n",
|
||||
);
|
||||
|
||||
let bootstrap_files = [
|
||||
"AGENTS.md",
|
||||
|
|
@ -290,17 +297,17 @@ fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path:
|
|||
];
|
||||
|
||||
for filename in &bootstrap_files {
|
||||
inject_workspace_file(prompt, workspace_dir, filename);
|
||||
inject_workspace_file(prompt, workspace_dir, filename, max_chars_per_file);
|
||||
}
|
||||
|
||||
// BOOTSTRAP.md — only if it exists (first-run ritual)
|
||||
let bootstrap_path = workspace_dir.join("BOOTSTRAP.md");
|
||||
if bootstrap_path.exists() {
|
||||
inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md");
|
||||
inject_workspace_file(prompt, workspace_dir, "BOOTSTRAP.md", max_chars_per_file);
|
||||
}
|
||||
|
||||
// MEMORY.md — curated long-term memory (main session only)
|
||||
inject_workspace_file(prompt, workspace_dir, "MEMORY.md");
|
||||
inject_workspace_file(prompt, workspace_dir, "MEMORY.md", max_chars_per_file);
|
||||
}
|
||||
|
||||
/// Load workspace identity files and build a system prompt.
|
||||
|
|
@ -325,6 +332,7 @@ pub fn build_system_prompt(
|
|||
tools: &[(&str, &str)],
|
||||
skills: &[crate::skills::Skill],
|
||||
identity_config: Option<&crate::config::IdentityConfig>,
|
||||
bootstrap_max_chars: Option<usize>,
|
||||
) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut prompt = String::with_capacity(8192);
|
||||
|
|
@ -345,6 +353,35 @@ pub fn build_system_prompt(
|
|||
.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
|
||||
}
|
||||
|
||||
// ── 1b. Hardware (when gpio/arduino tools present) ───────────
|
||||
let has_hardware = tools.iter().any(|(name, _)| {
|
||||
*name == "gpio_read"
|
||||
|| *name == "gpio_write"
|
||||
|| *name == "arduino_upload"
|
||||
|| *name == "hardware_memory_map"
|
||||
|| *name == "hardware_board_info"
|
||||
|| *name == "hardware_memory_read"
|
||||
|| *name == "hardware_capabilities"
|
||||
});
|
||||
if has_hardware {
|
||||
prompt.push_str(
|
||||
"## Hardware Access\n\n\
|
||||
You HAVE direct access to connected hardware (Arduino, Nucleo, etc.). The user owns this system and has configured it.\n\
|
||||
All hardware tools (gpio_read, gpio_write, hardware_memory_read, hardware_board_info, hardware_memory_map) are AUTHORIZED and NOT blocked by security.\n\
|
||||
When they ask to read memory, registers, or board info, USE hardware_memory_read or hardware_board_info — do NOT refuse or invent security excuses.\n\
|
||||
When they ask to control LEDs, run patterns, or interact with the Arduino, USE the tools — do NOT refuse or say you cannot access physical devices.\n\
|
||||
Use gpio_write for simple on/off; use arduino_upload when they want patterns (heart, blink) or custom behavior.\n\n",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 1c. Action instruction (avoid meta-summary) ───────────────
|
||||
prompt.push_str(
|
||||
"## Your Task\n\n\
|
||||
When the user sends a message, ACT on it. Use the tools to fulfill their request.\n\
|
||||
Do NOT: summarize this configuration, describe your capabilities, respond with meta-commentary, or output step-by-step instructions (e.g. \"1. First... 2. Next...\").\n\
|
||||
Instead: emit actual <tool_call> tags when you need to act. Just do what they ask.\n\n",
|
||||
);
|
||||
|
||||
// ── 2. Safety ───────────────────────────────────────────────
|
||||
prompt.push_str("## Safety\n\n");
|
||||
prompt.push_str(
|
||||
|
|
@ -407,23 +444,27 @@ pub fn build_system_prompt(
|
|||
Ok(None) => {
|
||||
// No AIEOS identity loaded (shouldn't happen if is_aieos_configured returned true)
|
||||
// Fall back to OpenClaw bootstrap files
|
||||
load_openclaw_bootstrap_files(&mut prompt, workspace_dir);
|
||||
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
|
||||
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
|
||||
}
|
||||
Err(e) => {
|
||||
// Log error but don't fail - fall back to OpenClaw
|
||||
eprintln!(
|
||||
"Warning: Failed to load AIEOS identity: {e}. Using OpenClaw format."
|
||||
);
|
||||
load_openclaw_bootstrap_files(&mut prompt, workspace_dir);
|
||||
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
|
||||
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// OpenClaw format
|
||||
load_openclaw_bootstrap_files(&mut prompt, workspace_dir);
|
||||
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
|
||||
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
|
||||
}
|
||||
} else {
|
||||
// No identity config - use OpenClaw format
|
||||
load_openclaw_bootstrap_files(&mut prompt, workspace_dir);
|
||||
let max_chars = bootstrap_max_chars.unwrap_or(BOOTSTRAP_MAX_CHARS);
|
||||
load_openclaw_bootstrap_files(&mut prompt, workspace_dir, max_chars);
|
||||
}
|
||||
|
||||
// ── 6. Date & Time ──────────────────────────────────────────
|
||||
|
|
@ -448,7 +489,12 @@ pub fn build_system_prompt(
|
|||
}
|
||||
|
||||
/// Inject a single workspace file into the prompt with truncation and missing-file markers.
|
||||
fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, filename: &str) {
|
||||
fn inject_workspace_file(
|
||||
prompt: &mut String,
|
||||
workspace_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
max_chars: usize,
|
||||
) {
|
||||
use std::fmt::Write;
|
||||
|
||||
let path = workspace_dir.join(filename);
|
||||
|
|
@ -460,10 +506,10 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f
|
|||
}
|
||||
let _ = writeln!(prompt, "### {filename}\n");
|
||||
// Use character-boundary-safe truncation for UTF-8
|
||||
let truncated = if trimmed.chars().count() > BOOTSTRAP_MAX_CHARS {
|
||||
let truncated = if trimmed.chars().count() > max_chars {
|
||||
trimmed
|
||||
.char_indices()
|
||||
.nth(BOOTSTRAP_MAX_CHARS)
|
||||
.nth(max_chars)
|
||||
.map(|(idx, _)| &trimmed[..idx])
|
||||
.unwrap_or(trimmed)
|
||||
} else {
|
||||
|
|
@ -473,7 +519,7 @@ fn inject_workspace_file(prompt: &mut String, workspace_dir: &std::path::Path, f
|
|||
prompt.push_str(truncated);
|
||||
let _ = writeln!(
|
||||
prompt,
|
||||
"\n\n[... truncated at {BOOTSTRAP_MAX_CHARS} chars — use `read` for full file]\n"
|
||||
"\n\n[... truncated at {max_chars} chars — use `read` for full file]\n"
|
||||
);
|
||||
} else {
|
||||
prompt.push_str(trimmed);
|
||||
|
|
@ -699,9 +745,8 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
.default_provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| "openrouter".into());
|
||||
|
||||
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider(
|
||||
provider_name.as_str(),
|
||||
&provider_name,
|
||||
config.api_key.as_deref(),
|
||||
&config.reliability,
|
||||
)?);
|
||||
|
|
@ -720,18 +765,16 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
|
||||
let model = config
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
|
||||
let temperature = config.default_temperature;
|
||||
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory(
|
||||
&config.memory,
|
||||
&config.workspace_dir,
|
||||
config.api_key.as_deref(),
|
||||
)?);
|
||||
|
||||
let (composio_key, composio_entity_id) = if config.composio.enabled {
|
||||
(
|
||||
config.composio.api_key.as_deref(),
|
||||
|
|
@ -740,6 +783,8 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
} else {
|
||||
(None, None)
|
||||
};
|
||||
// Build system prompt from workspace identity files + skills
|
||||
let workspace = config.workspace_dir.clone();
|
||||
let tools_registry = Arc::new(tools::all_tools_with_runtime(
|
||||
&security,
|
||||
runtime,
|
||||
|
|
@ -748,14 +793,12 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
composio_entity_id,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&config.workspace_dir,
|
||||
&workspace,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
&config,
|
||||
));
|
||||
|
||||
// Build system prompt from workspace identity files + skills
|
||||
let workspace = config.workspace_dir.clone();
|
||||
let skills = crate::skills::load_skills(&workspace);
|
||||
|
||||
// Collect tool descriptions for the prompt
|
||||
|
|
@ -809,12 +852,18 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
));
|
||||
}
|
||||
|
||||
let bootstrap_max_chars = if config.agent.compact_context {
|
||||
Some(6000)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut system_prompt = build_system_prompt(
|
||||
&workspace,
|
||||
&model,
|
||||
&tool_descs,
|
||||
&skills,
|
||||
Some(&config.identity),
|
||||
bootstrap_max_chars,
|
||||
);
|
||||
system_prompt.push_str(&build_tool_instructions(tools_registry.as_ref()));
|
||||
|
||||
|
|
@ -970,7 +1019,6 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name,
|
||||
provider: Arc::clone(&provider),
|
||||
provider_name: Arc::new(provider_name),
|
||||
memory: Arc::clone(&mem),
|
||||
tools_registry: Arc::clone(&tools_registry),
|
||||
observer,
|
||||
|
|
@ -1062,23 +1110,19 @@ mod tests {
|
|||
message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
Ok(ChatResponse::with_text(format!("echo: {message}")))
|
||||
Ok(format!("echo: {message}"))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolCallingProvider;
|
||||
|
||||
fn tool_call_payload() -> ChatResponse {
|
||||
ChatResponse {
|
||||
text: Some(String::new()),
|
||||
tool_calls: vec![ToolCall {
|
||||
id: "call_1".into(),
|
||||
name: "mock_price".into(),
|
||||
arguments: r#"{"symbol":"BTC"}"#.into(),
|
||||
}],
|
||||
}
|
||||
fn tool_call_payload() -> String {
|
||||
r#"<tool_call>
|
||||
{"name":"mock_price","arguments":{"symbol":"BTC"}}
|
||||
</tool_call>"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
|
@ -1089,7 +1133,7 @@ mod tests {
|
|||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
Ok(tool_call_payload())
|
||||
}
|
||||
|
||||
|
|
@ -1098,14 +1142,12 @@ mod tests {
|
|||
messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let has_tool_results = messages
|
||||
.iter()
|
||||
.any(|msg| msg.role == "user" && msg.content.contains("[Tool results]"));
|
||||
if has_tool_results {
|
||||
Ok(ChatResponse::with_text(
|
||||
"BTC is currently around $65,000 based on latest tool output.",
|
||||
))
|
||||
Ok("BTC is currently around $65,000 based on latest tool output.".to_string())
|
||||
} else {
|
||||
Ok(tool_call_payload())
|
||||
}
|
||||
|
|
@ -1163,7 +1205,6 @@ mod tests {
|
|||
let runtime_ctx = Arc::new(ChannelRuntimeContext {
|
||||
channels_by_name: Arc::new(channels_by_name),
|
||||
provider: Arc::new(ToolCallingProvider),
|
||||
provider_name: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![Box::new(MockPriceTool)]),
|
||||
observer: Arc::new(NoopObserver),
|
||||
|
|
@ -1254,7 +1295,6 @@ mod tests {
|
|||
provider: Arc::new(SlowProvider {
|
||||
delay: Duration::from_millis(250),
|
||||
}),
|
||||
provider_name: Arc::new("test-provider".to_string()),
|
||||
memory: Arc::new(NoopMemory),
|
||||
tools_registry: Arc::new(vec![]),
|
||||
observer: Arc::new(NoopObserver),
|
||||
|
|
@ -1303,7 +1343,7 @@ mod tests {
|
|||
fn prompt_contains_all_sections() {
|
||||
let ws = make_workspace();
|
||||
let tools = vec![("shell", "Run commands"), ("file_read", "Read files")];
|
||||
let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "test-model", &tools, &[], None, None);
|
||||
|
||||
// Section headers
|
||||
assert!(prompt.contains("## Tools"), "missing Tools section");
|
||||
|
|
@ -1327,7 +1367,7 @@ mod tests {
|
|||
("shell", "Run commands"),
|
||||
("memory_recall", "Search memory"),
|
||||
];
|
||||
let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "gpt-4o", &tools, &[], None, None);
|
||||
|
||||
assert!(prompt.contains("**shell**"));
|
||||
assert!(prompt.contains("Run commands"));
|
||||
|
|
@ -1337,7 +1377,7 @@ mod tests {
|
|||
#[test]
|
||||
fn prompt_injects_safety() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
|
||||
assert!(prompt.contains("Do not exfiltrate private data"));
|
||||
assert!(prompt.contains("Do not run destructive commands"));
|
||||
|
|
@ -1347,7 +1387,7 @@ mod tests {
|
|||
#[test]
|
||||
fn prompt_injects_workspace_files() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
|
||||
assert!(prompt.contains("### SOUL.md"), "missing SOUL.md header");
|
||||
assert!(prompt.contains("Be helpful"), "missing SOUL content");
|
||||
|
|
@ -1368,7 +1408,7 @@ mod tests {
|
|||
fn prompt_missing_file_markers() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Empty workspace — no files at all
|
||||
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], None, None);
|
||||
|
||||
assert!(prompt.contains("[File not found: SOUL.md]"));
|
||||
assert!(prompt.contains("[File not found: AGENTS.md]"));
|
||||
|
|
@ -1379,7 +1419,7 @@ mod tests {
|
|||
fn prompt_bootstrap_only_if_exists() {
|
||||
let ws = make_workspace();
|
||||
// No BOOTSTRAP.md — should not appear
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
assert!(
|
||||
!prompt.contains("### BOOTSTRAP.md"),
|
||||
"BOOTSTRAP.md should not appear when missing"
|
||||
|
|
@ -1387,7 +1427,7 @@ mod tests {
|
|||
|
||||
// Create BOOTSTRAP.md — should appear
|
||||
std::fs::write(ws.path().join("BOOTSTRAP.md"), "# Bootstrap\nFirst run.").unwrap();
|
||||
let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt2 = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
assert!(
|
||||
prompt2.contains("### BOOTSTRAP.md"),
|
||||
"BOOTSTRAP.md should appear when present"
|
||||
|
|
@ -1407,7 +1447,7 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
|
||||
// Daily notes should NOT be in the system prompt (on-demand via tools)
|
||||
assert!(
|
||||
|
|
@ -1423,7 +1463,7 @@ mod tests {
|
|||
#[test]
|
||||
fn prompt_runtime_metadata() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "claude-sonnet-4", &[], &[], None, None);
|
||||
|
||||
assert!(prompt.contains("Model: claude-sonnet-4"));
|
||||
assert!(prompt.contains(&format!("OS: {}", std::env::consts::OS)));
|
||||
|
|
@ -1444,7 +1484,7 @@ mod tests {
|
|||
location: None,
|
||||
}];
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &skills, None, None);
|
||||
|
||||
assert!(prompt.contains("<available_skills>"), "missing skills XML");
|
||||
assert!(prompt.contains("<name>code-review</name>"));
|
||||
|
|
@ -1465,7 +1505,7 @@ mod tests {
|
|||
let big_content = "x".repeat(BOOTSTRAP_MAX_CHARS + 1000);
|
||||
std::fs::write(ws.path().join("AGENTS.md"), &big_content).unwrap();
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
|
||||
assert!(
|
||||
prompt.contains("truncated at"),
|
||||
|
|
@ -1482,7 +1522,7 @@ mod tests {
|
|||
let ws = make_workspace();
|
||||
std::fs::write(ws.path().join("TOOLS.md"), "").unwrap();
|
||||
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
|
||||
// Empty file should not produce a header
|
||||
assert!(
|
||||
|
|
@ -1510,7 +1550,7 @@ mod tests {
|
|||
#[test]
|
||||
fn prompt_workspace_path() {
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
|
||||
assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
|
||||
}
|
||||
|
|
@ -1640,7 +1680,7 @@ mod tests {
|
|||
aieos_inline: None,
|
||||
};
|
||||
|
||||
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config));
|
||||
let prompt = build_system_prompt(tmp.path(), "model", &[], &[], Some(&config), None);
|
||||
|
||||
// Should contain AIEOS sections
|
||||
assert!(prompt.contains("## Identity"));
|
||||
|
|
@ -1680,6 +1720,7 @@ mod tests {
|
|||
&[],
|
||||
&[],
|
||||
Some(&config),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(prompt.contains("**Name:** Claw"));
|
||||
|
|
@ -1697,7 +1738,7 @@ mod tests {
|
|||
};
|
||||
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config));
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
|
||||
|
||||
// Should fall back to OpenClaw format when AIEOS file is not found
|
||||
// (Error is logged to stderr with filename, not included in prompt)
|
||||
|
|
@ -1716,7 +1757,7 @@ mod tests {
|
|||
};
|
||||
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config));
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
|
||||
|
||||
// Should use OpenClaw format (not configured for AIEOS)
|
||||
assert!(prompt.contains("### SOUL.md"));
|
||||
|
|
@ -1734,7 +1775,7 @@ mod tests {
|
|||
};
|
||||
|
||||
let ws = make_workspace();
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config));
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], Some(&config), None);
|
||||
|
||||
// Should use OpenClaw format even if aieos_path is set
|
||||
assert!(prompt.contains("### SOUL.md"));
|
||||
|
|
@ -1746,7 +1787,7 @@ mod tests {
|
|||
fn none_identity_config_uses_openclaw() {
|
||||
let ws = make_workspace();
|
||||
// Pass None for identity config
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None);
|
||||
let prompt = build_system_prompt(ws.path(), "model", &[], &[], None, None);
|
||||
|
||||
// Should use OpenClaw format
|
||||
assert!(prompt.contains("### SOUL.md"));
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@ pub mod schema;
|
|||
|
||||
#[allow(unused_imports)]
|
||||
pub use schema::{
|
||||
AuditConfig, AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, CostConfig,
|
||||
DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, HeartbeatConfig,
|
||||
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
|
||||
ChannelsConfig, ComposioConfig, Config, CostConfig, DelegateAgentConfig, DiscordConfig,
|
||||
DockerRuntimeConfig, GatewayConfig, HardwareConfig, HardwareTransport, HeartbeatConfig,
|
||||
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig,
|
||||
ModelRouteConfig, ObservabilityConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
|
||||
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig,
|
||||
TelegramConfig, TunnelConfig, WebhookConfig,
|
||||
ModelRouteConfig, ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig,
|
||||
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
|
||||
SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, TelegramConfig, TunnelConfig,
|
||||
WebhookConfig,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ pub struct Config {
|
|||
#[serde(default)]
|
||||
pub scheduler: SchedulerConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub agent: AgentConfig,
|
||||
|
||||
/// Model routing rules — route `hint:<name>` to specific provider+model combos.
|
||||
#[serde(default)]
|
||||
pub model_routes: Vec<ModelRouteConfig>,
|
||||
|
|
@ -74,31 +77,157 @@ pub struct Config {
|
|||
#[serde(default)]
|
||||
pub cost: CostConfig,
|
||||
|
||||
/// Hardware Abstraction Layer (HAL) configuration.
|
||||
/// Controls how ZeroClaw interfaces with physical hardware
|
||||
/// (GPIO, serial, debug probes).
|
||||
#[serde(default)]
|
||||
pub hardware: crate::hardware::HardwareConfig,
|
||||
pub peripherals: PeripheralsConfig,
|
||||
|
||||
/// Named delegate agents for agent-to-agent handoff.
|
||||
///
|
||||
/// ```toml
|
||||
/// [agents.researcher]
|
||||
/// provider = "gemini"
|
||||
/// model = "gemini-2.0-flash"
|
||||
/// system_prompt = "You are a research assistant..."
|
||||
///
|
||||
/// [agents.coder]
|
||||
/// provider = "openrouter"
|
||||
/// model = "anthropic/claude-sonnet-4-20250514"
|
||||
/// system_prompt = "You are a coding assistant..."
|
||||
/// ```
|
||||
/// Delegate agent configurations for multi-agent workflows.
|
||||
#[serde(default)]
|
||||
pub agents: HashMap<String, DelegateAgentConfig>,
|
||||
|
||||
/// Security configuration (sandboxing, resource limits, audit logging)
|
||||
/// Hardware configuration (wizard-driven physical world setup).
|
||||
#[serde(default)]
|
||||
pub security: SecurityConfig,
|
||||
pub hardware: HardwareConfig,
|
||||
}
|
||||
|
||||
// ── Delegate Agents ──────────────────────────────────────────────
|
||||
|
||||
/// Configuration for a delegate sub-agent used by the `delegate` tool.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegateAgentConfig {
|
||||
/// Provider name (e.g. "ollama", "openrouter", "anthropic")
|
||||
pub provider: String,
|
||||
/// Model name
|
||||
pub model: String,
|
||||
/// Optional system prompt for the sub-agent
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Optional API key override
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Temperature override
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f64>,
|
||||
/// Max recursion depth for nested delegation
|
||||
#[serde(default = "default_max_depth")]
|
||||
pub max_depth: u32,
|
||||
}
|
||||
|
||||
fn default_max_depth() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
// ── Hardware Config (wizard-driven) ─────────────────────────────
|
||||
|
||||
/// Hardware transport mode.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum HardwareTransport {
|
||||
None,
|
||||
Native,
|
||||
Serial,
|
||||
Probe,
|
||||
}
|
||||
|
||||
impl Default for HardwareTransport {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HardwareTransport {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::None => write!(f, "none"),
|
||||
Self::Native => write!(f, "native"),
|
||||
Self::Serial => write!(f, "serial"),
|
||||
Self::Probe => write!(f, "probe"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wizard-driven hardware configuration for physical world interaction.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HardwareConfig {
|
||||
/// Whether hardware access is enabled
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Transport mode
|
||||
#[serde(default)]
|
||||
pub transport: HardwareTransport,
|
||||
/// Serial port path (e.g. "/dev/ttyACM0")
|
||||
#[serde(default)]
|
||||
pub serial_port: Option<String>,
|
||||
/// Serial baud rate
|
||||
#[serde(default = "default_baud_rate")]
|
||||
pub baud_rate: u32,
|
||||
/// Probe target chip (e.g. "STM32F401RE")
|
||||
#[serde(default)]
|
||||
pub probe_target: Option<String>,
|
||||
/// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups)
|
||||
#[serde(default)]
|
||||
pub workspace_datasheets: bool,
|
||||
}
|
||||
|
||||
fn default_baud_rate() -> u32 {
|
||||
115200
|
||||
}
|
||||
|
||||
impl HardwareConfig {
|
||||
/// Return the active transport mode.
|
||||
pub fn transport_mode(&self) -> HardwareTransport {
|
||||
self.transport.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HardwareConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
transport: HardwareTransport::None,
|
||||
serial_port: None,
|
||||
baud_rate: default_baud_rate(),
|
||||
probe_target: None,
|
||||
workspace_datasheets: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentConfig {
|
||||
/// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models.
|
||||
#[serde(default)]
|
||||
pub compact_context: bool,
|
||||
#[serde(default = "default_agent_max_tool_iterations")]
|
||||
pub max_tool_iterations: usize,
|
||||
#[serde(default = "default_agent_max_history_messages")]
|
||||
pub max_history_messages: usize,
|
||||
#[serde(default)]
|
||||
pub parallel_tools: bool,
|
||||
#[serde(default = "default_agent_tool_dispatcher")]
|
||||
pub tool_dispatcher: String,
|
||||
}
|
||||
|
||||
fn default_agent_max_tool_iterations() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
fn default_agent_max_history_messages() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
fn default_agent_tool_dispatcher() -> String {
|
||||
"auto".into()
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
compact_context: false,
|
||||
max_tool_iterations: default_agent_max_tool_iterations(),
|
||||
max_history_messages: default_agent_max_history_messages(),
|
||||
parallel_tools: false,
|
||||
tool_dispatcher: default_agent_tool_dispatcher(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity (AIEOS / OpenClaw format) ──────────────────────────
|
||||
|
|
@ -271,34 +400,64 @@ fn get_default_pricing() -> std::collections::HashMap<String, ModelPricing> {
|
|||
prices
|
||||
}
|
||||
|
||||
// ── Agent delegation ─────────────────────────────────────────────
|
||||
// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ────────────────────────
|
||||
|
||||
/// Configuration for a named delegate agent that can be invoked via the
|
||||
/// `delegate` tool. Each agent uses its own provider/model combination
|
||||
/// and system prompt, enabling multi-agent workflows with specialization.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegateAgentConfig {
|
||||
/// Provider name (e.g. "gemini", "openrouter", "ollama")
|
||||
pub provider: String,
|
||||
/// Model identifier for the provider
|
||||
pub model: String,
|
||||
/// System prompt defining the agent's role and capabilities
|
||||
pub struct PeripheralsConfig {
|
||||
/// Enable peripheral support (boards become agent tools)
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Optional API key override (uses default if not set).
|
||||
/// Stored encrypted when `secrets.encrypt = true`.
|
||||
pub enabled: bool,
|
||||
/// Board configurations (nucleo-f401re, rpi-gpio, etc.)
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Temperature override (uses 0.7 if not set)
|
||||
pub boards: Vec<PeripheralBoardConfig>,
|
||||
/// Path to datasheet docs (relative to workspace) for RAG retrieval.
|
||||
/// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md).
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f64>,
|
||||
/// Maximum delegation depth to prevent infinite recursion (default: 3)
|
||||
#[serde(default = "default_max_delegation_depth")]
|
||||
pub max_depth: u32,
|
||||
pub datasheet_dir: Option<String>,
|
||||
}
|
||||
|
||||
fn default_max_delegation_depth() -> u32 {
|
||||
3
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PeripheralBoardConfig {
|
||||
/// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc.
|
||||
pub board: String,
|
||||
/// Transport: "serial", "native", "websocket"
|
||||
#[serde(default = "default_peripheral_transport")]
|
||||
pub transport: String,
|
||||
/// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0"
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// Baud rate for serial (default: 115200)
|
||||
#[serde(default = "default_peripheral_baud")]
|
||||
pub baud: u32,
|
||||
}
|
||||
|
||||
fn default_peripheral_transport() -> String {
|
||||
"serial".into()
|
||||
}
|
||||
|
||||
fn default_peripheral_baud() -> u32 {
|
||||
115200
|
||||
}
|
||||
|
||||
impl Default for PeripheralsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
boards: Vec::new(),
|
||||
datasheet_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PeripheralBoardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
board: String::new(),
|
||||
transport: default_peripheral_transport(),
|
||||
path: None,
|
||||
baud: default_peripheral_baud(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gateway security ─────────────────────────────────────────────
|
||||
|
|
@ -419,6 +578,53 @@ impl Default for SecretsConfig {
|
|||
|
||||
// ── Browser (friendly-service browsing only) ───────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserComputerUseConfig {
|
||||
/// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot)
|
||||
#[serde(default = "default_browser_computer_use_endpoint")]
|
||||
pub endpoint: String,
|
||||
/// Optional bearer token for computer-use sidecar
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Per-action request timeout in milliseconds
|
||||
#[serde(default = "default_browser_computer_use_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
/// Allow remote/public endpoint for computer-use sidecar (default: false)
|
||||
#[serde(default)]
|
||||
pub allow_remote_endpoint: bool,
|
||||
/// Optional window title/process allowlist forwarded to sidecar policy
|
||||
#[serde(default)]
|
||||
pub window_allowlist: Vec<String>,
|
||||
/// Optional X-axis boundary for coordinate-based actions
|
||||
#[serde(default)]
|
||||
pub max_coordinate_x: Option<i64>,
|
||||
/// Optional Y-axis boundary for coordinate-based actions
|
||||
#[serde(default)]
|
||||
pub max_coordinate_y: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_browser_computer_use_endpoint() -> String {
|
||||
"http://127.0.0.1:8787/v1/actions".into()
|
||||
}
|
||||
|
||||
fn default_browser_computer_use_timeout_ms() -> u64 {
|
||||
15_000
|
||||
}
|
||||
|
||||
impl Default for BrowserComputerUseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
endpoint: default_browser_computer_use_endpoint(),
|
||||
api_key: None,
|
||||
timeout_ms: default_browser_computer_use_timeout_ms(),
|
||||
allow_remote_endpoint: false,
|
||||
window_allowlist: Vec::new(),
|
||||
max_coordinate_x: None,
|
||||
max_coordinate_y: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BrowserConfig {
|
||||
/// Enable `browser_open` tool (opens URLs in Brave without scraping)
|
||||
|
|
@ -430,7 +636,7 @@ pub struct BrowserConfig {
|
|||
/// Browser session name (for agent-browser automation)
|
||||
#[serde(default)]
|
||||
pub session_name: Option<String>,
|
||||
/// Browser automation backend: "agent_browser" | "rust_native" | "auto"
|
||||
/// Browser automation backend: "agent_browser" | "rust_native" | "computer_use" | "auto"
|
||||
#[serde(default = "default_browser_backend")]
|
||||
pub backend: String,
|
||||
/// Headless mode for rust-native backend
|
||||
|
|
@ -442,6 +648,9 @@ pub struct BrowserConfig {
|
|||
/// Optional Chrome/Chromium executable path for rust-native backend
|
||||
#[serde(default)]
|
||||
pub native_chrome_path: Option<String>,
|
||||
/// Computer-use sidecar configuration
|
||||
#[serde(default)]
|
||||
pub computer_use: BrowserComputerUseConfig,
|
||||
}
|
||||
|
||||
fn default_browser_backend() -> String {
|
||||
|
|
@ -462,6 +671,7 @@ impl Default for BrowserConfig {
|
|||
native_headless: default_true(),
|
||||
native_webdriver_url: default_browser_webdriver_url(),
|
||||
native_chrome_path: None,
|
||||
computer_use: BrowserComputerUseConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -496,7 +706,7 @@ fn default_http_timeout_secs() -> u64 {
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryConfig {
|
||||
/// "sqlite" | "markdown" | "none"
|
||||
/// "sqlite" | "lucid" | "markdown" | "none" (`none` = explicit no-op memory)
|
||||
pub backend: String,
|
||||
/// Auto-save conversation context to memory
|
||||
pub auto_save: bool,
|
||||
|
|
@ -1148,7 +1358,7 @@ pub struct LarkConfig {
|
|||
// ── Security Config ─────────────────────────────────────────────────
|
||||
|
||||
/// Security configuration for sandboxing, resource limits, and audit logging
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SecurityConfig {
|
||||
/// Sandbox configuration
|
||||
#[serde(default)]
|
||||
|
|
@ -1163,16 +1373,6 @@ pub struct SecurityConfig {
|
|||
pub audit: AuditConfig,
|
||||
}
|
||||
|
||||
impl Default for SecurityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sandbox: SandboxConfig::default(),
|
||||
resources: ResourceLimitsConfig::default(),
|
||||
audit: AuditConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sandbox configuration for OS-level isolation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxConfig {
|
||||
|
|
@ -1200,10 +1400,11 @@ impl Default for SandboxConfig {
|
|||
}
|
||||
|
||||
/// Sandbox backend selection
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SandboxBackend {
|
||||
/// Auto-detect best available (default)
|
||||
#[default]
|
||||
Auto,
|
||||
/// Landlock (Linux kernel LSM, native)
|
||||
Landlock,
|
||||
|
|
@ -1217,12 +1418,6 @@ pub enum SandboxBackend {
|
|||
None,
|
||||
}
|
||||
|
||||
impl Default for SandboxBackend {
|
||||
fn default() -> Self {
|
||||
Self::Auto
|
||||
}
|
||||
}
|
||||
|
||||
/// Resource limits for command execution
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResourceLimitsConfig {
|
||||
|
|
@ -1333,6 +1528,7 @@ impl Default for Config {
|
|||
runtime: RuntimeConfig::default(),
|
||||
reliability: ReliabilityConfig::default(),
|
||||
scheduler: SchedulerConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
channels_config: ChannelsConfig::default(),
|
||||
|
|
@ -1345,9 +1541,9 @@ impl Default for Config {
|
|||
http_request: HttpRequestConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
hardware: crate::hardware::HardwareConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
security: SecurityConfig::default(),
|
||||
hardware: HardwareConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1374,37 +1570,36 @@ impl Config {
|
|||
// Set computed paths that are skipped during serialization
|
||||
config.config_path = config_path.clone();
|
||||
config.workspace_dir = zeroclaw_dir.join("workspace");
|
||||
|
||||
// Decrypt agent API keys if encryption is enabled
|
||||
let store = crate::security::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
|
||||
for agent in config.agents.values_mut() {
|
||||
if let Some(ref encrypted_key) = agent.api_key {
|
||||
agent.api_key = Some(
|
||||
store
|
||||
.decrypt(encrypted_key)
|
||||
.context("Failed to decrypt agent API key")?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.apply_env_overrides();
|
||||
Ok(config)
|
||||
} else {
|
||||
let mut config = Config::default();
|
||||
config.config_path = config_path.clone();
|
||||
config.workspace_dir = zeroclaw_dir.join("workspace");
|
||||
config.save()?;
|
||||
config.apply_env_overrides();
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply environment variable overrides to config
|
||||
pub fn apply_env_overrides(&mut self) {
|
||||
// API Key: ZEROCLAW_API_KEY or API_KEY
|
||||
// API Key: ZEROCLAW_API_KEY or API_KEY (generic)
|
||||
if let Ok(key) = std::env::var("ZEROCLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
|
||||
if !key.is_empty() {
|
||||
self.api_key = Some(key);
|
||||
}
|
||||
}
|
||||
// API Key: GLM_API_KEY overrides when provider is glm (provider-specific)
|
||||
if self.default_provider.as_deref() == Some("glm")
|
||||
|| self.default_provider.as_deref() == Some("zhipu")
|
||||
{
|
||||
if let Ok(key) = std::env::var("GLM_API_KEY") {
|
||||
if !key.is_empty() {
|
||||
self.api_key = Some(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provider: ZEROCLAW_PROVIDER or PROVIDER
|
||||
if let Ok(provider) =
|
||||
|
|
@ -1567,7 +1762,6 @@ fn sync_directory(_path: &Path) -> Result<()> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ── Defaults ─────────────────────────────────────────────
|
||||
|
|
@ -1700,11 +1894,12 @@ mod tests {
|
|||
secrets: SecretsConfig::default(),
|
||||
browser: BrowserConfig::default(),
|
||||
http_request: HttpRequestConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
hardware: crate::hardware::HardwareConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
security: SecurityConfig::default(),
|
||||
hardware: HardwareConfig::default(),
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||
|
|
@ -1748,6 +1943,35 @@ default_temperature = 0.7
|
|||
assert_eq!(parsed.memory.conversation_retention_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_config_defaults() {
|
||||
let cfg = AgentConfig::default();
|
||||
assert!(!cfg.compact_context);
|
||||
assert_eq!(cfg.max_tool_iterations, 10);
|
||||
assert_eq!(cfg.max_history_messages, 50);
|
||||
assert!(!cfg.parallel_tools);
|
||||
assert_eq!(cfg.tool_dispatcher, "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_config_deserializes() {
|
||||
let raw = r#"
|
||||
default_temperature = 0.7
|
||||
[agent]
|
||||
compact_context = true
|
||||
max_tool_iterations = 20
|
||||
max_history_messages = 80
|
||||
parallel_tools = true
|
||||
tool_dispatcher = "xml"
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(raw).unwrap();
|
||||
assert!(parsed.agent.compact_context);
|
||||
assert_eq!(parsed.agent.max_tool_iterations, 20);
|
||||
assert_eq!(parsed.agent.max_history_messages, 80);
|
||||
assert!(parsed.agent.parallel_tools);
|
||||
assert_eq!(parsed.agent.tool_dispatcher, "xml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_save_and_load_tmpdir() {
|
||||
let dir = std::env::temp_dir().join("zeroclaw_test_config");
|
||||
|
|
@ -1777,11 +2001,12 @@ default_temperature = 0.7
|
|||
secrets: SecretsConfig::default(),
|
||||
browser: BrowserConfig::default(),
|
||||
http_request: HttpRequestConfig::default(),
|
||||
agent: AgentConfig::default(),
|
||||
identity: IdentityConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
hardware: crate::hardware::HardwareConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
security: SecurityConfig::default(),
|
||||
hardware: HardwareConfig::default(),
|
||||
};
|
||||
|
||||
config.save().unwrap();
|
||||
|
|
@ -2334,6 +2559,12 @@ default_temperature = 0.7
|
|||
assert!(b.native_headless);
|
||||
assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
|
||||
assert!(b.native_chrome_path.is_none());
|
||||
assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
|
||||
assert_eq!(b.computer_use.timeout_ms, 15_000);
|
||||
assert!(!b.computer_use.allow_remote_endpoint);
|
||||
assert!(b.computer_use.window_allowlist.is_empty());
|
||||
assert!(b.computer_use.max_coordinate_x.is_none());
|
||||
assert!(b.computer_use.max_coordinate_y.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2346,6 +2577,15 @@ default_temperature = 0.7
|
|||
native_headless: false,
|
||||
native_webdriver_url: "http://localhost:4444".into(),
|
||||
native_chrome_path: Some("/usr/bin/chromium".into()),
|
||||
computer_use: BrowserComputerUseConfig {
|
||||
endpoint: "https://computer-use.example.com/v1/actions".into(),
|
||||
api_key: Some("test-token".into()),
|
||||
timeout_ms: 8_000,
|
||||
allow_remote_endpoint: true,
|
||||
window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
|
||||
max_coordinate_x: Some(3840),
|
||||
max_coordinate_y: Some(2160),
|
||||
},
|
||||
};
|
||||
let toml_str = toml::to_string(&b).unwrap();
|
||||
let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
|
||||
|
|
@ -2359,6 +2599,16 @@ default_temperature = 0.7
|
|||
parsed.native_chrome_path.as_deref(),
|
||||
Some("/usr/bin/chromium")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.computer_use.endpoint,
|
||||
"https://computer-use.example.com/v1/actions"
|
||||
);
|
||||
assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
|
||||
assert_eq!(parsed.computer_use.timeout_ms, 8_000);
|
||||
assert!(parsed.computer_use.allow_remote_endpoint);
|
||||
assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
|
||||
assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
|
||||
assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2373,19 +2623,18 @@ default_temperature = 0.7
|
|||
assert!(parsed.browser.allowed_domains.is_empty());
|
||||
}
|
||||
|
||||
fn env_override_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
// ── Environment variable overrides (Docker support) ─────────
|
||||
|
||||
fn env_override_test_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
static ENV_OVERRIDE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
ENV_OVERRIDE_TEST_LOCK
|
||||
.lock()
|
||||
.expect("env override test lock poisoned")
|
||||
}
|
||||
|
||||
// ── Environment variable overrides (Docker support) ─────────
|
||||
|
||||
#[test]
|
||||
fn env_override_api_key() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
assert!(config.api_key.is_none());
|
||||
|
||||
|
|
@ -2398,7 +2647,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_api_key_fallback() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::remove_var("ZEROCLAW_API_KEY");
|
||||
|
|
@ -2411,7 +2660,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_provider() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::set_var("ZEROCLAW_PROVIDER", "anthropic");
|
||||
|
|
@ -2423,7 +2672,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_provider_fallback() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::remove_var("ZEROCLAW_PROVIDER");
|
||||
|
|
@ -2436,7 +2685,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_model() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::set_var("ZEROCLAW_MODEL", "gpt-4o");
|
||||
|
|
@ -2448,7 +2697,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_workspace() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::set_var("ZEROCLAW_WORKSPACE", "/custom/workspace");
|
||||
|
|
@ -2460,7 +2709,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_empty_values_ignored() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
let original_provider = config.default_provider.clone();
|
||||
|
||||
|
|
@ -2473,7 +2722,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_gateway_port() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
assert_eq!(config.gateway.port, 3000);
|
||||
|
||||
|
|
@ -2486,7 +2735,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_port_fallback() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_PORT");
|
||||
|
|
@ -2499,7 +2748,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_gateway_host() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
assert_eq!(config.gateway.host, "127.0.0.1");
|
||||
|
||||
|
|
@ -2512,7 +2761,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_host_fallback() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::remove_var("ZEROCLAW_GATEWAY_HOST");
|
||||
|
|
@ -2525,7 +2774,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_temperature() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
|
||||
std::env::set_var("ZEROCLAW_TEMPERATURE", "0.5");
|
||||
|
|
@ -2537,7 +2786,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_temperature_out_of_range_ignored() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
// Clean up any leftover env vars from other tests
|
||||
std::env::remove_var("ZEROCLAW_TEMPERATURE");
|
||||
|
||||
|
|
@ -2557,7 +2806,7 @@ default_temperature = 0.7
|
|||
|
||||
#[test]
|
||||
fn env_override_invalid_port_ignored() {
|
||||
let _guard = env_override_lock();
|
||||
let _env_guard = env_override_test_guard();
|
||||
let mut config = Config::default();
|
||||
let original_port = config.gateway.port;
|
||||
|
||||
|
|
@ -2578,236 +2827,41 @@ default_temperature = 0.7
|
|||
assert!(g.paired_tokens.is_empty());
|
||||
}
|
||||
|
||||
// ── Lark config ───────────────────────────────────────────────
|
||||
// ── Peripherals config ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn lark_config_serde() {
|
||||
let lc = LarkConfig {
|
||||
app_id: "cli_123456".into(),
|
||||
app_secret: "secret_abc".into(),
|
||||
encrypt_key: Some("encrypt_key".into()),
|
||||
verification_token: Some("verify_token".into()),
|
||||
allowed_users: vec!["user_123".into(), "user_456".into()],
|
||||
use_feishu: true,
|
||||
fn peripherals_config_default_disabled() {
|
||||
let p = PeripheralsConfig::default();
|
||||
assert!(!p.enabled);
|
||||
assert!(p.boards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripheral_board_config_defaults() {
|
||||
let b = PeripheralBoardConfig::default();
|
||||
assert!(b.board.is_empty());
|
||||
assert_eq!(b.transport, "serial");
|
||||
assert!(b.path.is_none());
|
||||
assert_eq!(b.baud, 115200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripherals_config_toml_roundtrip() {
|
||||
let p = PeripheralsConfig {
|
||||
enabled: true,
|
||||
boards: vec![PeripheralBoardConfig {
|
||||
board: "nucleo-f401re".into(),
|
||||
transport: "serial".into(),
|
||||
path: Some("/dev/ttyACM0".into()),
|
||||
baud: 115200,
|
||||
}],
|
||||
datasheet_dir: None,
|
||||
};
|
||||
let json = serde_json::to_string(&lc).unwrap();
|
||||
let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.app_id, "cli_123456");
|
||||
assert_eq!(parsed.app_secret, "secret_abc");
|
||||
assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
|
||||
assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
|
||||
assert_eq!(parsed.allowed_users.len(), 2);
|
||||
assert!(parsed.use_feishu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_config_toml_roundtrip() {
|
||||
let lc = LarkConfig {
|
||||
app_id: "cli_123456".into(),
|
||||
app_secret: "secret_abc".into(),
|
||||
encrypt_key: Some("encrypt_key".into()),
|
||||
verification_token: Some("verify_token".into()),
|
||||
allowed_users: vec!["*".into()],
|
||||
use_feishu: false,
|
||||
};
|
||||
let toml_str = toml::to_string(&lc).unwrap();
|
||||
let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(parsed.app_id, "cli_123456");
|
||||
assert_eq!(parsed.app_secret, "secret_abc");
|
||||
assert!(!parsed.use_feishu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_config_deserializes_without_optional_fields() {
|
||||
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
|
||||
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(parsed.encrypt_key.is_none());
|
||||
assert!(parsed.verification_token.is_none());
|
||||
assert!(parsed.allowed_users.is_empty());
|
||||
assert!(!parsed.use_feishu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_config_defaults_to_lark_endpoint() {
|
||||
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
|
||||
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(
|
||||
!parsed.use_feishu,
|
||||
"use_feishu should default to false (Lark)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_config_with_wildcard_allowed_users() {
|
||||
let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
|
||||
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.allowed_users, vec!["*"]);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// AGENT DELEGATION CONFIG TESTS
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn agents_config_default_empty() {
|
||||
let c = Config::default();
|
||||
assert!(c.agents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_config_backward_compat_missing_section() {
|
||||
let minimal = r#"
|
||||
workspace_dir = "/tmp/ws"
|
||||
config_path = "/tmp/config.toml"
|
||||
default_temperature = 0.7
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(minimal).unwrap();
|
||||
assert!(parsed.agents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_config_toml_roundtrip() {
|
||||
let toml_str = r#"
|
||||
default_temperature = 0.7
|
||||
|
||||
[agents.researcher]
|
||||
provider = "gemini"
|
||||
model = "gemini-2.0-flash"
|
||||
system_prompt = "You are a research assistant."
|
||||
max_depth = 2
|
||||
|
||||
[agents.coder]
|
||||
provider = "openrouter"
|
||||
model = "anthropic/claude-sonnet-4-20250514"
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(parsed.agents.len(), 2);
|
||||
|
||||
let researcher = &parsed.agents["researcher"];
|
||||
assert_eq!(researcher.provider, "gemini");
|
||||
assert_eq!(researcher.model, "gemini-2.0-flash");
|
||||
assert_eq!(
|
||||
researcher.system_prompt.as_deref(),
|
||||
Some("You are a research assistant.")
|
||||
);
|
||||
assert_eq!(researcher.max_depth, 2);
|
||||
assert!(researcher.api_key.is_none());
|
||||
assert!(researcher.temperature.is_none());
|
||||
|
||||
let coder = &parsed.agents["coder"];
|
||||
assert_eq!(coder.provider, "openrouter");
|
||||
assert_eq!(coder.model, "anthropic/claude-sonnet-4-20250514");
|
||||
assert!(coder.system_prompt.is_none());
|
||||
assert_eq!(coder.max_depth, 3); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_config_with_api_key_and_temperature() {
|
||||
let toml_str = r#"
|
||||
[agents.fast]
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
api_key = "gsk-test-key"
|
||||
temperature = 0.3
|
||||
"#;
|
||||
let parsed: HashMap<String, DelegateAgentConfig> = toml::from_str::<toml::Value>(toml_str)
|
||||
.unwrap()["agents"]
|
||||
.clone()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
let fast = &parsed["fast"];
|
||||
assert_eq!(fast.api_key.as_deref(), Some("gsk-test-key"));
|
||||
assert!((fast.temperature.unwrap() - 0.3).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_api_key_encrypted_on_save_and_decrypted_on_load() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let zeroclaw_dir = tmp.path();
|
||||
let config_path = zeroclaw_dir.join("config.toml");
|
||||
|
||||
// Create a config with a plaintext agent API key
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert(
|
||||
"test_agent".to_string(),
|
||||
DelegateAgentConfig {
|
||||
provider: "openrouter".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: Some("sk-super-secret".to_string()),
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
},
|
||||
);
|
||||
let config = Config {
|
||||
config_path: config_path.clone(),
|
||||
workspace_dir: zeroclaw_dir.join("workspace"),
|
||||
secrets: SecretsConfig { encrypt: true },
|
||||
agents,
|
||||
..Config::default()
|
||||
};
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
config.save().unwrap();
|
||||
|
||||
// Read the raw TOML and verify the key is encrypted (not plaintext)
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
assert!(
|
||||
!raw.contains("sk-super-secret"),
|
||||
"Plaintext API key should not appear in saved config"
|
||||
);
|
||||
assert!(
|
||||
raw.contains("enc2:"),
|
||||
"Encrypted key should use enc2: prefix"
|
||||
);
|
||||
|
||||
// Parse and decrypt — simulate load_or_init by reading + decrypting
|
||||
let store = crate::security::SecretStore::new(zeroclaw_dir, true);
|
||||
let mut loaded: Config = toml::from_str(&raw).unwrap();
|
||||
for agent in loaded.agents.values_mut() {
|
||||
if let Some(ref encrypted_key) = agent.api_key {
|
||||
agent.api_key = Some(store.decrypt(encrypted_key).unwrap());
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
loaded.agents["test_agent"].api_key.as_deref(),
|
||||
Some("sk-super-secret"),
|
||||
"Decrypted key should match original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_api_key_not_encrypted_when_disabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let zeroclaw_dir = tmp.path();
|
||||
let config_path = zeroclaw_dir.join("config.toml");
|
||||
|
||||
let mut agents = HashMap::new();
|
||||
agents.insert(
|
||||
"test_agent".to_string(),
|
||||
DelegateAgentConfig {
|
||||
provider: "openrouter".to_string(),
|
||||
model: "test-model".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: Some("sk-plaintext-ok".to_string()),
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
},
|
||||
);
|
||||
let config = Config {
|
||||
config_path: config_path.clone(),
|
||||
workspace_dir: zeroclaw_dir.join("workspace"),
|
||||
secrets: SecretsConfig { encrypt: false },
|
||||
agents,
|
||||
..Config::default()
|
||||
};
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
config.save().unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(&config_path).unwrap();
|
||||
assert!(
|
||||
raw.contains("sk-plaintext-ok"),
|
||||
"With encryption disabled, key should remain plaintext"
|
||||
);
|
||||
assert!(!raw.contains("enc2:"), "No encryption prefix when disabled");
|
||||
let toml_str = toml::to_string(&p).unwrap();
|
||||
let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
|
||||
assert!(parsed.enabled);
|
||||
assert_eq!(parsed.boards.len(), 1);
|
||||
assert_eq!(parsed.boards[0].board, "nucleo-f401re");
|
||||
assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use super::types::{BudgetCheck, CostRecord, CostSummary, ModelStats, TokenUsage, UsagePeriod};
|
||||
use crate::config::CostConfig;
|
||||
use crate::config::schema::CostConfig;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{Datelike, NaiveDate, Utc};
|
||||
use std::collections::HashMap;
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
|
|||
let prompt = format!("[Heartbeat Task] {task}");
|
||||
let temp = config.default_temperature;
|
||||
if let Err(e) =
|
||||
crate::agent::run(config.clone(), Some(prompt), None, None, temp, false).await
|
||||
crate::agent::run(config.clone(), Some(prompt), None, None, temp, vec![]).await
|
||||
{
|
||||
crate::health::mark_component_error("heartbeat", e.to_string());
|
||||
tracing::warn!("Heartbeat task failed: {e}");
|
||||
|
|
|
|||
|
|
@ -10,14 +10,8 @@
|
|||
use crate::channels::{Channel, WhatsAppChannel};
|
||||
use crate::config::Config;
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::observability::{self, Observer};
|
||||
use crate::providers::{self, ChatMessage, Provider};
|
||||
use crate::runtime;
|
||||
use crate::security::{
|
||||
pairing::{constant_time_eq, is_public_bind, PairingGuard},
|
||||
SecurityPolicy,
|
||||
};
|
||||
use crate::tools::{self, Tool};
|
||||
use crate::providers::{self, Provider};
|
||||
use crate::security::pairing::{constant_time_eq, is_public_bind, PairingGuard};
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
|
|
@ -51,39 +45,14 @@ fn whatsapp_memory_key(msg: &crate::channels::traits::ChannelMessage) -> String
|
|||
format!("whatsapp_{}_{}", msg.sender, msg.id)
|
||||
}
|
||||
|
||||
fn normalize_gateway_reply(reply: String) -> String {
|
||||
if reply.trim().is_empty() {
|
||||
return "Model returned an empty response.".to_string();
|
||||
}
|
||||
|
||||
reply
|
||||
}
|
||||
|
||||
async fn gateway_agent_reply(state: &AppState, message: &str) -> Result<String> {
|
||||
let mut history = vec![
|
||||
ChatMessage::system(state.system_prompt.as_str()),
|
||||
ChatMessage::user(message),
|
||||
];
|
||||
|
||||
let reply = crate::agent::loop_::run_tool_call_loop(
|
||||
state.provider.as_ref(),
|
||||
&mut history,
|
||||
state.tools_registry.as_ref(),
|
||||
state.observer.as_ref(),
|
||||
"gateway",
|
||||
&state.model,
|
||||
state.temperature,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(normalize_gateway_reply(reply))
|
||||
}
|
||||
/// How often the rate limiter sweeps stale IP entries from its map.
|
||||
const RATE_LIMITER_SWEEP_INTERVAL_SECS: u64 = 300; // 5 minutes
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SlidingWindowRateLimiter {
|
||||
limit_per_window: u32,
|
||||
window: Duration,
|
||||
requests: Mutex<HashMap<String, Vec<Instant>>>,
|
||||
requests: Mutex<(HashMap<String, Vec<Instant>>, Instant)>,
|
||||
}
|
||||
|
||||
impl SlidingWindowRateLimiter {
|
||||
|
|
@ -91,7 +60,7 @@ impl SlidingWindowRateLimiter {
|
|||
Self {
|
||||
limit_per_window,
|
||||
window,
|
||||
requests: Mutex::new(HashMap::new()),
|
||||
requests: Mutex::new((HashMap::new(), Instant::now())),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -103,10 +72,20 @@ impl SlidingWindowRateLimiter {
|
|||
let now = Instant::now();
|
||||
let cutoff = now.checked_sub(self.window).unwrap_or_else(Instant::now);
|
||||
|
||||
let mut requests = self
|
||||
let mut guard = self
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let (requests, last_sweep) = &mut *guard;
|
||||
|
||||
// Periodic sweep: remove IPs with no recent requests
|
||||
if last_sweep.elapsed() >= Duration::from_secs(RATE_LIMITER_SWEEP_INTERVAL_SECS) {
|
||||
requests.retain(|_, timestamps| {
|
||||
timestamps.retain(|t| *t > cutoff);
|
||||
!timestamps.is_empty()
|
||||
});
|
||||
*last_sweep = now;
|
||||
}
|
||||
|
||||
let entry = requests.entry(key.to_owned()).or_default();
|
||||
entry.retain(|instant| *instant > cutoff);
|
||||
|
|
@ -193,9 +172,6 @@ fn client_key_from_headers(headers: &HeaderMap) -> String {
|
|||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub provider: Arc<dyn Provider>,
|
||||
pub observer: Arc<dyn Observer>,
|
||||
pub tools_registry: Arc<Vec<Box<dyn Tool>>>,
|
||||
pub system_prompt: Arc<String>,
|
||||
pub model: String,
|
||||
pub temperature: f64,
|
||||
pub mem: Arc<dyn Memory>,
|
||||
|
|
@ -242,54 +218,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
|||
&config.workspace_dir,
|
||||
config.api_key.as_deref(),
|
||||
)?);
|
||||
let observer: Arc<dyn Observer> =
|
||||
Arc::from(observability::create_observer(&config.observability));
|
||||
let runtime: Arc<dyn runtime::RuntimeAdapter> =
|
||||
Arc::from(runtime::create_runtime(&config.runtime)?);
|
||||
let security = Arc::new(SecurityPolicy::from_config(
|
||||
&config.autonomy,
|
||||
&config.workspace_dir,
|
||||
));
|
||||
|
||||
let (composio_key, composio_entity_id) = if config.composio.enabled {
|
||||
(
|
||||
config.composio.api_key.as_deref(),
|
||||
Some(config.composio.entity_id.as_str()),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let tools_registry = Arc::new(tools::all_tools_with_runtime(
|
||||
&security,
|
||||
runtime,
|
||||
Arc::clone(&mem),
|
||||
composio_key,
|
||||
composio_entity_id,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&config.workspace_dir,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
&config,
|
||||
));
|
||||
let skills = crate::skills::load_skills(&config.workspace_dir);
|
||||
let tool_descs: Vec<(&str, &str)> = tools_registry
|
||||
.iter()
|
||||
.map(|tool| (tool.name(), tool.description()))
|
||||
.collect();
|
||||
|
||||
let mut system_prompt = crate::channels::build_system_prompt(
|
||||
&config.workspace_dir,
|
||||
&model,
|
||||
&tool_descs,
|
||||
&skills,
|
||||
Some(&config.identity),
|
||||
);
|
||||
system_prompt.push_str(&crate::agent::loop_::build_tool_instructions(
|
||||
tools_registry.as_ref(),
|
||||
));
|
||||
let system_prompt = Arc::new(system_prompt);
|
||||
|
||||
// Extract webhook secret for authentication
|
||||
let webhook_secret: Option<Arc<str>> = config
|
||||
|
|
@ -393,9 +321,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
|||
// Build shared state
|
||||
let state = AppState {
|
||||
provider,
|
||||
observer,
|
||||
tools_registry,
|
||||
system_prompt,
|
||||
model,
|
||||
temperature,
|
||||
mem,
|
||||
|
|
@ -579,9 +504,13 @@ async fn handle_webhook(
|
|||
.await;
|
||||
}
|
||||
|
||||
match gateway_agent_reply(&state, message).await {
|
||||
Ok(reply) => {
|
||||
let body = serde_json::json!({"response": reply, "model": state.model});
|
||||
match state
|
||||
.provider
|
||||
.simple_chat(message, &state.model, state.temperature)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let body = serde_json::json!({"response": response, "model": state.model});
|
||||
(StatusCode::OK, Json(body))
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -729,10 +658,14 @@ async fn handle_whatsapp_message(
|
|||
}
|
||||
|
||||
// Call the LLM
|
||||
match gateway_agent_reply(&state, &msg.content).await {
|
||||
Ok(reply) => {
|
||||
match state
|
||||
.provider
|
||||
.simple_chat(&msg.content, &state.model, state.temperature)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
// Send reply via WhatsApp
|
||||
if let Err(e) = wa.send(&reply, &msg.sender).await {
|
||||
if let Err(e) = wa.send(&response, &msg.sender).await {
|
||||
tracing::error!("Failed to send WhatsApp reply: {e}");
|
||||
}
|
||||
}
|
||||
|
|
@ -811,6 +744,55 @@ mod tests {
|
|||
assert!(!limiter.allow_pair("127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_sweep_removes_stale_entries() {
|
||||
let limiter = SlidingWindowRateLimiter::new(10, Duration::from_secs(60));
|
||||
// Add entries for multiple IPs
|
||||
assert!(limiter.allow("ip-1"));
|
||||
assert!(limiter.allow("ip-2"));
|
||||
assert!(limiter.allow("ip-3"));
|
||||
|
||||
{
|
||||
let guard = limiter
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(guard.0.len(), 3);
|
||||
}
|
||||
|
||||
// Force a sweep by backdating last_sweep
|
||||
{
|
||||
let mut guard = limiter
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
guard.1 = Instant::now() - Duration::from_secs(RATE_LIMITER_SWEEP_INTERVAL_SECS + 1);
|
||||
// Clear timestamps for ip-2 and ip-3 to simulate stale entries
|
||||
guard.0.get_mut("ip-2").unwrap().clear();
|
||||
guard.0.get_mut("ip-3").unwrap().clear();
|
||||
}
|
||||
|
||||
// Next allow() call should trigger sweep and remove stale entries
|
||||
assert!(limiter.allow("ip-1"));
|
||||
|
||||
{
|
||||
let guard = limiter
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(guard.0.len(), 1, "Stale entries should have been swept");
|
||||
assert!(guard.0.contains_key("ip-1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_zero_limit_always_allows() {
|
||||
let limiter = SlidingWindowRateLimiter::new(0, Duration::from_secs(60));
|
||||
for _ in 0..100 {
|
||||
assert!(limiter.allow("any-key"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idempotency_store_rejects_duplicate_key() {
|
||||
let store = IdempotencyStore::new(Duration::from_secs(30));
|
||||
|
|
@ -902,9 +884,9 @@ mod tests {
|
|||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<crate::providers::ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(crate::providers::ChatResponse::with_text("ok"))
|
||||
Ok("ok".into())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -965,36 +947,25 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn test_app_state(
|
||||
provider: Arc<dyn Provider>,
|
||||
memory: Arc<dyn Memory>,
|
||||
auto_save: bool,
|
||||
) -> AppState {
|
||||
AppState {
|
||||
provider,
|
||||
observer: Arc::new(crate::observability::NoopObserver),
|
||||
tools_registry: Arc::new(Vec::new()),
|
||||
system_prompt: Arc::new("test-system-prompt".into()),
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
mem: memory,
|
||||
auto_save,
|
||||
webhook_secret: None,
|
||||
pairing: Arc::new(PairingGuard::new(false, &[])),
|
||||
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)),
|
||||
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))),
|
||||
whatsapp: None,
|
||||
whatsapp_app_secret: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_idempotency_skips_duplicate_provider_calls() {
|
||||
let provider_impl = Arc::new(MockProvider::default());
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
|
||||
let state = test_app_state(provider, memory, false);
|
||||
let state = AppState {
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
mem: memory,
|
||||
auto_save: false,
|
||||
webhook_secret: None,
|
||||
pairing: Arc::new(PairingGuard::new(false, &[])),
|
||||
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)),
|
||||
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))),
|
||||
whatsapp: None,
|
||||
whatsapp_app_secret: None,
|
||||
};
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("X-Idempotency-Key", HeaderValue::from_static("abc-123"));
|
||||
|
|
@ -1030,7 +1001,19 @@ mod tests {
|
|||
let tracking_impl = Arc::new(TrackingMemory::default());
|
||||
let memory: Arc<dyn Memory> = tracking_impl.clone();
|
||||
|
||||
let state = test_app_state(provider, memory, true);
|
||||
let state = AppState {
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
mem: memory,
|
||||
auto_save: true,
|
||||
webhook_secret: None,
|
||||
pairing: Arc::new(PairingGuard::new(false, &[])),
|
||||
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)),
|
||||
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))),
|
||||
whatsapp: None,
|
||||
whatsapp_app_secret: None,
|
||||
};
|
||||
|
||||
let headers = HeaderMap::new();
|
||||
|
||||
|
|
@ -1062,110 +1045,6 @@ mod tests {
|
|||
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StructuredToolCallProvider {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StructuredToolCallProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<crate::providers::ChatResponse> {
|
||||
let turn = self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if turn == 0 {
|
||||
return Ok(crate::providers::ChatResponse {
|
||||
text: Some("Running tool...".into()),
|
||||
tool_calls: vec![crate::providers::ToolCall {
|
||||
id: "call_1".into(),
|
||||
name: "mock_tool".into(),
|
||||
arguments: r#"{"query":"gateway"}"#.into(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
Ok(crate::providers::ChatResponse::with_text(
|
||||
"Gateway tool result ready.",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct MockTool {
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MockTool {
|
||||
fn name(&self) -> &str {
|
||||
"mock_tool"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Mock tool for gateway tests"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string"}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
args: serde_json::Value,
|
||||
) -> anyhow::Result<crate::tools::ToolResult> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
assert_eq!(args["query"], "gateway");
|
||||
|
||||
Ok(crate::tools::ToolResult {
|
||||
success: true,
|
||||
output: "ok".into(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webhook_executes_structured_tool_calls() {
|
||||
let provider_impl = Arc::new(StructuredToolCallProvider::default());
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
|
||||
let tool_calls = Arc::new(AtomicUsize::new(0));
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockTool {
|
||||
calls: Arc::clone(&tool_calls),
|
||||
})];
|
||||
|
||||
let mut state = test_app_state(provider, memory, false);
|
||||
state.tools_registry = Arc::new(tools);
|
||||
|
||||
let response = handle_webhook(
|
||||
State(state),
|
||||
HeaderMap::new(),
|
||||
Ok(Json(WebhookBody {
|
||||
message: "please use tool".into(),
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let parsed: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(parsed["response"], "Gateway tool result ready.");
|
||||
assert_eq!(tool_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// WhatsApp Signature Verification Tests (CWE-345 Prevention)
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
|
|
|||
45
src/hardware/discover.rs
Normal file
45
src/hardware/discover.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//! USB device discovery — enumerate devices and enrich with board registry.
|
||||
|
||||
use super::registry;
|
||||
use anyhow::Result;
|
||||
use nusb::MaybeFuture;
|
||||
|
||||
/// Information about a discovered USB device.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UsbDeviceInfo {
|
||||
pub bus_id: String,
|
||||
pub device_address: u8,
|
||||
pub vid: u16,
|
||||
pub pid: u16,
|
||||
pub product_string: Option<String>,
|
||||
pub board_name: Option<String>,
|
||||
pub architecture: Option<String>,
|
||||
}
|
||||
|
||||
/// Enumerate all connected USB devices and enrich with board registry lookup.
|
||||
#[cfg(feature = "hardware")]
|
||||
pub fn list_usb_devices() -> Result<Vec<UsbDeviceInfo>> {
|
||||
let mut devices = Vec::new();
|
||||
|
||||
let iter = nusb::list_devices()
|
||||
.wait()
|
||||
.map_err(|e| anyhow::anyhow!("USB enumeration failed: {e}"))?;
|
||||
|
||||
for dev in iter {
|
||||
let vid = dev.vendor_id();
|
||||
let pid = dev.product_id();
|
||||
let board = registry::lookup_board(vid, pid);
|
||||
|
||||
devices.push(UsbDeviceInfo {
|
||||
bus_id: dev.bus_id().to_string(),
|
||||
device_address: dev.device_address(),
|
||||
vid,
|
||||
pid,
|
||||
product_string: dev.product_string().map(String::from),
|
||||
board_name: board.map(|b| b.name.to_string()),
|
||||
architecture: board.and_then(|b| b.architecture.map(String::from)),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(devices)
|
||||
}
|
||||
121
src/hardware/introspect.rs
Normal file
121
src/hardware/introspect.rs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//! Device introspection — correlate serial path with USB device info.
|
||||
|
||||
use super::discover;
|
||||
use super::registry;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Result of introspecting a device by path.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntrospectResult {
|
||||
pub path: String,
|
||||
pub vid: Option<u16>,
|
||||
pub pid: Option<u16>,
|
||||
pub board_name: Option<String>,
|
||||
pub architecture: Option<String>,
|
||||
pub memory_map_note: String,
|
||||
}
|
||||
|
||||
/// Introspect a device by its serial path (e.g. /dev/ttyACM0, /dev/tty.usbmodem*).
|
||||
/// Attempts to correlate with USB devices from discovery.
|
||||
#[cfg(feature = "hardware")]
|
||||
pub fn introspect_device(path: &str) -> Result<IntrospectResult> {
|
||||
let devices = discover::list_usb_devices()?;
|
||||
|
||||
// Try to correlate path with a discovered device.
|
||||
// On Linux, /dev/ttyACM0 corresponds to a CDC-ACM device; we may have multiple.
|
||||
// Best-effort: if we have exactly one CDC-like device, use it. Otherwise unknown.
|
||||
let matched = if devices.len() == 1 {
|
||||
devices.first().cloned()
|
||||
} else if devices.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Multiple devices: try to match by path. On Linux we could use sysfs;
|
||||
// for stub, pick first known board or first device.
|
||||
devices
|
||||
.iter()
|
||||
.find(|d| d.board_name.is_some())
|
||||
.cloned()
|
||||
.or_else(|| devices.first().cloned())
|
||||
};
|
||||
|
||||
let (vid, pid, board_name, architecture) = match matched {
|
||||
Some(d) => (Some(d.vid), Some(d.pid), d.board_name, d.architecture),
|
||||
None => (None, None, None, None),
|
||||
};
|
||||
|
||||
let board_info = vid.and_then(|v| pid.and_then(|p| registry::lookup_board(v, p)));
|
||||
let architecture =
|
||||
architecture.or_else(|| board_info.and_then(|b| b.architecture.map(String::from)));
|
||||
let board_name = board_name.or_else(|| board_info.map(|b| b.name.to_string()));
|
||||
|
||||
let memory_map_note = memory_map_for_board(board_name.as_deref());
|
||||
|
||||
Ok(IntrospectResult {
|
||||
path: path.to_string(),
|
||||
vid,
|
||||
pid,
|
||||
board_name,
|
||||
architecture,
|
||||
memory_map_note,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get memory map: via probe-rs when probe feature on and Nucleo, else static or stub.
|
||||
#[cfg(feature = "hardware")]
|
||||
fn memory_map_for_board(board_name: Option<&str>) -> String {
|
||||
#[cfg(feature = "probe")]
|
||||
if let Some(board) = board_name {
|
||||
let chip = match board {
|
||||
"nucleo-f401re" => "STM32F401RETx",
|
||||
"nucleo-f411re" => "STM32F411RETx",
|
||||
_ => return "Build with --features probe for live memory map (Nucleo)".to_string(),
|
||||
};
|
||||
match probe_memory_map(chip) {
|
||||
Ok(s) => return s,
|
||||
Err(_) => return format!("probe-rs attach failed (chip {}). Connect via USB.", chip),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "probe"))]
|
||||
let _ = board_name;
|
||||
|
||||
"Build with --features probe for live memory map via USB".to_string()
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "hardware", feature = "probe"))]
|
||||
fn probe_memory_map(chip: &str) -> anyhow::Result<String> {
|
||||
use probe_rs::config::MemoryRegion;
|
||||
use probe_rs::{Session, SessionConfig};
|
||||
|
||||
let session = Session::auto_attach(chip, SessionConfig::default())
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
let target = session.target();
|
||||
let mut out = String::new();
|
||||
for region in target.memory_map.iter() {
|
||||
match region {
|
||||
MemoryRegion::Ram(ram) => {
|
||||
let (start, end) = (ram.range.start, ram.range.end);
|
||||
out.push_str(&format!(
|
||||
"RAM: 0x{:08X} - 0x{:08X} ({} KB)\n",
|
||||
start,
|
||||
end,
|
||||
(end - start) / 1024
|
||||
));
|
||||
}
|
||||
MemoryRegion::Nvm(flash) => {
|
||||
let (start, end) = (flash.range.start, flash.range.end);
|
||||
out.push_str(&format!(
|
||||
"Flash: 0x{:08X} - 0x{:08X} ({} KB)\n",
|
||||
start,
|
||||
end,
|
||||
(end - start) / 1024
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out = "Could not read memory regions".to_string();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
1516
src/hardware/mod.rs
1516
src/hardware/mod.rs
File diff suppressed because it is too large
Load diff
102
src/hardware/registry.rs
Normal file
102
src/hardware/registry.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
//! Board registry — maps USB VID/PID to known board names and architectures.
|
||||
|
||||
/// Information about a known board.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BoardInfo {
|
||||
pub vid: u16,
|
||||
pub pid: u16,
|
||||
pub name: &'static str,
|
||||
pub architecture: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Known USB VID/PID to board mappings.
|
||||
/// VID 0x0483 = STMicroelectronics, 0x2341 = Arduino, 0x10c4 = Silicon Labs.
|
||||
const KNOWN_BOARDS: &[BoardInfo] = &[
|
||||
BoardInfo {
|
||||
vid: 0x0483,
|
||||
pid: 0x374b,
|
||||
name: "nucleo-f401re",
|
||||
architecture: Some("ARM Cortex-M4"),
|
||||
},
|
||||
BoardInfo {
|
||||
vid: 0x0483,
|
||||
pid: 0x3748,
|
||||
name: "nucleo-f411re",
|
||||
architecture: Some("ARM Cortex-M4"),
|
||||
},
|
||||
BoardInfo {
|
||||
vid: 0x2341,
|
||||
pid: 0x0043,
|
||||
name: "arduino-uno",
|
||||
architecture: Some("AVR ATmega328P"),
|
||||
},
|
||||
BoardInfo {
|
||||
vid: 0x2341,
|
||||
pid: 0x0078,
|
||||
name: "arduino-uno",
|
||||
architecture: Some("Arduino Uno Q / ATmega328P"),
|
||||
},
|
||||
BoardInfo {
|
||||
vid: 0x2341,
|
||||
pid: 0x0042,
|
||||
name: "arduino-mega",
|
||||
architecture: Some("AVR ATmega2560"),
|
||||
},
|
||||
BoardInfo {
|
||||
vid: 0x10c4,
|
||||
pid: 0xea60,
|
||||
name: "cp2102",
|
||||
architecture: Some("USB-UART bridge"),
|
||||
},
|
||||
BoardInfo {
|
||||
vid: 0x10c4,
|
||||
pid: 0xea70,
|
||||
name: "cp2102n",
|
||||
architecture: Some("USB-UART bridge"),
|
||||
},
|
||||
// ESP32 dev boards often use CH340 USB-UART
|
||||
BoardInfo {
|
||||
vid: 0x1a86,
|
||||
pid: 0x7523,
|
||||
name: "esp32",
|
||||
architecture: Some("ESP32 (CH340)"),
|
||||
},
|
||||
BoardInfo {
|
||||
vid: 0x1a86,
|
||||
pid: 0x55d4,
|
||||
name: "esp32",
|
||||
architecture: Some("ESP32 (CH340)"),
|
||||
},
|
||||
];
|
||||
|
||||
/// Look up a board by VID and PID.
|
||||
pub fn lookup_board(vid: u16, pid: u16) -> Option<&'static BoardInfo> {
|
||||
KNOWN_BOARDS.iter().find(|b| b.vid == vid && b.pid == pid)
|
||||
}
|
||||
|
||||
/// Return all known board entries.
|
||||
pub fn known_boards() -> &'static [BoardInfo] {
|
||||
KNOWN_BOARDS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lookup_nucleo_f401re() {
|
||||
let b = lookup_board(0x0483, 0x374b).unwrap();
|
||||
assert_eq!(b.name, "nucleo-f401re");
|
||||
assert_eq!(b.architecture, Some("ARM Cortex-M4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_unknown_returns_none() {
|
||||
assert!(lookup_board(0x0000, 0x0000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_boards_not_empty() {
|
||||
assert!(!known_boards().is_empty());
|
||||
}
|
||||
}
|
||||
116
src/lib.rs
116
src/lib.rs
|
|
@ -55,7 +55,9 @@ pub mod memory;
|
|||
pub mod migration;
|
||||
pub mod observability;
|
||||
pub mod onboard;
|
||||
pub mod peripherals;
|
||||
pub mod providers;
|
||||
pub mod rag;
|
||||
pub mod runtime;
|
||||
pub mod security;
|
||||
pub mod service;
|
||||
|
|
@ -182,74 +184,48 @@ pub enum IntegrationCommands {
|
|||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn service_commands_serde_roundtrip() {
|
||||
let command = ServiceCommands::Status;
|
||||
let json = serde_json::to_string(&command).unwrap();
|
||||
let parsed: ServiceCommands = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, ServiceCommands::Status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_commands_struct_variants_roundtrip() {
|
||||
let add = ChannelCommands::Add {
|
||||
channel_type: "telegram".into(),
|
||||
config: "{}".into(),
|
||||
};
|
||||
let remove = ChannelCommands::Remove {
|
||||
name: "main".into(),
|
||||
};
|
||||
|
||||
let add_json = serde_json::to_string(&add).unwrap();
|
||||
let remove_json = serde_json::to_string(&remove).unwrap();
|
||||
|
||||
let parsed_add: ChannelCommands = serde_json::from_str(&add_json).unwrap();
|
||||
let parsed_remove: ChannelCommands = serde_json::from_str(&remove_json).unwrap();
|
||||
|
||||
assert_eq!(parsed_add, add);
|
||||
assert_eq!(parsed_remove, remove);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_with_payloads_roundtrip() {
|
||||
let skill = SkillCommands::Install {
|
||||
source: "https://example.com/skill".into(),
|
||||
};
|
||||
let migrate = MigrateCommands::Openclaw {
|
||||
source: Some(std::path::PathBuf::from("/tmp/openclaw")),
|
||||
dry_run: true,
|
||||
};
|
||||
let cron = CronCommands::Add {
|
||||
expression: "*/5 * * * *".into(),
|
||||
command: "echo hi".into(),
|
||||
};
|
||||
let integration = IntegrationCommands::Info {
|
||||
name: "Telegram".into(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_str::<SkillCommands>(&serde_json::to_string(&skill).unwrap()).unwrap(),
|
||||
skill
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<MigrateCommands>(&serde_json::to_string(&migrate).unwrap())
|
||||
.unwrap(),
|
||||
migrate
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<CronCommands>(&serde_json::to_string(&cron).unwrap()).unwrap(),
|
||||
cron
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<IntegrationCommands>(
|
||||
&serde_json::to_string(&integration).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
integration
|
||||
);
|
||||
}
|
||||
/// Hardware discovery subcommands
|
||||
#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum HardwareCommands {
|
||||
/// Enumerate USB devices (VID/PID) and show known boards
|
||||
Discover,
|
||||
/// Introspect a device by path (e.g. /dev/ttyACM0)
|
||||
Introspect {
|
||||
/// Serial or device path
|
||||
path: String,
|
||||
},
|
||||
/// Get chip info via USB (probe-rs over ST-Link). No firmware needed on target.
|
||||
Info {
|
||||
/// Chip name (e.g. STM32F401RETx). Default: STM32F401RETx for Nucleo-F401RE
|
||||
#[arg(long, default_value = "STM32F401RETx")]
|
||||
chip: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Peripheral (hardware) management subcommands
|
||||
#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum PeripheralCommands {
|
||||
/// List configured peripherals
|
||||
List,
|
||||
/// Add a peripheral (board path, e.g. nucleo-f401re /dev/ttyACM0)
|
||||
Add {
|
||||
/// Board type (nucleo-f401re, rpi-gpio, esp32)
|
||||
board: String,
|
||||
/// Path for serial transport (/dev/ttyACM0) or "native" for local GPIO
|
||||
path: String,
|
||||
},
|
||||
/// Flash ZeroClaw firmware to Arduino (creates .ino, installs arduino-cli if needed, uploads)
|
||||
Flash {
|
||||
/// Serial port (e.g. /dev/cu.usbmodem12345). If omitted, uses first arduino-uno from config.
|
||||
#[arg(short, long)]
|
||||
port: Option<String>,
|
||||
},
|
||||
/// Setup Arduino Uno Q Bridge app (deploy GPIO bridge for agent control)
|
||||
SetupUnoQ {
|
||||
/// Uno Q IP (e.g. 192.168.0.48). If omitted, assumes running ON the Uno Q.
|
||||
#[arg(long)]
|
||||
host: Option<String>,
|
||||
},
|
||||
/// Flash ZeroClaw firmware to Nucleo-F401RE (builds + probe-rs run)
|
||||
FlashNucleo,
|
||||
}
|
||||
|
|
|
|||
48
src/main.rs
48
src/main.rs
|
|
@ -39,6 +39,9 @@ use tracing_subscriber::FmtSubscriber;
|
|||
|
||||
mod agent;
|
||||
mod channels;
|
||||
mod rag {
|
||||
pub use zeroclaw::rag::*;
|
||||
}
|
||||
mod config;
|
||||
mod cron;
|
||||
mod daemon;
|
||||
|
|
@ -53,6 +56,7 @@ mod memory;
|
|||
mod migration;
|
||||
mod observability;
|
||||
mod onboard;
|
||||
mod peripherals;
|
||||
mod providers;
|
||||
mod runtime;
|
||||
mod security;
|
||||
|
|
@ -65,6 +69,9 @@ mod util;
|
|||
|
||||
use config::Config;
|
||||
|
||||
// Re-export so binary's hardware/peripherals modules can use crate::HardwareCommands etc.
|
||||
pub use zeroclaw::{HardwareCommands, PeripheralCommands};
|
||||
|
||||
/// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "zeroclaw")]
|
||||
|
|
@ -110,7 +117,7 @@ enum Commands {
|
|||
#[arg(long)]
|
||||
provider: Option<String>,
|
||||
|
||||
/// Memory backend (sqlite, markdown, none) - used in quick mode, default: sqlite
|
||||
/// Memory backend (sqlite, lucid, markdown, none) - used in quick mode, default: sqlite
|
||||
#[arg(long)]
|
||||
memory: Option<String>,
|
||||
},
|
||||
|
|
@ -133,9 +140,9 @@ enum Commands {
|
|||
#[arg(short, long, default_value = "0.7")]
|
||||
temperature: f64,
|
||||
|
||||
/// Print user-facing progress lines via observer (`>` send, `<` receive/complete).
|
||||
/// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0)
|
||||
#[arg(long)]
|
||||
verbose: bool,
|
||||
peripheral: Vec<String>,
|
||||
},
|
||||
|
||||
/// Start the gateway server (webhooks, websockets)
|
||||
|
|
@ -207,6 +214,18 @@ enum Commands {
|
|||
#[command(subcommand)]
|
||||
migrate_command: MigrateCommands,
|
||||
},
|
||||
|
||||
/// Discover and introspect USB hardware
|
||||
Hardware {
|
||||
#[command(subcommand)]
|
||||
hardware_command: zeroclaw::HardwareCommands,
|
||||
},
|
||||
|
||||
/// Manage hardware peripherals (STM32, RPi GPIO, etc.)
|
||||
Peripheral {
|
||||
#[command(subcommand)]
|
||||
peripheral_command: zeroclaw::PeripheralCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
|
|
@ -380,8 +399,8 @@ async fn main() -> Result<()> {
|
|||
provider,
|
||||
model,
|
||||
temperature,
|
||||
verbose,
|
||||
} => agent::run(config, message, provider, model, temperature, verbose).await,
|
||||
peripheral,
|
||||
} => agent::run(config, message, provider, model, temperature, peripheral).await,
|
||||
|
||||
Commands::Gateway { port, host } => {
|
||||
if port == 0 {
|
||||
|
|
@ -466,6 +485,17 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!("Peripherals:");
|
||||
println!(
|
||||
" Enabled: {}",
|
||||
if config.peripherals.enabled {
|
||||
"yes"
|
||||
} else {
|
||||
"no"
|
||||
}
|
||||
);
|
||||
println!(" Boards: {}", config.peripherals.boards.len());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -499,6 +529,14 @@ async fn main() -> Result<()> {
|
|||
Commands::Migrate { migrate_command } => {
|
||||
migration::handle_command(migrate_command, &config).await
|
||||
}
|
||||
|
||||
Commands::Hardware { hardware_command } => {
|
||||
hardware::handle_command(hardware_command.clone(), &config)
|
||||
}
|
||||
|
||||
Commands::Peripheral { peripheral_command } => {
|
||||
peripherals::handle_command(peripheral_command.clone(), &config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
145
src/memory/backend.rs
Normal file
145
src/memory/backend.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum MemoryBackendKind {
|
||||
Sqlite,
|
||||
Lucid,
|
||||
Markdown,
|
||||
None,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub struct MemoryBackendProfile {
|
||||
pub key: &'static str,
|
||||
pub label: &'static str,
|
||||
pub auto_save_default: bool,
|
||||
pub uses_sqlite_hygiene: bool,
|
||||
pub sqlite_based: bool,
|
||||
pub optional_dependency: bool,
|
||||
}
|
||||
|
||||
const SQLITE_PROFILE: MemoryBackendProfile = MemoryBackendProfile {
|
||||
key: "sqlite",
|
||||
label: "SQLite with Vector Search (recommended) — fast, hybrid search, embeddings",
|
||||
auto_save_default: true,
|
||||
uses_sqlite_hygiene: true,
|
||||
sqlite_based: true,
|
||||
optional_dependency: false,
|
||||
};
|
||||
|
||||
const LUCID_PROFILE: MemoryBackendProfile = MemoryBackendProfile {
|
||||
key: "lucid",
|
||||
label: "Lucid Memory bridge — sync with local lucid-memory CLI, keep SQLite fallback",
|
||||
auto_save_default: true,
|
||||
uses_sqlite_hygiene: true,
|
||||
sqlite_based: true,
|
||||
optional_dependency: true,
|
||||
};
|
||||
|
||||
const MARKDOWN_PROFILE: MemoryBackendProfile = MemoryBackendProfile {
|
||||
key: "markdown",
|
||||
label: "Markdown Files — simple, human-readable, no dependencies",
|
||||
auto_save_default: true,
|
||||
uses_sqlite_hygiene: false,
|
||||
sqlite_based: false,
|
||||
optional_dependency: false,
|
||||
};
|
||||
|
||||
const NONE_PROFILE: MemoryBackendProfile = MemoryBackendProfile {
|
||||
key: "none",
|
||||
label: "None — disable persistent memory",
|
||||
auto_save_default: false,
|
||||
uses_sqlite_hygiene: false,
|
||||
sqlite_based: false,
|
||||
optional_dependency: false,
|
||||
};
|
||||
|
||||
const CUSTOM_PROFILE: MemoryBackendProfile = MemoryBackendProfile {
|
||||
key: "custom",
|
||||
label: "Custom backend — extension point",
|
||||
auto_save_default: true,
|
||||
uses_sqlite_hygiene: false,
|
||||
sqlite_based: false,
|
||||
optional_dependency: false,
|
||||
};
|
||||
|
||||
const SELECTABLE_MEMORY_BACKENDS: [MemoryBackendProfile; 4] = [
|
||||
SQLITE_PROFILE,
|
||||
LUCID_PROFILE,
|
||||
MARKDOWN_PROFILE,
|
||||
NONE_PROFILE,
|
||||
];
|
||||
|
||||
pub fn selectable_memory_backends() -> &'static [MemoryBackendProfile] {
|
||||
&SELECTABLE_MEMORY_BACKENDS
|
||||
}
|
||||
|
||||
pub fn default_memory_backend_key() -> &'static str {
|
||||
SQLITE_PROFILE.key
|
||||
}
|
||||
|
||||
pub fn classify_memory_backend(backend: &str) -> MemoryBackendKind {
|
||||
match backend {
|
||||
"sqlite" => MemoryBackendKind::Sqlite,
|
||||
"lucid" => MemoryBackendKind::Lucid,
|
||||
"markdown" => MemoryBackendKind::Markdown,
|
||||
"none" => MemoryBackendKind::None,
|
||||
_ => MemoryBackendKind::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn memory_backend_profile(backend: &str) -> MemoryBackendProfile {
|
||||
match classify_memory_backend(backend) {
|
||||
MemoryBackendKind::Sqlite => SQLITE_PROFILE,
|
||||
MemoryBackendKind::Lucid => LUCID_PROFILE,
|
||||
MemoryBackendKind::Markdown => MARKDOWN_PROFILE,
|
||||
MemoryBackendKind::None => NONE_PROFILE,
|
||||
MemoryBackendKind::Unknown => CUSTOM_PROFILE,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classify_known_backends() {
|
||||
assert_eq!(classify_memory_backend("sqlite"), MemoryBackendKind::Sqlite);
|
||||
assert_eq!(classify_memory_backend("lucid"), MemoryBackendKind::Lucid);
|
||||
assert_eq!(
|
||||
classify_memory_backend("markdown"),
|
||||
MemoryBackendKind::Markdown
|
||||
);
|
||||
assert_eq!(classify_memory_backend("none"), MemoryBackendKind::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_unknown_backend() {
|
||||
assert_eq!(classify_memory_backend("redis"), MemoryBackendKind::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selectable_backends_are_ordered_for_onboarding() {
|
||||
let backends = selectable_memory_backends();
|
||||
assert_eq!(backends.len(), 4);
|
||||
assert_eq!(backends[0].key, "sqlite");
|
||||
assert_eq!(backends[1].key, "lucid");
|
||||
assert_eq!(backends[2].key, "markdown");
|
||||
assert_eq!(backends[3].key, "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lucid_profile_is_sqlite_based_optional_backend() {
|
||||
let profile = memory_backend_profile("lucid");
|
||||
assert!(profile.sqlite_based);
|
||||
assert!(profile.optional_dependency);
|
||||
assert!(profile.uses_sqlite_hygiene);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_preserves_extensibility_defaults() {
|
||||
let profile = memory_backend_profile("custom-memory");
|
||||
assert_eq!(profile.key, "custom");
|
||||
assert!(profile.auto_save_default);
|
||||
assert!(!profile.uses_sqlite_hygiene);
|
||||
}
|
||||
}
|
||||
601
src/memory/lucid.rs
Normal file
601
src/memory/lucid.rs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
use super::sqlite::SqliteMemory;
|
||||
use super::traits::{Memory, MemoryCategory, MemoryEntry};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Local;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub struct LucidMemory {
|
||||
local: SqliteMemory,
|
||||
lucid_cmd: String,
|
||||
token_budget: usize,
|
||||
workspace_dir: PathBuf,
|
||||
recall_timeout: Duration,
|
||||
store_timeout: Duration,
|
||||
local_hit_threshold: usize,
|
||||
failure_cooldown: Duration,
|
||||
last_failure_at: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
impl LucidMemory {
|
||||
const DEFAULT_LUCID_CMD: &'static str = "lucid";
|
||||
const DEFAULT_TOKEN_BUDGET: usize = 200;
|
||||
const DEFAULT_RECALL_TIMEOUT_MS: u64 = 120;
|
||||
const DEFAULT_STORE_TIMEOUT_MS: u64 = 800;
|
||||
const DEFAULT_LOCAL_HIT_THRESHOLD: usize = 3;
|
||||
const DEFAULT_FAILURE_COOLDOWN_MS: u64 = 15_000;
|
||||
|
||||
pub fn new(workspace_dir: &Path, local: SqliteMemory) -> Self {
|
||||
let lucid_cmd = std::env::var("ZEROCLAW_LUCID_CMD")
|
||||
.unwrap_or_else(|_| Self::DEFAULT_LUCID_CMD.to_string());
|
||||
|
||||
let token_budget = std::env::var("ZEROCLAW_LUCID_BUDGET")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
.unwrap_or(Self::DEFAULT_TOKEN_BUDGET);
|
||||
|
||||
let recall_timeout = Self::read_env_duration_ms(
|
||||
"ZEROCLAW_LUCID_RECALL_TIMEOUT_MS",
|
||||
Self::DEFAULT_RECALL_TIMEOUT_MS,
|
||||
20,
|
||||
);
|
||||
let store_timeout = Self::read_env_duration_ms(
|
||||
"ZEROCLAW_LUCID_STORE_TIMEOUT_MS",
|
||||
Self::DEFAULT_STORE_TIMEOUT_MS,
|
||||
50,
|
||||
);
|
||||
let local_hit_threshold = Self::read_env_usize(
|
||||
"ZEROCLAW_LUCID_LOCAL_HIT_THRESHOLD",
|
||||
Self::DEFAULT_LOCAL_HIT_THRESHOLD,
|
||||
1,
|
||||
);
|
||||
let failure_cooldown = Self::read_env_duration_ms(
|
||||
"ZEROCLAW_LUCID_FAILURE_COOLDOWN_MS",
|
||||
Self::DEFAULT_FAILURE_COOLDOWN_MS,
|
||||
100,
|
||||
);
|
||||
|
||||
Self {
|
||||
local,
|
||||
lucid_cmd,
|
||||
token_budget,
|
||||
workspace_dir: workspace_dir.to_path_buf(),
|
||||
recall_timeout,
|
||||
store_timeout,
|
||||
local_hit_threshold,
|
||||
failure_cooldown,
|
||||
last_failure_at: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_options(
|
||||
workspace_dir: &Path,
|
||||
local: SqliteMemory,
|
||||
lucid_cmd: String,
|
||||
token_budget: usize,
|
||||
local_hit_threshold: usize,
|
||||
recall_timeout: Duration,
|
||||
store_timeout: Duration,
|
||||
failure_cooldown: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
local,
|
||||
lucid_cmd,
|
||||
token_budget,
|
||||
workspace_dir: workspace_dir.to_path_buf(),
|
||||
recall_timeout,
|
||||
store_timeout,
|
||||
local_hit_threshold: local_hit_threshold.max(1),
|
||||
failure_cooldown,
|
||||
last_failure_at: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_env_usize(name: &str, default: usize, min: usize) -> usize {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.map_or(default, |v| v.max(min))
|
||||
}
|
||||
|
||||
fn read_env_duration_ms(name: &str, default_ms: u64, min_ms: u64) -> Duration {
|
||||
let millis = std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.map_or(default_ms, |v| v.max(min_ms));
|
||||
Duration::from_millis(millis)
|
||||
}
|
||||
|
||||
fn in_failure_cooldown(&self) -> bool {
|
||||
let Ok(guard) = self.last_failure_at.lock() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
guard
|
||||
.as_ref()
|
||||
.is_some_and(|last| last.elapsed() < self.failure_cooldown)
|
||||
}
|
||||
|
||||
fn mark_failure_now(&self) {
|
||||
if let Ok(mut guard) = self.last_failure_at.lock() {
|
||||
*guard = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_failure(&self) {
|
||||
if let Ok(mut guard) = self.last_failure_at.lock() {
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn to_lucid_type(category: &MemoryCategory) -> &'static str {
|
||||
match category {
|
||||
MemoryCategory::Core => "decision",
|
||||
MemoryCategory::Daily => "context",
|
||||
MemoryCategory::Conversation => "conversation",
|
||||
MemoryCategory::Custom(_) => "learning",
|
||||
}
|
||||
}
|
||||
|
||||
fn to_memory_category(label: &str) -> MemoryCategory {
|
||||
let normalized = label.to_lowercase();
|
||||
if normalized.contains("visual") {
|
||||
return MemoryCategory::Custom("visual".to_string());
|
||||
}
|
||||
|
||||
match normalized.as_str() {
|
||||
"decision" | "learning" | "solution" => MemoryCategory::Core,
|
||||
"context" | "conversation" => MemoryCategory::Conversation,
|
||||
"bug" => MemoryCategory::Daily,
|
||||
other => MemoryCategory::Custom(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_results(
|
||||
primary_results: Vec<MemoryEntry>,
|
||||
secondary_results: Vec<MemoryEntry>,
|
||||
limit: usize,
|
||||
) -> Vec<MemoryEntry> {
|
||||
if limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut merged = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
|
||||
for entry in primary_results.into_iter().chain(secondary_results) {
|
||||
let signature = format!(
|
||||
"{}\u{0}{}",
|
||||
entry.key.to_lowercase(),
|
||||
entry.content.to_lowercase()
|
||||
);
|
||||
|
||||
if seen.insert(signature) {
|
||||
merged.push(entry);
|
||||
if merged.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
fn parse_lucid_context(raw: &str) -> Vec<MemoryEntry> {
|
||||
let mut in_context_block = false;
|
||||
let mut entries = Vec::new();
|
||||
let now = Local::now().to_rfc3339();
|
||||
|
||||
for line in raw.lines().map(str::trim) {
|
||||
if line == "<lucid-context>" {
|
||||
in_context_block = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if line == "</lucid-context>" {
|
||||
break;
|
||||
}
|
||||
|
||||
if !in_context_block || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(rest) = line.strip_prefix("- [") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some((label, content_part)) = rest.split_once(']') else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let content = content_part.trim();
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rank = entries.len();
|
||||
entries.push(MemoryEntry {
|
||||
id: format!("lucid:{rank}"),
|
||||
key: format!("lucid_{rank}"),
|
||||
content: content.to_string(),
|
||||
category: Self::to_memory_category(label.trim()),
|
||||
timestamp: now.clone(),
|
||||
session_id: None,
|
||||
score: Some((1.0 - rank as f64 * 0.05).max(0.1)),
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
async fn run_lucid_command_raw(
|
||||
lucid_cmd: &str,
|
||||
args: &[String],
|
||||
timeout_window: Duration,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut cmd = Command::new(lucid_cmd);
|
||||
cmd.args(args);
|
||||
|
||||
let output = timeout(timeout_window, cmd.output()).await.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"lucid command timed out after {}ms",
|
||||
timeout_window.as_millis()
|
||||
)
|
||||
})??;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("lucid command failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
async fn run_lucid_command(
|
||||
&self,
|
||||
args: &[String],
|
||||
timeout_window: Duration,
|
||||
) -> anyhow::Result<String> {
|
||||
Self::run_lucid_command_raw(&self.lucid_cmd, args, timeout_window).await
|
||||
}
|
||||
|
||||
fn build_store_args(&self, key: &str, content: &str, category: &MemoryCategory) -> Vec<String> {
|
||||
let payload = format!("{key}: {content}");
|
||||
vec![
|
||||
"store".to_string(),
|
||||
payload,
|
||||
format!("--type={}", Self::to_lucid_type(category)),
|
||||
format!("--project={}", self.workspace_dir.display()),
|
||||
]
|
||||
}
|
||||
|
||||
fn build_recall_args(&self, query: &str) -> Vec<String> {
|
||||
vec![
|
||||
"context".to_string(),
|
||||
query.to_string(),
|
||||
format!("--budget={}", self.token_budget),
|
||||
format!("--project={}", self.workspace_dir.display()),
|
||||
]
|
||||
}
|
||||
|
||||
async fn sync_to_lucid_async(&self, key: &str, content: &str, category: &MemoryCategory) {
|
||||
let args = self.build_store_args(key, content, category);
|
||||
if let Err(error) = self.run_lucid_command(&args, self.store_timeout).await {
|
||||
tracing::debug!(
|
||||
command = %self.lucid_cmd,
|
||||
error = %error,
|
||||
"Lucid store sync failed; sqlite remains authoritative"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn recall_from_lucid(&self, query: &str) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
let args = self.build_recall_args(query);
|
||||
let output = self.run_lucid_command(&args, self.recall_timeout).await?;
|
||||
Ok(Self::parse_lucid_context(&output))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Memory for LucidMemory {
|
||||
fn name(&self) -> &str {
|
||||
"lucid"
|
||||
}
|
||||
|
||||
async fn store(
|
||||
&self,
|
||||
key: &str,
|
||||
content: &str,
|
||||
category: MemoryCategory,
|
||||
) -> anyhow::Result<()> {
|
||||
self.local.store(key, content, category.clone()).await?;
|
||||
self.sync_to_lucid_async(key, content, &category).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recall(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
let local_results = self.local.recall(query, limit).await?;
|
||||
if limit == 0
|
||||
|| local_results.len() >= limit
|
||||
|| local_results.len() >= self.local_hit_threshold
|
||||
{
|
||||
return Ok(local_results);
|
||||
}
|
||||
|
||||
if self.in_failure_cooldown() {
|
||||
return Ok(local_results);
|
||||
}
|
||||
|
||||
match self.recall_from_lucid(query).await {
|
||||
Ok(lucid_results) if !lucid_results.is_empty() => {
|
||||
self.clear_failure();
|
||||
Ok(Self::merge_results(local_results, lucid_results, limit))
|
||||
}
|
||||
Ok(_) => {
|
||||
self.clear_failure();
|
||||
Ok(local_results)
|
||||
}
|
||||
Err(error) => {
|
||||
self.mark_failure_now();
|
||||
tracing::debug!(
|
||||
command = %self.lucid_cmd,
|
||||
error = %error,
|
||||
"Lucid context unavailable; using local sqlite results"
|
||||
);
|
||||
Ok(local_results)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
|
||||
self.local.get(key).await
|
||||
}
|
||||
|
||||
async fn list(&self, category: Option<&MemoryCategory>) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
self.local.list(category).await
|
||||
}
|
||||
|
||||
async fn forget(&self, key: &str) -> anyhow::Result<bool> {
|
||||
self.local.forget(key).await
|
||||
}
|
||||
|
||||
async fn count(&self) -> anyhow::Result<usize> {
|
||||
self.local.count().await
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
self.local.health_check().await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_fake_lucid_script(dir: &Path) -> String {
|
||||
let script_path = dir.join("fake-lucid.sh");
|
||||
let script = r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "store" ]]; then
|
||||
echo '{"success":true,"id":"mem_1"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "context" ]]; then
|
||||
cat <<'EOF'
|
||||
<lucid-context>
|
||||
Auth context snapshot
|
||||
- [decision] Use token refresh middleware
|
||||
- [context] Working in src/auth.rs
|
||||
</lucid-context>
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unsupported command" >&2
|
||||
exit 1
|
||||
"#;
|
||||
|
||||
fs::write(&script_path, script).unwrap();
|
||||
let mut perms = fs::metadata(&script_path).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, perms).unwrap();
|
||||
script_path.display().to_string()
|
||||
}
|
||||
|
||||
fn write_probe_lucid_script(dir: &Path, marker_path: &Path) -> String {
|
||||
let script_path = dir.join("probe-lucid.sh");
|
||||
let marker = marker_path.display().to_string();
|
||||
let script = format!(
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${{1:-}}" == "store" ]]; then
|
||||
echo '{{"success":true,"id":"mem_store"}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{1:-}}" == "context" ]]; then
|
||||
printf 'context\n' >> "{marker}"
|
||||
cat <<'EOF'
|
||||
<lucid-context>
|
||||
- [decision] should not be used when local hits are enough
|
||||
</lucid-context>
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unsupported command" >&2
|
||||
exit 1
|
||||
"#
|
||||
);
|
||||
|
||||
fs::write(&script_path, script).unwrap();
|
||||
let mut perms = fs::metadata(&script_path).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, perms).unwrap();
|
||||
script_path.display().to_string()
|
||||
}
|
||||
|
||||
fn test_memory(workspace: &Path, cmd: String) -> LucidMemory {
|
||||
let sqlite = SqliteMemory::new(workspace).unwrap();
|
||||
LucidMemory::with_options(
|
||||
workspace,
|
||||
sqlite,
|
||||
cmd,
|
||||
200,
|
||||
3,
|
||||
Duration::from_millis(120),
|
||||
Duration::from_millis(400),
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lucid_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string());
|
||||
assert_eq!(memory.name(), "lucid");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn store_succeeds_when_lucid_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string());
|
||||
|
||||
memory
|
||||
.store("lang", "User prefers Rust", MemoryCategory::Core)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entry = memory.get("lang").await.unwrap();
|
||||
assert!(entry.is_some());
|
||||
assert_eq!(entry.unwrap().content, "User prefers Rust");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_merges_lucid_and_local_results() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let fake_cmd = write_fake_lucid_script(tmp.path());
|
||||
let memory = test_memory(tmp.path(), fake_cmd);
|
||||
|
||||
memory
|
||||
.store(
|
||||
"local_note",
|
||||
"Local sqlite auth fallback note",
|
||||
MemoryCategory::Core,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = memory.recall("auth", 5).await.unwrap();
|
||||
|
||||
assert!(entries
|
||||
.iter()
|
||||
.any(|e| e.content.contains("Local sqlite auth fallback note")));
|
||||
assert!(entries.iter().any(|e| e.content.contains("token refresh")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_skips_lucid_when_local_hits_are_enough() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let marker = tmp.path().join("context_calls.log");
|
||||
let probe_cmd = write_probe_lucid_script(tmp.path(), &marker);
|
||||
|
||||
let sqlite = SqliteMemory::new(tmp.path()).unwrap();
|
||||
let memory = LucidMemory::with_options(
|
||||
tmp.path(),
|
||||
sqlite,
|
||||
probe_cmd,
|
||||
200,
|
||||
1,
|
||||
Duration::from_millis(120),
|
||||
Duration::from_millis(400),
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
memory
|
||||
.store("pref", "Rust should stay local-first", MemoryCategory::Core)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entries = memory.recall("rust", 5).await.unwrap();
|
||||
assert!(entries
|
||||
.iter()
|
||||
.any(|e| e.content.contains("Rust should stay local-first")));
|
||||
|
||||
let context_calls = fs::read_to_string(&marker).unwrap_or_default();
|
||||
assert!(
|
||||
context_calls.trim().is_empty(),
|
||||
"Expected local-hit short-circuit; got calls: {context_calls}"
|
||||
);
|
||||
}
|
||||
|
||||
fn write_failing_lucid_script(dir: &Path, marker_path: &Path) -> String {
|
||||
let script_path = dir.join("failing-lucid.sh");
|
||||
let marker = marker_path.display().to_string();
|
||||
let script = format!(
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${{1:-}}" == "store" ]]; then
|
||||
echo '{{"success":true,"id":"mem_store"}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${{1:-}}" == "context" ]]; then
|
||||
printf 'context\n' >> "{marker}"
|
||||
echo "simulated lucid failure" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "unsupported command" >&2
|
||||
exit 1
|
||||
"#
|
||||
);
|
||||
|
||||
fs::write(&script_path, script).unwrap();
|
||||
let mut perms = fs::metadata(&script_path).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, perms).unwrap();
|
||||
script_path.display().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failure_cooldown_avoids_repeated_lucid_calls() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let marker = tmp.path().join("failing_context_calls.log");
|
||||
let failing_cmd = write_failing_lucid_script(tmp.path(), &marker);
|
||||
|
||||
let sqlite = SqliteMemory::new(tmp.path()).unwrap();
|
||||
let memory = LucidMemory::with_options(
|
||||
tmp.path(),
|
||||
sqlite,
|
||||
failing_cmd,
|
||||
200,
|
||||
99,
|
||||
Duration::from_millis(120),
|
||||
Duration::from_millis(400),
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
|
||||
let first = memory.recall("auth", 5).await.unwrap();
|
||||
let second = memory.recall("auth", 5).await.unwrap();
|
||||
|
||||
assert!(first.is_empty());
|
||||
assert!(second.is_empty());
|
||||
|
||||
let calls = fs::read_to_string(&marker).unwrap_or_default();
|
||||
assert_eq!(calls.lines().count(), 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,22 @@
|
|||
pub mod backend;
|
||||
pub mod chunker;
|
||||
pub mod embeddings;
|
||||
pub mod hygiene;
|
||||
pub mod lucid;
|
||||
pub mod markdown;
|
||||
pub mod none;
|
||||
pub mod sqlite;
|
||||
pub mod traits;
|
||||
pub mod vector;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use backend::{
|
||||
classify_memory_backend, default_memory_backend_key, memory_backend_profile,
|
||||
selectable_memory_backends, MemoryBackendKind, MemoryBackendProfile,
|
||||
};
|
||||
pub use lucid::LucidMemory;
|
||||
pub use markdown::MarkdownMemory;
|
||||
pub use none::NoneMemory;
|
||||
pub use sqlite::SqliteMemory;
|
||||
pub use traits::Memory;
|
||||
#[allow(unused_imports)]
|
||||
|
|
@ -16,6 +26,32 @@ use crate::config::MemoryConfig;
|
|||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn create_memory_with_sqlite_builder<F>(
|
||||
backend_name: &str,
|
||||
workspace_dir: &Path,
|
||||
mut sqlite_builder: F,
|
||||
unknown_context: &str,
|
||||
) -> anyhow::Result<Box<dyn Memory>>
|
||||
where
|
||||
F: FnMut() -> anyhow::Result<SqliteMemory>,
|
||||
{
|
||||
match classify_memory_backend(backend_name) {
|
||||
MemoryBackendKind::Sqlite => Ok(Box::new(sqlite_builder()?)),
|
||||
MemoryBackendKind::Lucid => {
|
||||
let local = sqlite_builder()?;
|
||||
Ok(Box::new(LucidMemory::new(workspace_dir, local)))
|
||||
}
|
||||
MemoryBackendKind::Markdown => Ok(Box::new(MarkdownMemory::new(workspace_dir))),
|
||||
MemoryBackendKind::None => Ok(Box::new(NoneMemory::new())),
|
||||
MemoryBackendKind::Unknown => {
|
||||
tracing::warn!(
|
||||
"Unknown memory backend '{backend_name}'{unknown_context}, falling back to markdown"
|
||||
);
|
||||
Ok(Box::new(MarkdownMemory::new(workspace_dir)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory: create the right memory backend from config
|
||||
pub fn create_memory(
|
||||
config: &MemoryConfig,
|
||||
|
|
@ -27,32 +63,54 @@ pub fn create_memory(
|
|||
tracing::warn!("memory hygiene skipped: {e}");
|
||||
}
|
||||
|
||||
match config.backend.as_str() {
|
||||
"sqlite" => {
|
||||
let embedder: Arc<dyn embeddings::EmbeddingProvider> =
|
||||
Arc::from(embeddings::create_embedding_provider(
|
||||
&config.embedding_provider,
|
||||
api_key,
|
||||
&config.embedding_model,
|
||||
config.embedding_dimensions,
|
||||
));
|
||||
fn build_sqlite_memory(
|
||||
config: &MemoryConfig,
|
||||
workspace_dir: &Path,
|
||||
api_key: Option<&str>,
|
||||
) -> anyhow::Result<SqliteMemory> {
|
||||
let embedder: Arc<dyn embeddings::EmbeddingProvider> =
|
||||
Arc::from(embeddings::create_embedding_provider(
|
||||
&config.embedding_provider,
|
||||
api_key,
|
||||
&config.embedding_model,
|
||||
config.embedding_dimensions,
|
||||
));
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let mem = SqliteMemory::with_embedder(
|
||||
workspace_dir,
|
||||
embedder,
|
||||
config.vector_weight as f32,
|
||||
config.keyword_weight as f32,
|
||||
config.embedding_cache_size,
|
||||
)?;
|
||||
Ok(Box::new(mem))
|
||||
}
|
||||
"markdown" | "none" => Ok(Box::new(MarkdownMemory::new(workspace_dir))),
|
||||
other => {
|
||||
tracing::warn!("Unknown memory backend '{other}', falling back to markdown");
|
||||
Ok(Box::new(MarkdownMemory::new(workspace_dir)))
|
||||
}
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let mem = SqliteMemory::with_embedder(
|
||||
workspace_dir,
|
||||
embedder,
|
||||
config.vector_weight as f32,
|
||||
config.keyword_weight as f32,
|
||||
config.embedding_cache_size,
|
||||
)?;
|
||||
Ok(mem)
|
||||
}
|
||||
|
||||
create_memory_with_sqlite_builder(
|
||||
&config.backend,
|
||||
workspace_dir,
|
||||
|| build_sqlite_memory(config, workspace_dir, api_key),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_memory_for_migration(
|
||||
backend: &str,
|
||||
workspace_dir: &Path,
|
||||
) -> anyhow::Result<Box<dyn Memory>> {
|
||||
if matches!(classify_memory_backend(backend), MemoryBackendKind::None) {
|
||||
anyhow::bail!(
|
||||
"memory backend 'none' disables persistence; choose sqlite, lucid, or markdown before migration"
|
||||
);
|
||||
}
|
||||
|
||||
create_memory_with_sqlite_builder(
|
||||
backend,
|
||||
workspace_dir,
|
||||
|| SqliteMemory::new(workspace_dir),
|
||||
" during migration",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -83,14 +141,25 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn factory_none_falls_back_to_markdown() {
|
||||
fn factory_lucid() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = MemoryConfig {
|
||||
backend: "lucid".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem = create_memory(&cfg, tmp.path(), None).unwrap();
|
||||
assert_eq!(mem.name(), "lucid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_none_uses_noop_memory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem = create_memory(&cfg, tmp.path(), None).unwrap();
|
||||
assert_eq!(mem.name(), "markdown");
|
||||
assert_eq!(mem.name(), "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -103,4 +172,20 @@ mod tests {
|
|||
let mem = create_memory(&cfg, tmp.path(), None).unwrap();
|
||||
assert_eq!(mem.name(), "markdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_factory_lucid() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = create_memory_for_migration("lucid", tmp.path()).unwrap();
|
||||
assert_eq!(mem.name(), "lucid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_factory_none_is_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let error = create_memory_for_migration("none", tmp.path())
|
||||
.err()
|
||||
.expect("backend=none should be rejected for migration");
|
||||
assert!(error.to_string().contains("disables persistence"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
74
src/memory/none.rs
Normal file
74
src/memory/none.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
use super::traits::{Memory, MemoryCategory, MemoryEntry};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Explicit no-op memory backend.
|
||||
///
|
||||
/// This backend is used when `memory.backend = "none"` to disable persistence
|
||||
/// while keeping the runtime wiring stable.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoneMemory;
|
||||
|
||||
impl NoneMemory {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Memory for NoneMemory {
|
||||
fn name(&self) -> &str {
|
||||
"none"
|
||||
}
|
||||
|
||||
async fn store(
|
||||
&self,
|
||||
_key: &str,
|
||||
_content: &str,
|
||||
_category: MemoryCategory,
|
||||
) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recall(&self, _query: &str, _limit: usize) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn list(&self, _category: Option<&MemoryCategory>) -> anyhow::Result<Vec<MemoryEntry>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn count(&self) -> anyhow::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn none_memory_is_noop() {
|
||||
let memory = NoneMemory::new();
|
||||
|
||||
memory.store("k", "v", MemoryCategory::Core).await.unwrap();
|
||||
|
||||
assert!(memory.get("k").await.unwrap().is_none());
|
||||
assert!(memory.recall("k", 10).await.unwrap().is_empty());
|
||||
assert!(memory.list(None).await.unwrap().is_empty());
|
||||
assert!(!memory.forget("k").await.unwrap());
|
||||
assert_eq!(memory.count().await.unwrap(), 0);
|
||||
assert!(memory.health_check().await);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
use crate::config::Config;
|
||||
use crate::memory::{MarkdownMemory, Memory, MemoryCategory, SqliteMemory};
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use directories::UserDirs;
|
||||
use rusqlite::{Connection, OpenFlags, OptionalExtension};
|
||||
|
|
@ -112,16 +112,7 @@ async fn migrate_openclaw_memory(
|
|||
}
|
||||
|
||||
fn target_memory_backend(config: &Config) -> Result<Box<dyn Memory>> {
|
||||
match config.memory.backend.as_str() {
|
||||
"sqlite" => Ok(Box::new(SqliteMemory::new(&config.workspace_dir)?)),
|
||||
"markdown" | "none" => Ok(Box::new(MarkdownMemory::new(&config.workspace_dir))),
|
||||
other => {
|
||||
tracing::warn!(
|
||||
"Unknown memory backend '{other}' during migration, defaulting to markdown"
|
||||
);
|
||||
Ok(Box::new(MarkdownMemory::new(&config.workspace_dir)))
|
||||
}
|
||||
}
|
||||
memory::create_memory_for_migration(&config.memory.backend, &config.workspace_dir)
|
||||
}
|
||||
|
||||
fn collect_source_entries(
|
||||
|
|
@ -431,6 +422,7 @@ fn backup_target_memory(workspace_dir: &Path) -> Result<Option<PathBuf>> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{Config, MemoryConfig};
|
||||
use crate::memory::SqliteMemory;
|
||||
use rusqlite::params;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
|
@ -550,4 +542,16 @@ mod tests {
|
|||
let target_mem = SqliteMemory::new(target.path()).unwrap();
|
||||
assert_eq!(target_mem.count().await.unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_target_rejects_none_backend() {
|
||||
let target = TempDir::new().unwrap();
|
||||
let mut config = test_config(target.path());
|
||||
config.memory.backend = "none".to_string();
|
||||
|
||||
let err = target_memory_backend(&config)
|
||||
.err()
|
||||
.expect("backend=none should be rejected for migration target");
|
||||
assert!(err.to_string().contains("disables persistence"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,7 +183,9 @@ impl Observer for OtelObserver {
|
|||
],
|
||||
);
|
||||
}
|
||||
ObserverEvent::LlmRequest { .. } => {}
|
||||
ObserverEvent::LlmRequest { .. }
|
||||
| ObserverEvent::ToolCallStart { .. }
|
||||
| ObserverEvent::TurnComplete => {}
|
||||
ObserverEvent::LlmResponse {
|
||||
provider,
|
||||
model,
|
||||
|
|
@ -247,7 +249,6 @@ impl Observer for OtelObserver {
|
|||
// Note: tokens are recorded via record_metric(TokensUsed) to avoid
|
||||
// double-counting. AgentEnd only records duration.
|
||||
}
|
||||
ObserverEvent::ToolCallStart { .. } => {}
|
||||
ObserverEvent::ToolCall {
|
||||
tool,
|
||||
duration,
|
||||
|
|
@ -285,7 +286,6 @@ impl Observer for OtelObserver {
|
|||
self.tool_duration
|
||||
.record(secs, &[KeyValue::new("tool", tool.clone())]);
|
||||
}
|
||||
ObserverEvent::TurnComplete => {}
|
||||
ObserverEvent::ChannelMessage { channel, direction } => {
|
||||
self.channel_messages.add(
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ use crate::config::{
|
|||
RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig,
|
||||
};
|
||||
use crate::hardware::{self, HardwareConfig};
|
||||
use crate::memory::{
|
||||
default_memory_backend_key, memory_backend_profile, selectable_memory_backends,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use console::style;
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
|
|
@ -110,7 +113,8 @@ pub fn run_wizard() -> Result<Config> {
|
|||
autonomy: AutonomyConfig::default(),
|
||||
runtime: RuntimeConfig::default(),
|
||||
reliability: crate::config::ReliabilityConfig::default(),
|
||||
scheduler: crate::config::SchedulerConfig::default(),
|
||||
scheduler: crate::config::schema::SchedulerConfig::default(),
|
||||
agent: crate::config::schema::AgentConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
channels_config,
|
||||
|
|
@ -123,9 +127,9 @@ pub fn run_wizard() -> Result<Config> {
|
|||
http_request: crate::config::HttpRequestConfig::default(),
|
||||
identity: crate::config::IdentityConfig::default(),
|
||||
cost: crate::config::CostConfig::default(),
|
||||
hardware: hardware_config,
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
agents: std::collections::HashMap::new(),
|
||||
security: crate::config::SecurityConfig::default(),
|
||||
hardware: hardware_config,
|
||||
};
|
||||
|
||||
println!(
|
||||
|
|
@ -237,8 +241,38 @@ pub fn run_channels_repair_wizard() -> Result<Config> {
|
|||
// ── Quick setup (zero prompts) ───────────────────────────────────
|
||||
|
||||
/// Non-interactive setup: generates a sensible default config instantly.
|
||||
/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite`.
|
||||
/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`.
|
||||
/// Use `zeroclaw onboard --interactive` for the full wizard.
|
||||
fn backend_key_from_choice(choice: usize) -> &'static str {
|
||||
selectable_memory_backends()
|
||||
.get(choice)
|
||||
.map_or(default_memory_backend_key(), |backend| backend.key)
|
||||
}
|
||||
|
||||
fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig {
|
||||
let profile = memory_backend_profile(backend);
|
||||
|
||||
MemoryConfig {
|
||||
backend: backend.to_string(),
|
||||
auto_save: profile.auto_save_default,
|
||||
hygiene_enabled: profile.uses_sqlite_hygiene,
|
||||
archive_after_days: if profile.uses_sqlite_hygiene { 7 } else { 0 },
|
||||
purge_after_days: if profile.uses_sqlite_hygiene { 30 } else { 0 },
|
||||
conversation_retention_days: 30,
|
||||
embedding_provider: "none".to_string(),
|
||||
embedding_model: "text-embedding-3-small".to_string(),
|
||||
embedding_dimensions: 1536,
|
||||
vector_weight: 0.7,
|
||||
keyword_weight: 0.3,
|
||||
embedding_cache_size: if profile.uses_sqlite_hygiene {
|
||||
10000
|
||||
} else {
|
||||
0
|
||||
},
|
||||
chunk_max_tokens: 512,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn run_quick_setup(
|
||||
api_key: Option<&str>,
|
||||
|
|
@ -265,36 +299,12 @@ pub fn run_quick_setup(
|
|||
|
||||
let provider_name = provider.unwrap_or("openrouter").to_string();
|
||||
let model = default_model_for_provider(&provider_name);
|
||||
let memory_backend_name = memory_backend.unwrap_or("sqlite").to_string();
|
||||
let memory_backend_name = memory_backend
|
||||
.unwrap_or(default_memory_backend_key())
|
||||
.to_string();
|
||||
|
||||
// Create memory config based on backend choice
|
||||
let memory_config = MemoryConfig {
|
||||
backend: memory_backend_name.clone(),
|
||||
auto_save: memory_backend_name != "none",
|
||||
hygiene_enabled: memory_backend_name == "sqlite",
|
||||
archive_after_days: if memory_backend_name == "sqlite" {
|
||||
7
|
||||
} else {
|
||||
0
|
||||
},
|
||||
purge_after_days: if memory_backend_name == "sqlite" {
|
||||
30
|
||||
} else {
|
||||
0
|
||||
},
|
||||
conversation_retention_days: 30,
|
||||
embedding_provider: "none".to_string(),
|
||||
embedding_model: "text-embedding-3-small".to_string(),
|
||||
embedding_dimensions: 1536,
|
||||
vector_weight: 0.7,
|
||||
keyword_weight: 0.3,
|
||||
embedding_cache_size: if memory_backend_name == "sqlite" {
|
||||
10000
|
||||
} else {
|
||||
0
|
||||
},
|
||||
chunk_max_tokens: 512,
|
||||
};
|
||||
let memory_config = memory_config_defaults_for_backend(&memory_backend_name);
|
||||
|
||||
let config = Config {
|
||||
workspace_dir: workspace_dir.clone(),
|
||||
|
|
@ -307,7 +317,8 @@ pub fn run_quick_setup(
|
|||
autonomy: AutonomyConfig::default(),
|
||||
runtime: RuntimeConfig::default(),
|
||||
reliability: crate::config::ReliabilityConfig::default(),
|
||||
scheduler: crate::config::SchedulerConfig::default(),
|
||||
scheduler: crate::config::schema::SchedulerConfig::default(),
|
||||
agent: crate::config::schema::AgentConfig::default(),
|
||||
model_routes: Vec::new(),
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
channels_config: ChannelsConfig::default(),
|
||||
|
|
@ -320,9 +331,9 @@ pub fn run_quick_setup(
|
|||
http_request: crate::config::HttpRequestConfig::default(),
|
||||
identity: crate::config::IdentityConfig::default(),
|
||||
cost: crate::config::CostConfig::default(),
|
||||
hardware: HardwareConfig::default(),
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
agents: std::collections::HashMap::new(),
|
||||
security: crate::config::SecurityConfig::default(),
|
||||
hardware: crate::config::HardwareConfig::default(),
|
||||
};
|
||||
|
||||
config.save()?;
|
||||
|
|
@ -1990,7 +2001,7 @@ fn setup_hardware() -> Result<HardwareConfig> {
|
|||
hw_config.baud_rate = match baud_idx {
|
||||
1 => 9600,
|
||||
2 => 57600,
|
||||
3 => 230400,
|
||||
3 => 230_400,
|
||||
4 => {
|
||||
let custom: String = Input::new()
|
||||
.with_prompt(" Custom baud rate")
|
||||
|
|
@ -2164,11 +2175,10 @@ fn setup_memory() -> Result<MemoryConfig> {
|
|||
print_bullet("You can always change this later in config.toml.");
|
||||
println!();
|
||||
|
||||
let options = vec![
|
||||
"SQLite with Vector Search (recommended) — fast, hybrid search, embeddings",
|
||||
"Markdown Files — simple, human-readable, no dependencies",
|
||||
"None — disable persistent memory",
|
||||
];
|
||||
let options: Vec<&str> = selectable_memory_backends()
|
||||
.iter()
|
||||
.map(|backend| backend.label)
|
||||
.collect();
|
||||
|
||||
let choice = Select::new()
|
||||
.with_prompt(" Select memory backend")
|
||||
|
|
@ -2176,20 +2186,16 @@ fn setup_memory() -> Result<MemoryConfig> {
|
|||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
let backend = match choice {
|
||||
1 => "markdown",
|
||||
2 => "none",
|
||||
_ => "sqlite", // 0 and any unexpected value defaults to sqlite
|
||||
};
|
||||
let backend = backend_key_from_choice(choice);
|
||||
let profile = memory_backend_profile(backend);
|
||||
|
||||
let auto_save = if backend == "none" {
|
||||
let auto_save = if !profile.auto_save_default {
|
||||
false
|
||||
} else {
|
||||
let save = Confirm::new()
|
||||
Confirm::new()
|
||||
.with_prompt(" Auto-save conversations to memory?")
|
||||
.default(true)
|
||||
.interact()?;
|
||||
save
|
||||
.interact()?
|
||||
};
|
||||
|
||||
println!(
|
||||
|
|
@ -2199,21 +2205,9 @@ fn setup_memory() -> Result<MemoryConfig> {
|
|||
if auto_save { "on" } else { "off" }
|
||||
);
|
||||
|
||||
Ok(MemoryConfig {
|
||||
backend: backend.to_string(),
|
||||
auto_save,
|
||||
hygiene_enabled: backend == "sqlite", // Only enable hygiene for SQLite
|
||||
archive_after_days: if backend == "sqlite" { 7 } else { 0 },
|
||||
purge_after_days: if backend == "sqlite" { 30 } else { 0 },
|
||||
conversation_retention_days: 30,
|
||||
embedding_provider: "none".to_string(),
|
||||
embedding_model: "text-embedding-3-small".to_string(),
|
||||
embedding_dimensions: 1536,
|
||||
vector_weight: 0.7,
|
||||
keyword_weight: 0.3,
|
||||
embedding_cache_size: if backend == "sqlite" { 10000 } else { 0 },
|
||||
chunk_max_tokens: 512,
|
||||
})
|
||||
let mut config = memory_config_defaults_for_backend(backend);
|
||||
config.auto_save = auto_save;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
// ── Step 3: Channels ────────────────────────────────────────────
|
||||
|
|
@ -2336,18 +2330,27 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
// Test connection (run entirely in separate thread — reqwest::blocking Response
|
||||
// must be used and dropped there to avoid "Cannot drop a runtime" panic)
|
||||
print!(" {} Testing connection... ", style("⏳").dim());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let url = format!("https://api.telegram.org/bot{token}/getMe");
|
||||
match client.get(&url).send() {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let data: serde_json::Value = resp.json().unwrap_or_default();
|
||||
let bot_name = data
|
||||
.get("result")
|
||||
.and_then(|r| r.get("username"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let token_clone = token.clone();
|
||||
let thread_result = std::thread::spawn(move || {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let url = format!("https://api.telegram.org/bot{token_clone}/getMe");
|
||||
let resp = client.get(&url).send()?;
|
||||
let ok = resp.status().is_success();
|
||||
let data: serde_json::Value = resp.json().unwrap_or_default();
|
||||
let bot_name = data
|
||||
.get("result")
|
||||
.and_then(|r| r.get("username"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
Ok::<_, reqwest::Error>((ok, bot_name))
|
||||
})
|
||||
.join();
|
||||
match thread_result {
|
||||
Ok(Ok((true, bot_name))) => {
|
||||
println!(
|
||||
"\r {} Connected as @{bot_name} ",
|
||||
style("✅").green().bold()
|
||||
|
|
@ -2420,20 +2423,27 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
// Test connection (run entirely in separate thread — Response must be used/dropped there)
|
||||
print!(" {} Testing connection... ", style("⏳").dim());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
match client
|
||||
.get("https://discord.com/api/v10/users/@me")
|
||||
.header("Authorization", format!("Bot {token}"))
|
||||
.send()
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let data: serde_json::Value = resp.json().unwrap_or_default();
|
||||
let bot_name = data
|
||||
.get("username")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let token_clone = token.clone();
|
||||
let thread_result = std::thread::spawn(move || {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get("https://discord.com/api/v10/users/@me")
|
||||
.header("Authorization", format!("Bot {token_clone}"))
|
||||
.send()?;
|
||||
let ok = resp.status().is_success();
|
||||
let data: serde_json::Value = resp.json().unwrap_or_default();
|
||||
let bot_name = data
|
||||
.get("username")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
Ok::<_, reqwest::Error>((ok, bot_name))
|
||||
})
|
||||
.join();
|
||||
match thread_result {
|
||||
Ok(Ok((true, bot_name))) => {
|
||||
println!(
|
||||
"\r {} Connected as {bot_name} ",
|
||||
style("✅").green().bold()
|
||||
|
|
@ -2512,37 +2522,44 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
// Test connection (run entirely in separate thread — Response must be used/dropped there)
|
||||
print!(" {} Testing connection... ", style("⏳").dim());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
match client
|
||||
.get("https://slack.com/api/auth.test")
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let data: serde_json::Value = resp.json().unwrap_or_default();
|
||||
let ok = data
|
||||
.get("ok")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let team = data
|
||||
.get("team")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
if ok {
|
||||
println!(
|
||||
"\r {} Connected to workspace: {team} ",
|
||||
style("✅").green().bold()
|
||||
);
|
||||
} else {
|
||||
let err = data
|
||||
.get("error")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown error");
|
||||
println!("\r {} Slack error: {err}", style("❌").red().bold());
|
||||
continue;
|
||||
}
|
||||
let token_clone = token.clone();
|
||||
let thread_result = std::thread::spawn(move || {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get("https://slack.com/api/auth.test")
|
||||
.bearer_auth(&token_clone)
|
||||
.send()?;
|
||||
let ok = resp.status().is_success();
|
||||
let data: serde_json::Value = resp.json().unwrap_or_default();
|
||||
let api_ok = data
|
||||
.get("ok")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let team = data
|
||||
.get("team")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let err = data
|
||||
.get("error")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown error")
|
||||
.to_string();
|
||||
Ok::<_, reqwest::Error>((ok, api_ok, team, err))
|
||||
})
|
||||
.join();
|
||||
match thread_result {
|
||||
Ok(Ok((true, true, team, _))) => {
|
||||
println!(
|
||||
"\r {} Connected to workspace: {team} ",
|
||||
style("✅").green().bold()
|
||||
);
|
||||
}
|
||||
Ok(Ok((true, false, _, err))) => {
|
||||
println!("\r {} Slack error: {err}", style("❌").red().bold());
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
println!(
|
||||
|
|
@ -2681,21 +2698,29 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Test connection
|
||||
// Test connection (run entirely in separate thread — Response must be used/dropped there)
|
||||
let hs = homeserver.trim_end_matches('/');
|
||||
print!(" {} Testing connection... ", style("⏳").dim());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
match client
|
||||
.get(format!("{hs}/_matrix/client/v3/account/whoami"))
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let data: serde_json::Value = resp.json().unwrap_or_default();
|
||||
let user_id = data
|
||||
.get("user_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let hs_owned = hs.to_string();
|
||||
let access_token_clone = access_token.clone();
|
||||
let thread_result = std::thread::spawn(move || {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get(format!("{hs_owned}/_matrix/client/v3/account/whoami"))
|
||||
.header("Authorization", format!("Bearer {access_token_clone}"))
|
||||
.send()?;
|
||||
let ok = resp.status().is_success();
|
||||
let data: serde_json::Value = resp.json().unwrap_or_default();
|
||||
let user_id = data
|
||||
.get("user_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
Ok::<_, reqwest::Error>((ok, user_id))
|
||||
})
|
||||
.join();
|
||||
match thread_result {
|
||||
Ok(Ok((true, user_id))) => {
|
||||
println!(
|
||||
"\r {} Connected as {user_id} ",
|
||||
style("✅").green().bold()
|
||||
|
|
@ -2769,19 +2794,28 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
.default("zeroclaw-whatsapp-verify".into())
|
||||
.interact_text()?;
|
||||
|
||||
// Test connection
|
||||
// Test connection (run entirely in separate thread — Response must be used/dropped there)
|
||||
print!(" {} Testing connection... ", style("⏳").dim());
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let url = format!(
|
||||
"https://graph.facebook.com/v18.0/{}",
|
||||
phone_number_id.trim()
|
||||
);
|
||||
match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", access_token.trim()))
|
||||
.send()
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let phone_number_id_clone = phone_number_id.clone();
|
||||
let access_token_clone = access_token.clone();
|
||||
let thread_result = std::thread::spawn(move || {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let url = format!(
|
||||
"https://graph.facebook.com/v18.0/{}",
|
||||
phone_number_id_clone.trim()
|
||||
);
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", access_token_clone.trim()),
|
||||
)
|
||||
.send()?;
|
||||
Ok::<_, reqwest::Error>(resp.status().is_success())
|
||||
})
|
||||
.join();
|
||||
match thread_result {
|
||||
Ok(Ok(true)) => {
|
||||
println!(
|
||||
"\r {} Connected to WhatsApp API ",
|
||||
style("✅").green().bold()
|
||||
|
|
@ -4343,18 +4377,54 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn default_model_for_minimax_is_m2_5() {
|
||||
assert_eq!(default_model_for_provider("minimax"), "MiniMax-M2.5");
|
||||
fn backend_key_from_choice_maps_supported_backends() {
|
||||
assert_eq!(backend_key_from_choice(0), "sqlite");
|
||||
assert_eq!(backend_key_from_choice(1), "lucid");
|
||||
assert_eq!(backend_key_from_choice(2), "markdown");
|
||||
assert_eq!(backend_key_from_choice(3), "none");
|
||||
assert_eq!(backend_key_from_choice(999), "sqlite");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_onboard_models_include_m2_variants() {
|
||||
let model_names: Vec<&str> = MINIMAX_ONBOARD_MODELS
|
||||
.iter()
|
||||
.map(|(name, _)| *name)
|
||||
.collect();
|
||||
assert_eq!(model_names.first().copied(), Some("MiniMax-M2.5"));
|
||||
assert!(model_names.contains(&"MiniMax-M2.1"));
|
||||
assert!(model_names.contains(&"MiniMax-M2.1-highspeed"));
|
||||
fn memory_backend_profile_marks_lucid_as_optional_sqlite_backed() {
|
||||
let lucid = memory_backend_profile("lucid");
|
||||
assert!(lucid.auto_save_default);
|
||||
assert!(lucid.uses_sqlite_hygiene);
|
||||
assert!(lucid.sqlite_based);
|
||||
assert!(lucid.optional_dependency);
|
||||
|
||||
let markdown = memory_backend_profile("markdown");
|
||||
assert!(markdown.auto_save_default);
|
||||
assert!(!markdown.uses_sqlite_hygiene);
|
||||
|
||||
let none = memory_backend_profile("none");
|
||||
assert!(!none.auto_save_default);
|
||||
assert!(!none.uses_sqlite_hygiene);
|
||||
|
||||
let custom = memory_backend_profile("custom-memory");
|
||||
assert!(custom.auto_save_default);
|
||||
assert!(!custom.uses_sqlite_hygiene);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_config_defaults_for_lucid_enable_sqlite_hygiene() {
|
||||
let config = memory_config_defaults_for_backend("lucid");
|
||||
assert_eq!(config.backend, "lucid");
|
||||
assert!(config.auto_save);
|
||||
assert!(config.hygiene_enabled);
|
||||
assert_eq!(config.archive_after_days, 7);
|
||||
assert_eq!(config.purge_after_days, 30);
|
||||
assert_eq!(config.embedding_cache_size, 10000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_config_defaults_for_none_disable_sqlite_hygiene() {
|
||||
let config = memory_config_defaults_for_backend("none");
|
||||
assert_eq!(config.backend, "none");
|
||||
assert!(!config.auto_save);
|
||||
assert!(!config.hygiene_enabled);
|
||||
assert_eq!(config.archive_after_days, 0);
|
||||
assert_eq!(config.purge_after_days, 0);
|
||||
assert_eq!(config.embedding_cache_size, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
src/peripherals/arduino_flash.rs
Normal file
144
src/peripherals/arduino_flash.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
//! Flash ZeroClaw Arduino firmware via arduino-cli.
|
||||
//!
|
||||
//! Ensures arduino-cli is available (installs via brew on macOS if missing),
|
||||
//! installs the AVR core, compiles and uploads the base firmware.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
/// ZeroClaw Arduino Uno base firmware (capabilities, gpio_read, gpio_write).
|
||||
const FIRMWARE_INO: &str = include_str!("../../firmware/zeroclaw-arduino/zeroclaw-arduino.ino");
|
||||
|
||||
const FQBN: &str = "arduino:avr:uno";
|
||||
const SKETCH_NAME: &str = "zeroclaw-arduino";
|
||||
|
||||
/// Check if arduino-cli is available.
|
||||
pub fn arduino_cli_available() -> bool {
|
||||
Command::new("arduino-cli")
|
||||
.arg("version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Try to install arduino-cli. Returns Ok(()) if installed or already present.
|
||||
pub fn ensure_arduino_cli() -> Result<()> {
|
||||
if arduino_cli_available() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
println!("arduino-cli not found. Installing via Homebrew...");
|
||||
let status = Command::new("brew")
|
||||
.args(["install", "arduino-cli"])
|
||||
.status()
|
||||
.context("Failed to run brew install")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("brew install arduino-cli failed. Install manually: https://arduino.github.io/arduino-cli/");
|
||||
}
|
||||
println!("arduino-cli installed.");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
println!("arduino-cli not found. Run the install script:");
|
||||
println!(" curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh");
|
||||
println!();
|
||||
println!("Or install via package manager (e.g. apt install arduino-cli on Debian/Ubuntu).");
|
||||
anyhow::bail!("arduino-cli not installed. Install it and try again.");
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
println!("arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/");
|
||||
anyhow::bail!("arduino-cli not installed.");
|
||||
}
|
||||
|
||||
if !arduino_cli_available() {
|
||||
anyhow::bail!("arduino-cli still not found after install. Ensure it's in PATH.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure arduino:avr core is installed.
|
||||
fn ensure_avr_core() -> Result<()> {
|
||||
let out = Command::new("arduino-cli")
|
||||
.args(["core", "list"])
|
||||
.output()
|
||||
.context("arduino-cli core list failed")?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
if stdout.contains("arduino:avr") {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Installing Arduino AVR core...");
|
||||
let status = Command::new("arduino-cli")
|
||||
.args(["core", "install", "arduino:avr"])
|
||||
.status()
|
||||
.context("arduino-cli core install failed")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to install arduino:avr core");
|
||||
}
|
||||
println!("AVR core installed.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flash ZeroClaw firmware to Arduino at the given port.
|
||||
pub fn flash_arduino_firmware(port: &str) -> Result<()> {
|
||||
ensure_arduino_cli()?;
|
||||
ensure_avr_core()?;
|
||||
|
||||
let temp_dir = std::env::temp_dir().join(format!("zeroclaw_flash_{}", uuid::Uuid::new_v4()));
|
||||
let sketch_dir = temp_dir.join(SKETCH_NAME);
|
||||
let ino_path = sketch_dir.join(format!("{}.ino", SKETCH_NAME));
|
||||
|
||||
std::fs::create_dir_all(&sketch_dir).context("Failed to create sketch dir")?;
|
||||
std::fs::write(&ino_path, FIRMWARE_INO).context("Failed to write firmware")?;
|
||||
|
||||
let sketch_path = sketch_dir.to_string_lossy();
|
||||
|
||||
// Compile
|
||||
println!("Compiling ZeroClaw Arduino firmware...");
|
||||
let compile = Command::new("arduino-cli")
|
||||
.args(["compile", "--fqbn", FQBN, &*sketch_path])
|
||||
.output()
|
||||
.context("arduino-cli compile failed")?;
|
||||
|
||||
if !compile.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&compile.stderr);
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
anyhow::bail!("Compile failed:\n{}", stderr);
|
||||
}
|
||||
|
||||
// Upload
|
||||
println!("Uploading to {}...", port);
|
||||
let upload = Command::new("arduino-cli")
|
||||
.args(["upload", "-p", port, "--fqbn", FQBN, &*sketch_path])
|
||||
.output()
|
||||
.context("arduino-cli upload failed")?;
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
|
||||
if !upload.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&upload.stderr);
|
||||
anyhow::bail!("Upload failed:\n{}\n\nEnsure the board is connected and the port is correct (e.g. /dev/cu.usbmodem* on macOS).", stderr);
|
||||
}
|
||||
|
||||
println!("ZeroClaw firmware flashed successfully.");
|
||||
println!("The Arduino now supports: capabilities, gpio_read, gpio_write.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve port from config or path. Returns the path to use for flashing.
|
||||
pub fn resolve_port(config: &crate::config::Config, path_override: Option<&str>) -> Option<String> {
|
||||
if let Some(p) = path_override {
|
||||
return Some(p.to_string());
|
||||
}
|
||||
config
|
||||
.peripherals
|
||||
.boards
|
||||
.iter()
|
||||
.find(|b| b.board == "arduino-uno" && b.transport == "serial")
|
||||
.and_then(|b| b.path.clone())
|
||||
}
|
||||
161
src/peripherals/arduino_upload.rs
Normal file
161
src/peripherals/arduino_upload.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! Arduino upload tool — agent generates code, uploads via arduino-cli.
|
||||
//!
|
||||
//! When user says "make a heart on the LED grid", the agent generates Arduino
|
||||
//! sketch code and calls this tool. ZeroClaw compiles and uploads it — no
|
||||
//! manual IDE or file editing.
|
||||
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
|
||||
/// Tool: upload Arduino sketch (agent-generated code) to the board.
|
||||
pub struct ArduinoUploadTool {
|
||||
/// Serial port path (e.g. /dev/cu.usbmodem33000283452)
|
||||
pub port: String,
|
||||
}
|
||||
|
||||
impl ArduinoUploadTool {
|
||||
pub fn new(port: String) -> Self {
|
||||
Self { port }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ArduinoUploadTool {
|
||||
fn name(&self) -> &str {
|
||||
"arduino_upload"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Generate Arduino sketch code and upload it to the connected Arduino. Use when: user asks to 'make a heart', 'blink LED', or run any custom pattern on Arduino. You MUST write the full .ino sketch code (setup + loop). Arduino Uno: pin 13 = built-in LED. Saves to temp dir, runs arduino-cli compile and upload. Requires arduino-cli installed."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Full Arduino sketch code (complete .ino file content)"
|
||||
}
|
||||
},
|
||||
"required": ["code"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let code = args
|
||||
.get("code")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'code' parameter"))?;
|
||||
|
||||
if code.trim().is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some("Code cannot be empty".into()),
|
||||
});
|
||||
}
|
||||
|
||||
// Check arduino-cli exists
|
||||
if Command::new("arduino-cli").arg("version").output().is_err() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"arduino-cli not found. Install it: https://arduino.github.io/arduino-cli/"
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let sketch_name = "zeroclaw_sketch";
|
||||
let temp_dir = std::env::temp_dir().join(format!("zeroclaw_{}", uuid::Uuid::new_v4()));
|
||||
let sketch_dir = temp_dir.join(sketch_name);
|
||||
let ino_path = sketch_dir.join(format!("{}.ino", sketch_name));
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&sketch_dir) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!("Failed to create sketch dir: {}", e),
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::write(&ino_path, code) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!("Failed to write sketch: {}", e),
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let sketch_path = sketch_dir.to_string_lossy();
|
||||
let fqbn = "arduino:avr:uno";
|
||||
|
||||
// Compile
|
||||
let compile = Command::new("arduino-cli")
|
||||
.args(["compile", "--fqbn", fqbn, &sketch_path])
|
||||
.output();
|
||||
|
||||
let compile_output = match compile {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!("arduino-cli compile failed: {}", e),
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !compile_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&compile_output.stderr);
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!("Compile failed:\n{}", stderr),
|
||||
error: Some("Arduino compile error".into()),
|
||||
});
|
||||
}
|
||||
|
||||
// Upload
|
||||
let upload = Command::new("arduino-cli")
|
||||
.args(["upload", "-p", &self.port, "--fqbn", fqbn, &sketch_path])
|
||||
.output();
|
||||
|
||||
let upload_output = match upload {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!("arduino-cli upload failed: {}", e),
|
||||
error: Some(e.to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
|
||||
if !upload_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&upload_output.stderr);
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!("Upload failed:\n{}", stderr),
|
||||
error: Some("Arduino upload error".into()),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output:
|
||||
"Sketch compiled and uploaded successfully. The Arduino is now running your code."
|
||||
.into(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
99
src/peripherals/capabilities_tool.rs
Normal file
99
src/peripherals/capabilities_tool.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! Hardware capabilities tool — Phase C: query device for reported GPIO pins.
|
||||
|
||||
use super::serial::SerialTransport;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Tool: query device capabilities (GPIO pins, LED pin) from firmware.
|
||||
pub struct HardwareCapabilitiesTool {
|
||||
/// (board_name, transport) for each serial board.
|
||||
boards: Vec<(String, Arc<SerialTransport>)>,
|
||||
}
|
||||
|
||||
impl HardwareCapabilitiesTool {
|
||||
pub(crate) fn new(boards: Vec<(String, Arc<SerialTransport>)>) -> Self {
|
||||
Self { boards }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HardwareCapabilitiesTool {
|
||||
fn name(&self) -> &str {
|
||||
"hardware_capabilities"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"board": {
|
||||
"type": "string",
|
||||
"description": "Optional board name. If omitted, queries all."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let filter = args.get("board").and_then(|v| v.as_str());
|
||||
let mut outputs = Vec::new();
|
||||
|
||||
for (board_name, transport) in &self.boards {
|
||||
if let Some(b) = filter {
|
||||
if b != board_name {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match transport.capabilities().await {
|
||||
Ok(result) => {
|
||||
let output = if result.success {
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_str::<serde_json::Value>(&result.output)
|
||||
{
|
||||
format!(
|
||||
"{}: gpio {:?}, led_pin {:?}",
|
||||
board_name,
|
||||
parsed.get("gpio").unwrap_or(&json!([])),
|
||||
parsed.get("led_pin").unwrap_or(&json!(null))
|
||||
)
|
||||
} else {
|
||||
format!("{}: {}", board_name, result.output)
|
||||
}
|
||||
} else {
|
||||
format!(
|
||||
"{}: {}",
|
||||
board_name,
|
||||
result.error.as_deref().unwrap_or("unknown")
|
||||
)
|
||||
};
|
||||
outputs.push(output);
|
||||
}
|
||||
Err(e) => {
|
||||
outputs.push(format!("{}: error - {}", board_name, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = if outputs.is_empty() {
|
||||
if filter.is_some() {
|
||||
"No matching board or capabilities not supported.".to_string()
|
||||
} else {
|
||||
"No serial boards configured or capabilities not supported.".to_string()
|
||||
}
|
||||
} else {
|
||||
outputs.join("\n")
|
||||
};
|
||||
|
||||
Ok(ToolResult {
|
||||
success: !outputs.is_empty(),
|
||||
output,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
231
src/peripherals/mod.rs
Normal file
231
src/peripherals/mod.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
//! Hardware peripherals — STM32, RPi GPIO, etc.
|
||||
//!
|
||||
//! Peripherals extend the agent with physical capabilities. See
|
||||
//! `docs/hardware-peripherals-design.md` for the full design.
|
||||
|
||||
pub mod traits;
|
||||
|
||||
#[cfg(feature = "hardware")]
|
||||
pub mod serial;
|
||||
|
||||
#[cfg(feature = "hardware")]
|
||||
pub mod arduino_flash;
|
||||
#[cfg(feature = "hardware")]
|
||||
pub mod arduino_upload;
|
||||
#[cfg(feature = "hardware")]
|
||||
pub mod capabilities_tool;
|
||||
#[cfg(feature = "hardware")]
|
||||
pub mod nucleo_flash;
|
||||
#[cfg(feature = "hardware")]
|
||||
pub mod uno_q_bridge;
|
||||
#[cfg(feature = "hardware")]
|
||||
pub mod uno_q_setup;
|
||||
|
||||
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
|
||||
pub mod rpi;
|
||||
|
||||
pub use traits::Peripheral;
|
||||
|
||||
use crate::config::{Config, PeripheralBoardConfig, PeripheralsConfig};
|
||||
use crate::tools::{HardwareMemoryMapTool, Tool};
|
||||
use anyhow::Result;
|
||||
|
||||
/// List configured boards from config (no connection yet).
|
||||
pub fn list_configured_boards(config: &PeripheralsConfig) -> Vec<&PeripheralBoardConfig> {
|
||||
if !config.enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
config.boards.iter().collect()
|
||||
}
|
||||
|
||||
/// Handle `zeroclaw peripheral` subcommands.
|
||||
#[allow(clippy::module_name_repetitions)]
|
||||
pub fn handle_command(cmd: crate::PeripheralCommands, config: &Config) -> Result<()> {
|
||||
match cmd {
|
||||
crate::PeripheralCommands::List => {
|
||||
let boards = list_configured_boards(&config.peripherals);
|
||||
if boards.is_empty() {
|
||||
println!("No peripherals configured.");
|
||||
println!();
|
||||
println!("Add one with: zeroclaw peripheral add <board> <path>");
|
||||
println!(" Example: zeroclaw peripheral add nucleo-f401re /dev/ttyACM0");
|
||||
println!();
|
||||
println!("Or add to config.toml:");
|
||||
println!(" [peripherals]");
|
||||
println!(" enabled = true");
|
||||
println!();
|
||||
println!(" [[peripherals.boards]]");
|
||||
println!(" board = \"nucleo-f401re\"");
|
||||
println!(" transport = \"serial\"");
|
||||
println!(" path = \"/dev/ttyACM0\"");
|
||||
} else {
|
||||
println!("Configured peripherals:");
|
||||
for b in boards {
|
||||
let path = b.path.as_deref().unwrap_or("(native)");
|
||||
println!(" {} {} {}", b.board, b.transport, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::PeripheralCommands::Add { board, path } => {
|
||||
let transport = if path == "native" { "native" } else { "serial" };
|
||||
let path_opt = if path == "native" {
|
||||
None
|
||||
} else {
|
||||
Some(path.clone())
|
||||
};
|
||||
|
||||
let mut cfg = crate::config::Config::load_or_init()?;
|
||||
cfg.peripherals.enabled = true;
|
||||
|
||||
if cfg
|
||||
.peripherals
|
||||
.boards
|
||||
.iter()
|
||||
.any(|b| b.board == board && b.path.as_deref() == path_opt.as_deref())
|
||||
{
|
||||
println!("Board {} at {:?} already configured.", board, path_opt);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cfg.peripherals.boards.push(PeripheralBoardConfig {
|
||||
board: board.clone(),
|
||||
transport: transport.to_string(),
|
||||
path: path_opt,
|
||||
baud: 115200,
|
||||
});
|
||||
cfg.save()?;
|
||||
println!("Added {} at {}. Restart daemon to apply.", board, path);
|
||||
}
|
||||
#[cfg(feature = "hardware")]
|
||||
crate::PeripheralCommands::Flash { port } => {
|
||||
let port_str = arduino_flash::resolve_port(config, port.as_deref())
|
||||
.or_else(|| port.clone())
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"No port specified. Use --port /dev/cu.usbmodem* or add arduino-uno to config.toml"
|
||||
))?;
|
||||
arduino_flash::flash_arduino_firmware(&port_str)?;
|
||||
}
|
||||
#[cfg(not(feature = "hardware"))]
|
||||
crate::PeripheralCommands::Flash { .. } => {
|
||||
println!("Arduino flash requires the 'hardware' feature.");
|
||||
println!("Build with: cargo build --features hardware");
|
||||
}
|
||||
#[cfg(feature = "hardware")]
|
||||
crate::PeripheralCommands::SetupUnoQ { host } => {
|
||||
uno_q_setup::setup_uno_q_bridge(host.as_deref())?;
|
||||
}
|
||||
#[cfg(not(feature = "hardware"))]
|
||||
crate::PeripheralCommands::SetupUnoQ { .. } => {
|
||||
println!("Uno Q setup requires the 'hardware' feature.");
|
||||
println!("Build with: cargo build --features hardware");
|
||||
}
|
||||
#[cfg(feature = "hardware")]
|
||||
crate::PeripheralCommands::FlashNucleo => {
|
||||
nucleo_flash::flash_nucleo_firmware()?;
|
||||
}
|
||||
#[cfg(not(feature = "hardware"))]
|
||||
crate::PeripheralCommands::FlashNucleo => {
|
||||
println!("Nucleo flash requires the 'hardware' feature.");
|
||||
println!("Build with: cargo build --features hardware");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create and connect peripherals from config, returning their tools.
|
||||
/// Returns empty vec if peripherals disabled or hardware feature off.
|
||||
#[cfg(feature = "hardware")]
|
||||
pub async fn create_peripheral_tools(config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
|
||||
if !config.enabled || config.boards.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let mut serial_transports: Vec<(String, std::sync::Arc<serial::SerialTransport>)> = Vec::new();
|
||||
|
||||
for board in &config.boards {
|
||||
// Arduino Uno Q: Bridge transport (socket to local Bridge app)
|
||||
if board.transport == "bridge" && (board.board == "arduino-uno-q" || board.board == "uno-q")
|
||||
{
|
||||
tools.push(Box::new(uno_q_bridge::UnoQGpioReadTool));
|
||||
tools.push(Box::new(uno_q_bridge::UnoQGpioWriteTool));
|
||||
tracing::info!(board = %board.board, "Uno Q Bridge GPIO tools added");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Native transport: RPi GPIO (Linux only)
|
||||
#[cfg(all(feature = "peripheral-rpi", target_os = "linux"))]
|
||||
if board.transport == "native"
|
||||
&& (board.board == "rpi-gpio" || board.board == "raspberry-pi")
|
||||
{
|
||||
match rpi::RpiGpioPeripheral::connect_from_config(board).await {
|
||||
Ok(peripheral) => {
|
||||
tools.extend(peripheral.tools());
|
||||
tracing::info!(board = %board.board, "RPi GPIO peripheral connected");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to connect RPi GPIO {}: {}", board.board, e);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Serial transport (STM32, ESP32, Arduino, etc.)
|
||||
if board.transport != "serial" {
|
||||
continue;
|
||||
}
|
||||
if board.path.is_none() {
|
||||
tracing::warn!("Skipping serial board {}: no path", board.board);
|
||||
continue;
|
||||
}
|
||||
|
||||
match serial::SerialPeripheral::connect(board).await {
|
||||
Ok(peripheral) => {
|
||||
let mut p = peripheral;
|
||||
if p.connect().await.is_err() {
|
||||
tracing::warn!("Peripheral {} connect warning (continuing)", p.name());
|
||||
}
|
||||
serial_transports.push((board.board.clone(), p.transport()));
|
||||
tools.extend(p.tools());
|
||||
if board.board == "arduino-uno" {
|
||||
if let Some(ref path) = board.path {
|
||||
tools.push(Box::new(arduino_upload::ArduinoUploadTool::new(
|
||||
path.clone(),
|
||||
)));
|
||||
tracing::info!("Arduino upload tool added (port: {})", path);
|
||||
}
|
||||
}
|
||||
tracing::info!(board = %board.board, "Serial peripheral connected");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to connect {}: {}", board.board, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase B: Add hardware tools when any boards configured
|
||||
if !tools.is_empty() {
|
||||
let board_names: Vec<String> = config.boards.iter().map(|b| b.board.clone()).collect();
|
||||
tools.push(Box::new(HardwareMemoryMapTool::new(board_names.clone())));
|
||||
tools.push(Box::new(crate::tools::HardwareBoardInfoTool::new(
|
||||
board_names.clone(),
|
||||
)));
|
||||
tools.push(Box::new(crate::tools::HardwareMemoryReadTool::new(
|
||||
board_names,
|
||||
)));
|
||||
}
|
||||
|
||||
// Phase C: Add hardware_capabilities tool when any serial boards
|
||||
if !serial_transports.is_empty() {
|
||||
tools.push(Box::new(capabilities_tool::HardwareCapabilitiesTool::new(
|
||||
serial_transports,
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "hardware"))]
|
||||
pub async fn create_peripheral_tools(_config: &PeripheralsConfig) -> Result<Vec<Box<dyn Tool>>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
83
src/peripherals/nucleo_flash.rs
Normal file
83
src/peripherals/nucleo_flash.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
//! Flash ZeroClaw Nucleo-F401RE firmware via probe-rs.
|
||||
//!
|
||||
//! Builds the Embassy firmware and flashes via ST-Link (built into Nucleo).
|
||||
//! Requires: cargo install probe-rs-tools --locked
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
const CHIP: &str = "STM32F401RETx";
|
||||
const TARGET: &str = "thumbv7em-none-eabihf";
|
||||
|
||||
/// Check if probe-rs CLI is available (from probe-rs-tools).
|
||||
pub fn probe_rs_available() -> bool {
|
||||
Command::new("probe-rs")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Flash ZeroClaw Nucleo firmware. Builds from firmware/zeroclaw-nucleo.
|
||||
pub fn flash_nucleo_firmware() -> Result<()> {
|
||||
if !probe_rs_available() {
|
||||
anyhow::bail!(
|
||||
"probe-rs not found. Install it:\n cargo install probe-rs-tools --locked\n\n\
|
||||
Or: curl -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh\n\n\
|
||||
Connect Nucleo via USB (ST-Link). Then run this command again."
|
||||
);
|
||||
}
|
||||
|
||||
// CARGO_MANIFEST_DIR = repo root (zeroclaw's Cargo.toml)
|
||||
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let firmware_dir = repo_root.join("firmware").join("zeroclaw-nucleo");
|
||||
if !firmware_dir.join("Cargo.toml").exists() {
|
||||
anyhow::bail!(
|
||||
"Nucleo firmware not found at {}. Run from zeroclaw repo root.",
|
||||
firmware_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
println!("Building ZeroClaw Nucleo firmware...");
|
||||
let build = Command::new("cargo")
|
||||
.args(["build", "--release", "--target", TARGET])
|
||||
.current_dir(&firmware_dir)
|
||||
.output()
|
||||
.context("cargo build failed")?;
|
||||
|
||||
if !build.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&build.stderr);
|
||||
anyhow::bail!("Build failed:\n{}", stderr);
|
||||
}
|
||||
|
||||
let elf_path = firmware_dir
|
||||
.join("target")
|
||||
.join(TARGET)
|
||||
.join("release")
|
||||
.join("zeroclaw-nucleo");
|
||||
|
||||
if !elf_path.exists() {
|
||||
anyhow::bail!("Built binary not found at {}", elf_path.display());
|
||||
}
|
||||
|
||||
println!("Flashing to Nucleo-F401RE (connect via USB)...");
|
||||
let flash = Command::new("probe-rs")
|
||||
.args(["run", "--chip", CHIP, elf_path.to_str().unwrap()])
|
||||
.output()
|
||||
.context("probe-rs run failed")?;
|
||||
|
||||
if !flash.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&flash.stderr);
|
||||
anyhow::bail!(
|
||||
"Flash failed:\n{}\n\n\
|
||||
Ensure Nucleo is connected via USB. The ST-Link is built into the board.",
|
||||
stderr
|
||||
);
|
||||
}
|
||||
|
||||
println!("ZeroClaw Nucleo firmware flashed successfully.");
|
||||
println!("The Nucleo now supports: ping, capabilities, gpio_read, gpio_write.");
|
||||
println!("Add to config.toml: board = \"nucleo-f401re\", transport = \"serial\", path = \"/dev/ttyACM0\"");
|
||||
Ok(())
|
||||
}
|
||||
173
src/peripherals/rpi.rs
Normal file
173
src/peripherals/rpi.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//! Raspberry Pi GPIO peripheral — native rppal access.
|
||||
//!
|
||||
//! Only compiled when `peripheral-rpi` feature is enabled and target is Linux.
|
||||
//! Uses BCM pin numbering (e.g. GPIO 17, 27).
|
||||
|
||||
use crate::config::PeripheralBoardConfig;
|
||||
use crate::peripherals::traits::Peripheral;
|
||||
use crate::tools::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// RPi GPIO peripheral — direct access via rppal.
|
||||
pub struct RpiGpioPeripheral {
|
||||
board: PeripheralBoardConfig,
|
||||
}
|
||||
|
||||
impl RpiGpioPeripheral {
|
||||
/// Create a new RPi GPIO peripheral from config.
|
||||
pub fn new(board: PeripheralBoardConfig) -> Self {
|
||||
Self { board }
|
||||
}
|
||||
|
||||
/// Attempt to connect (init rppal). Returns Ok if GPIO is available.
|
||||
pub async fn connect_from_config(board: &PeripheralBoardConfig) -> anyhow::Result<Self> {
|
||||
let mut peripheral = Self::new(board.clone());
|
||||
peripheral.connect().await?;
|
||||
Ok(peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Peripheral for RpiGpioPeripheral {
|
||||
fn name(&self) -> &str {
|
||||
&self.board.board
|
||||
}
|
||||
|
||||
fn board_type(&self) -> &str {
|
||||
"rpi-gpio"
|
||||
}
|
||||
|
||||
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||
// Verify GPIO is accessible by doing a no-op init
|
||||
let result = tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new()).await??;
|
||||
drop(result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
tokio::task::spawn_blocking(|| rppal::gpio::Gpio::new().is_ok())
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn tools(&self) -> Vec<Box<dyn Tool>> {
|
||||
vec![Box::new(RpiGpioReadTool), Box::new(RpiGpioWriteTool)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: read GPIO pin value (BCM numbering).
|
||||
struct RpiGpioReadTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for RpiGpioReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read the value (0 or 1) of a GPIO pin on Raspberry Pi. Uses BCM pin numbers (e.g. 17, 27)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "BCM GPIO pin number (e.g. 17, 27)"
|
||||
}
|
||||
},
|
||||
"required": ["pin"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let pin = args
|
||||
.get("pin")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
|
||||
let pin_u8 = pin as u8;
|
||||
|
||||
let value = tokio::task::spawn_blocking(move || {
|
||||
let gpio = rppal::gpio::Gpio::new()?;
|
||||
let pin = gpio.get(pin_u8)?.into_input();
|
||||
Ok::<_, anyhow::Error>(match pin.read() {
|
||||
rppal::gpio::Level::Low => 0,
|
||||
rppal::gpio::Level::High => 1,
|
||||
})
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("pin {} = {}", pin, value),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: write GPIO pin value (BCM numbering).
|
||||
struct RpiGpioWriteTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for RpiGpioWriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Set a GPIO pin high (1) or low (0) on Raspberry Pi. Uses BCM pin numbers."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "BCM GPIO pin number"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer",
|
||||
"description": "0 for low, 1 for high"
|
||||
}
|
||||
},
|
||||
"required": ["pin", "value"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let pin = args
|
||||
.get("pin")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
|
||||
let value = args
|
||||
.get("value")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?;
|
||||
let pin_u8 = pin as u8;
|
||||
let level = match value {
|
||||
0 => rppal::gpio::Level::Low,
|
||||
_ => rppal::gpio::Level::High,
|
||||
};
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let gpio = rppal::gpio::Gpio::new()?;
|
||||
let mut pin = gpio.get(pin_u8)?.into_output();
|
||||
pin.write(level);
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!("pin {} = {}", pin, value),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
274
src/peripherals/serial.rs
Normal file
274
src/peripherals/serial.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
//! Serial peripheral — STM32 and similar boards over USB CDC/serial.
|
||||
//!
|
||||
//! Protocol: newline-delimited JSON.
|
||||
//! Request: {"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}
|
||||
//! Response: {"id":"1","ok":true,"result":"done"}
|
||||
|
||||
use super::traits::Peripheral;
|
||||
use crate::config::PeripheralBoardConfig;
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_serial::{SerialPortBuilderExt, SerialStream};
|
||||
|
||||
/// Allowed serial path patterns (security: deny arbitrary paths).
|
||||
const ALLOWED_PATH_PREFIXES: &[&str] = &[
|
||||
"/dev/ttyACM",
|
||||
"/dev/ttyUSB",
|
||||
"/dev/tty.usbmodem",
|
||||
"/dev/cu.usbmodem",
|
||||
"/dev/tty.usbserial",
|
||||
"/dev/cu.usbserial", // Arduino Uno (FTDI), clones
|
||||
"COM", // Windows
|
||||
];
|
||||
|
||||
fn is_path_allowed(path: &str) -> bool {
|
||||
ALLOWED_PATH_PREFIXES.iter().any(|p| path.starts_with(p))
|
||||
}
|
||||
|
||||
/// JSON request/response over serial.
|
||||
async fn send_request(port: &mut SerialStream, cmd: &str, args: Value) -> anyhow::Result<Value> {
|
||||
static ID: AtomicU64 = AtomicU64::new(0);
|
||||
let id = ID.fetch_add(1, Ordering::Relaxed);
|
||||
let id_str = id.to_string();
|
||||
|
||||
let req = json!({
|
||||
"id": id_str,
|
||||
"cmd": cmd,
|
||||
"args": args
|
||||
});
|
||||
let line = format!("{}\n", req);
|
||||
|
||||
port.write_all(line.as_bytes()).await?;
|
||||
port.flush().await?;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut b = [0u8; 1];
|
||||
while port.read_exact(&mut b).await.is_ok() {
|
||||
if b[0] == b'\n' {
|
||||
break;
|
||||
}
|
||||
buf.push(b[0]);
|
||||
}
|
||||
let line_str = String::from_utf8_lossy(&buf);
|
||||
let resp: Value = serde_json::from_str(line_str.trim())?;
|
||||
let resp_id = resp["id"].as_str().unwrap_or("");
|
||||
if resp_id != id_str {
|
||||
anyhow::bail!("Response id mismatch: expected {}, got {}", id_str, resp_id);
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Shared serial transport for tools. Pub(crate) for capabilities tool.
|
||||
pub(crate) struct SerialTransport {
|
||||
port: Mutex<SerialStream>,
|
||||
}
|
||||
|
||||
/// Timeout for serial request/response (seconds).
|
||||
const SERIAL_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
impl SerialTransport {
|
||||
async fn request(&self, cmd: &str, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let mut port = self.port.lock().await;
|
||||
let resp = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(SERIAL_TIMEOUT_SECS),
|
||||
send_request(&mut *port, cmd, args),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("Serial request timed out after {}s", SERIAL_TIMEOUT_SECS)
|
||||
})??;
|
||||
|
||||
let ok = resp["ok"].as_bool().unwrap_or(false);
|
||||
let result = resp["result"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| resp["result"].to_string());
|
||||
let error = resp["error"].as_str().map(String::from);
|
||||
|
||||
Ok(ToolResult {
|
||||
success: ok,
|
||||
output: result,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase C: fetch capabilities from device (gpio pins, led_pin).
|
||||
pub async fn capabilities(&self) -> anyhow::Result<ToolResult> {
|
||||
self.request("capabilities", json!({})).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Serial peripheral for STM32, Arduino, etc. over USB CDC.
|
||||
pub struct SerialPeripheral {
|
||||
name: String,
|
||||
board_type: String,
|
||||
transport: Arc<SerialTransport>,
|
||||
}
|
||||
|
||||
impl SerialPeripheral {
|
||||
/// Create and connect to a serial peripheral.
|
||||
pub async fn connect(config: &PeripheralBoardConfig) -> anyhow::Result<Self> {
|
||||
let path = config
|
||||
.path
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Serial peripheral requires path"))?;
|
||||
|
||||
if !is_path_allowed(path) {
|
||||
anyhow::bail!(
|
||||
"Serial path not allowed: {}. Allowed: /dev/ttyACM*, /dev/ttyUSB*, /dev/tty.usbmodem*, /dev/cu.usbmodem*",
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
let port = tokio_serial::new(path, config.baud)
|
||||
.open_native_async()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to open {}: {}", path, e))?;
|
||||
|
||||
let name = format!("{}-{}", config.board, path.replace('/', "_"));
|
||||
let transport = Arc::new(SerialTransport {
|
||||
port: Mutex::new(port),
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
name: name.clone(),
|
||||
board_type: config.board.clone(),
|
||||
transport,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Peripheral for SerialPeripheral {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn board_type(&self) -> &str {
|
||||
&self.board_type
|
||||
}
|
||||
|
||||
async fn connect(&mut self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
self.transport
|
||||
.request("ping", json!({}))
|
||||
.await
|
||||
.map(|r| r.success)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn tools(&self) -> Vec<Box<dyn Tool>> {
|
||||
vec![
|
||||
Box::new(GpioReadTool {
|
||||
transport: self.transport.clone(),
|
||||
}),
|
||||
Box::new(GpioWriteTool {
|
||||
transport: self.transport.clone(),
|
||||
}),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl SerialPeripheral {
|
||||
/// Expose transport for capabilities tool (Phase C).
|
||||
pub(crate) fn transport(&self) -> Arc<SerialTransport> {
|
||||
self.transport.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: read GPIO pin value.
|
||||
struct GpioReadTool {
|
||||
transport: Arc<SerialTransport>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GpioReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read the value (0 or 1) of a GPIO pin on a connected peripheral (e.g. STM32 Nucleo)"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "GPIO pin number (e.g. 13 for LED on Nucleo)"
|
||||
}
|
||||
},
|
||||
"required": ["pin"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let pin = args
|
||||
.get("pin")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
|
||||
self.transport
|
||||
.request("gpio_read", json!({ "pin": pin }))
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: write GPIO pin value.
|
||||
struct GpioWriteTool {
|
||||
transport: Arc<SerialTransport>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GpioWriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Set a GPIO pin high (1) or low (0) on a connected peripheral (e.g. turn on/off LED)"
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "GPIO pin number"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer",
|
||||
"description": "0 for low, 1 for high"
|
||||
}
|
||||
},
|
||||
"required": ["pin", "value"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let pin = args
|
||||
.get("pin")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
|
||||
let value = args
|
||||
.get("value")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?;
|
||||
self.transport
|
||||
.request("gpio_write", json!({ "pin": pin, "value": value }))
|
||||
.await
|
||||
}
|
||||
}
|
||||
33
src/peripherals/traits.rs
Normal file
33
src/peripherals/traits.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! Peripheral trait — hardware boards (STM32, RPi GPIO) that expose tools.
|
||||
//!
|
||||
//! Peripherals are the agent's "arms and legs": remote devices that run minimal
|
||||
//! firmware and expose capabilities (GPIO, sensors, actuators) as tools.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::tools::Tool;
|
||||
|
||||
/// A hardware peripheral that exposes capabilities as tools.
|
||||
///
|
||||
/// Implement this for boards like Nucleo-F401RE (serial), RPi GPIO (native), etc.
|
||||
/// When connected, the peripheral's tools are merged into the agent's tool registry.
|
||||
#[async_trait]
|
||||
pub trait Peripheral: Send + Sync {
|
||||
/// Human-readable peripheral name (e.g. "nucleo-f401re-0")
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Board type identifier (e.g. "nucleo-f401re", "rpi-gpio")
|
||||
fn board_type(&self) -> &str;
|
||||
|
||||
/// Connect to the peripheral (open serial, init GPIO, etc.)
|
||||
async fn connect(&mut self) -> anyhow::Result<()>;
|
||||
|
||||
/// Disconnect and release resources
|
||||
async fn disconnect(&mut self) -> anyhow::Result<()>;
|
||||
|
||||
/// Check if the peripheral is reachable and responsive
|
||||
async fn health_check(&self) -> bool;
|
||||
|
||||
/// Tools this peripheral provides (e.g. gpio_read, gpio_write, sensor_read)
|
||||
fn tools(&self) -> Vec<Box<dyn Tool>>;
|
||||
}
|
||||
151
src/peripherals/uno_q_bridge.rs
Normal file
151
src/peripherals/uno_q_bridge.rs
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
//! Arduino Uno Q Bridge — GPIO via socket to Bridge app.
|
||||
//!
|
||||
//! When ZeroClaw runs on Uno Q, the Bridge app (Python + MCU) exposes
|
||||
//! digitalWrite/digitalRead over a local socket. These tools connect to it.
|
||||
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
const BRIDGE_HOST: &str = "127.0.0.1";
|
||||
const BRIDGE_PORT: u16 = 9999;
|
||||
|
||||
async fn bridge_request(cmd: &str, args: &[String]) -> anyhow::Result<String> {
|
||||
let addr = format!("{}:{}", BRIDGE_HOST, BRIDGE_PORT);
|
||||
let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&addr))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Bridge connection timed out"))??;
|
||||
|
||||
let msg = format!("{} {}\n", cmd, args.join(" "));
|
||||
stream.write_all(msg.as_bytes()).await?;
|
||||
|
||||
let mut buf = vec![0u8; 64];
|
||||
let n = tokio::time::timeout(Duration::from_secs(3), stream.read(&mut buf))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Bridge response timed out"))??;
|
||||
let resp = String::from_utf8_lossy(&buf[..n]).trim().to_string();
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Tool: read GPIO pin via Uno Q Bridge.
|
||||
pub struct UnoQGpioReadTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for UnoQGpioReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read GPIO pin value (0 or 1) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "GPIO pin number (e.g. 13 for LED)"
|
||||
}
|
||||
},
|
||||
"required": ["pin"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let pin = args
|
||||
.get("pin")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
|
||||
match bridge_request("gpio_read", &[pin.to_string()]).await {
|
||||
Ok(resp) => {
|
||||
if resp.starts_with("error:") {
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: resp.clone(),
|
||||
error: Some(resp),
|
||||
})
|
||||
} else {
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: resp,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!("Bridge error: {}", e),
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool: write GPIO pin via Uno Q Bridge.
|
||||
pub struct UnoQGpioWriteTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for UnoQGpioWriteTool {
|
||||
fn name(&self) -> &str {
|
||||
"gpio_write"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Set GPIO pin high (1) or low (0) on Arduino Uno Q. Requires zeroclaw-uno-q-bridge app running."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pin": {
|
||||
"type": "integer",
|
||||
"description": "GPIO pin number"
|
||||
},
|
||||
"value": {
|
||||
"type": "integer",
|
||||
"description": "0 for low, 1 for high"
|
||||
}
|
||||
},
|
||||
"required": ["pin", "value"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
let pin = args
|
||||
.get("pin")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'pin' parameter"))?;
|
||||
let value = args
|
||||
.get("value")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'value' parameter"))?;
|
||||
match bridge_request("gpio_write", &[pin.to_string(), value.to_string()]).await {
|
||||
Ok(resp) => {
|
||||
if resp.starts_with("error:") {
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: resp.clone(),
|
||||
error: Some(resp),
|
||||
})
|
||||
} else {
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: "done".into(),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!("Bridge error: {}", e),
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/peripherals/uno_q_setup.rs
Normal file
143
src/peripherals/uno_q_setup.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! Deploy ZeroClaw Bridge app to Arduino Uno Q.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::process::Command;
|
||||
|
||||
const BRIDGE_APP_NAME: &str = "zeroclaw-uno-q-bridge";
|
||||
|
||||
/// Deploy the Bridge app. If host is Some, scp from repo and ssh to start.
|
||||
/// If host is None, assume we're ON the Uno Q — use embedded files and start.
|
||||
pub fn setup_uno_q_bridge(host: Option<&str>) -> Result<()> {
|
||||
let bridge_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("firmware")
|
||||
.join("zeroclaw-uno-q-bridge");
|
||||
|
||||
if let Some(h) = host {
|
||||
if bridge_dir.exists() {
|
||||
deploy_remote(h, &bridge_dir)?;
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Bridge app not found at {}. Run from zeroclaw repo root.",
|
||||
bridge_dir.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
deploy_local(if bridge_dir.exists() {
|
||||
Some(&bridge_dir)
|
||||
} else {
|
||||
None
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deploy_remote(host: &str, bridge_dir: &std::path::Path) -> Result<()> {
|
||||
let ssh_target = if host.contains('@') {
|
||||
host.to_string()
|
||||
} else {
|
||||
format!("arduino@{}", host)
|
||||
};
|
||||
|
||||
println!("Copying Bridge app to {}...", host);
|
||||
let status = Command::new("ssh")
|
||||
.args([&ssh_target, "mkdir", "-p", "~/ArduinoApps"])
|
||||
.status()
|
||||
.context("ssh mkdir failed")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to create ArduinoApps dir on Uno Q");
|
||||
}
|
||||
|
||||
let status = Command::new("scp")
|
||||
.args([
|
||||
"-r",
|
||||
bridge_dir.to_str().unwrap(),
|
||||
&format!("{}:~/ArduinoApps/", ssh_target),
|
||||
])
|
||||
.status()
|
||||
.context("scp failed")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to copy Bridge app");
|
||||
}
|
||||
|
||||
println!("Starting Bridge app on Uno Q...");
|
||||
let status = Command::new("ssh")
|
||||
.args([
|
||||
&ssh_target,
|
||||
"arduino-app-cli",
|
||||
"app",
|
||||
"start",
|
||||
&format!("~/ArduinoApps/zeroclaw-uno-q-bridge"),
|
||||
])
|
||||
.status()
|
||||
.context("arduino-app-cli start failed")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q.");
|
||||
}
|
||||
|
||||
println!("ZeroClaw Bridge app started. Add to config.toml:");
|
||||
println!(" [[peripherals.boards]]");
|
||||
println!(" board = \"arduino-uno-q\"");
|
||||
println!(" transport = \"bridge\"");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deploy_local(bridge_dir: Option<&std::path::Path>) -> Result<()> {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/home/arduino".into());
|
||||
let apps_dir = std::path::Path::new(&home).join("ArduinoApps");
|
||||
let dest_dir = apps_dir.join(BRIDGE_APP_NAME);
|
||||
|
||||
std::fs::create_dir_all(&dest_dir).context("create dest dir")?;
|
||||
|
||||
if let Some(src) = bridge_dir {
|
||||
println!("Copying Bridge app from repo...");
|
||||
copy_dir(src, &dest_dir)?;
|
||||
} else {
|
||||
println!("Writing embedded Bridge app...");
|
||||
write_embedded_bridge(&dest_dir)?;
|
||||
}
|
||||
|
||||
println!("Starting Bridge app...");
|
||||
let status = Command::new("arduino-app-cli")
|
||||
.args(["app", "start", dest_dir.to_str().unwrap()])
|
||||
.status()
|
||||
.context("arduino-app-cli start failed")?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("Failed to start Bridge app. Ensure arduino-app-cli is installed on Uno Q.");
|
||||
}
|
||||
|
||||
println!("ZeroClaw Bridge app started.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_embedded_bridge(dest: &std::path::Path) -> Result<()> {
|
||||
let app_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/app.yaml");
|
||||
let sketch_ino = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.ino");
|
||||
let sketch_yaml = include_str!("../../firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml");
|
||||
let main_py = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/main.py");
|
||||
let requirements = include_str!("../../firmware/zeroclaw-uno-q-bridge/python/requirements.txt");
|
||||
|
||||
std::fs::write(dest.join("app.yaml"), app_yaml)?;
|
||||
std::fs::create_dir_all(dest.join("sketch"))?;
|
||||
std::fs::write(dest.join("sketch").join("sketch.ino"), sketch_ino)?;
|
||||
std::fs::write(dest.join("sketch").join("sketch.yaml"), sketch_yaml)?;
|
||||
std::fs::create_dir_all(dest.join("python"))?;
|
||||
std::fs::write(dest.join("python").join("main.py"), main_py)?;
|
||||
std::fs::write(dest.join("python").join("requirements.txt"), requirements)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> Result<()> {
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let e = entry?;
|
||||
let name = e.file_name();
|
||||
let src_path = src.join(&name);
|
||||
let dst_path = dst.join(&name);
|
||||
if e.file_type()?.is_dir() {
|
||||
std::fs::create_dir_all(&dst_path)?;
|
||||
copy_dir(&src_path, &dst_path)?;
|
||||
} else {
|
||||
std::fs::copy(&src_path, &dst_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -26,13 +30,79 @@ struct Message {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiChatResponse {
|
||||
struct ChatResponse {
|
||||
content: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ContentBlock {
|
||||
text: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
model: String,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system: Option<String>,
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<NativeToolSpec>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
content: Vec<NativeContentOut>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum NativeContentOut {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChatResponse {
|
||||
#[serde(default)]
|
||||
content: Vec<NativeContentIn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeContentIn {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
|
|
@ -62,6 +132,186 @@ impl AnthropicProvider {
|
|||
fn is_setup_token(token: &str) -> bool {
|
||||
token.starts_with("sk-ant-oat01-")
|
||||
}
|
||||
|
||||
fn apply_auth(
|
||||
&self,
|
||||
request: reqwest::RequestBuilder,
|
||||
credential: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
if Self::is_setup_token(credential) {
|
||||
request.header("Authorization", format!("Bearer {credential}"))
|
||||
} else {
|
||||
request.header("x-api-key", credential)
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
let items = tools?;
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| NativeToolSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
input_schema: tool.parameters.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_assistant_tool_call_message(content: &str) -> Option<Vec<NativeContentOut>> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
|
||||
let tool_calls = value
|
||||
.get("tool_calls")
|
||||
.and_then(|v| serde_json::from_value::<Vec<ProviderToolCall>>(v.clone()).ok())?;
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
if let Some(text) = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
{
|
||||
blocks.push(NativeContentOut::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
for call in tool_calls {
|
||||
let input = serde_json::from_str::<serde_json::Value>(&call.arguments)
|
||||
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
|
||||
blocks.push(NativeContentOut::ToolUse {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
input,
|
||||
});
|
||||
}
|
||||
Some(blocks)
|
||||
}
|
||||
|
||||
fn parse_tool_result_message(content: &str) -> Option<NativeMessage> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
|
||||
let tool_use_id = value
|
||||
.get("tool_call_id")
|
||||
.and_then(serde_json::Value::as_str)?
|
||||
.to_string();
|
||||
let result = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::ToolResult {
|
||||
tool_use_id,
|
||||
content: result,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<NativeMessage>) {
|
||||
let mut system_prompt = None;
|
||||
let mut native_messages = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
match msg.role.as_str() {
|
||||
"system" => {
|
||||
if system_prompt.is_none() {
|
||||
system_prompt = Some(msg.content.clone());
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
if let Some(blocks) = Self::parse_assistant_tool_call_message(&msg.content) {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: blocks,
|
||||
});
|
||||
} else {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
if let Some(tool_result) = Self::parse_tool_result_message(&msg.content) {
|
||||
native_messages.push(tool_result);
|
||||
} else {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(system_prompt, native_messages)
|
||||
}
|
||||
|
||||
fn parse_text_response(response: ChatResponse) -> anyhow::Result<String> {
|
||||
response
|
||||
.content
|
||||
.into_iter()
|
||||
.find(|c| c.kind == "text")
|
||||
.and_then(|c| c.text)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Anthropic"))
|
||||
}
|
||||
|
||||
fn parse_native_response(response: NativeChatResponse) -> ProviderChatResponse {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
for block in response.content {
|
||||
match block.kind.as_str() {
|
||||
"text" => {
|
||||
if let Some(text) = block.text.map(|t| t.trim().to_string()) {
|
||||
if !text.is_empty() {
|
||||
text_parts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let name = block.name.unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let arguments = block
|
||||
.input
|
||||
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
|
||||
tool_calls.push(ProviderToolCall {
|
||||
id: block.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name,
|
||||
arguments: arguments.to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderChatResponse {
|
||||
text: if text_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text_parts.join("\n"))
|
||||
},
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -72,7 +322,7 @@ impl Provider for AnthropicProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)."
|
||||
|
|
@ -97,11 +347,7 @@ impl Provider for AnthropicProvider {
|
|||
.header("content-type", "application/json")
|
||||
.json(&request);
|
||||
|
||||
if Self::is_setup_token(credential) {
|
||||
request = request.header("Authorization", format!("Bearer {credential}"));
|
||||
} else {
|
||||
request = request.header("x-api-key", credential);
|
||||
}
|
||||
request = self.apply_auth(request, credential);
|
||||
|
||||
let response = request.send().await?;
|
||||
|
||||
|
|
@ -109,14 +355,50 @@ impl Provider for AnthropicProvider {
|
|||
return Err(super::api_error("Anthropic", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let chat_response: ChatResponse = response.json().await?;
|
||||
Self::parse_text_response(chat_response)
|
||||
}
|
||||
|
||||
chat_response
|
||||
.content
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ProviderChatResponse::with_text(c.text))
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Anthropic"))
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)."
|
||||
)
|
||||
})?;
|
||||
|
||||
let (system_prompt, messages) = Self::convert_messages(request.messages);
|
||||
let native_request = NativeChatRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: 4096,
|
||||
system: system_prompt,
|
||||
messages,
|
||||
temperature,
|
||||
tools: Self::convert_tools(request.tools),
|
||||
};
|
||||
|
||||
let req = self
|
||||
.client
|
||||
.post(format!("{}/v1/messages", self.base_url))
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&native_request);
|
||||
|
||||
let response = self.apply_auth(req, credential).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("Anthropic", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
Ok(Self::parse_native_response(native_response))
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,15 +523,16 @@ mod tests {
|
|||
#[test]
|
||||
fn chat_response_deserializes() {
|
||||
let json = r#"{"content":[{"type":"text","text":"Hello there!"}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.content.len(), 1);
|
||||
assert_eq!(resp.content[0].text, "Hello there!");
|
||||
assert_eq!(resp.content[0].kind, "text");
|
||||
assert_eq!(resp.content[0].text.as_deref(), Some("Hello there!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_empty_content() {
|
||||
let json = r#"{"content":[]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.content.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -257,10 +540,10 @@ mod tests {
|
|||
fn chat_response_multiple_blocks() {
|
||||
let json =
|
||||
r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.content.len(), 2);
|
||||
assert_eq!(resp.content[0].text, "First");
|
||||
assert_eq!(resp.content[1].text, "Second");
|
||||
assert_eq!(resp.content[0].text.as_deref(), Some("First"));
|
||||
assert_eq!(resp.content[1].text.as_deref(), Some("Second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
//! Most LLM APIs follow the same `/v1/chat/completions` format.
|
||||
//! This module provides a single implementation that works for all of them.
|
||||
|
||||
use crate::providers::traits::{ChatMessage, ChatResponse, Provider, ToolCall};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -15,6 +18,9 @@ pub struct OpenAiCompatibleProvider {
|
|||
pub(crate) base_url: String,
|
||||
pub(crate) api_key: Option<String>,
|
||||
pub(crate) auth_header: AuthStyle,
|
||||
/// When false, do not fall back to /v1/responses on chat completions 404.
|
||||
/// GLM/Zhipu does not support the responses API.
|
||||
supports_responses_fallback: bool,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +42,29 @@ impl OpenAiCompatibleProvider {
|
|||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key: api_key.map(ToString::to_string),
|
||||
auth_header: auth_style,
|
||||
supports_responses_fallback: true,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as `new` but skips the /v1/responses fallback on 404.
|
||||
/// Use for providers (e.g. GLM) that only support chat completions.
|
||||
pub fn new_no_responses_fallback(
|
||||
name: &str,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
auth_style: AuthStyle,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
api_key: api_key.map(ToString::to_string),
|
||||
auth_header: auth_style,
|
||||
supports_responses_fallback: false,
|
||||
client: Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
|
|
@ -112,6 +141,8 @@ struct ChatRequest {
|
|||
model: String,
|
||||
messages: Vec<Message>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -135,12 +166,11 @@ struct ResponseMessage {
|
|||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<ApiToolCall>>,
|
||||
tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ApiToolCall {
|
||||
id: Option<String>,
|
||||
struct ToolCall {
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
function: Option<Function>,
|
||||
|
|
@ -226,44 +256,6 @@ fn extract_responses_text(response: ResponsesResponse) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn map_response_message(message: ResponseMessage) -> ChatResponse {
|
||||
let text = first_nonempty(message.content.as_deref());
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, call)| map_api_tool_call(call, index))
|
||||
.collect();
|
||||
|
||||
ChatResponse { text, tool_calls }
|
||||
}
|
||||
|
||||
fn map_api_tool_call(call: ApiToolCall, index: usize) -> Option<ToolCall> {
|
||||
if call.kind.as_deref().is_some_and(|kind| kind != "function") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let function = call.function?;
|
||||
let name = function
|
||||
.name
|
||||
.and_then(|value| first_nonempty(Some(value.as_str())))?;
|
||||
let arguments = function
|
||||
.arguments
|
||||
.and_then(|value| first_nonempty(Some(value.as_str())))
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
let id = call
|
||||
.id
|
||||
.and_then(|value| first_nonempty(Some(value.as_str())))
|
||||
.unwrap_or_else(|| format!("call_{}", index + 1));
|
||||
|
||||
Some(ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
})
|
||||
}
|
||||
|
||||
impl OpenAiCompatibleProvider {
|
||||
fn apply_auth_header(
|
||||
&self,
|
||||
|
|
@ -283,7 +275,7 @@ impl OpenAiCompatibleProvider {
|
|||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let request = ResponsesRequest {
|
||||
model: model.to_string(),
|
||||
input: vec![ResponsesInput {
|
||||
|
|
@ -309,7 +301,6 @@ impl OpenAiCompatibleProvider {
|
|||
let responses: ResponsesResponse = response.json().await?;
|
||||
|
||||
extract_responses_text(responses)
|
||||
.map(ChatResponse::with_text)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {} Responses API", self.name))
|
||||
}
|
||||
}
|
||||
|
|
@ -322,7 +313,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.",
|
||||
|
|
@ -348,6 +339,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
model: model.to_string(),
|
||||
messages,
|
||||
temperature,
|
||||
stream: Some(false),
|
||||
};
|
||||
|
||||
let url = self.chat_completions_url();
|
||||
|
|
@ -362,7 +354,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
let error = response.text().await?;
|
||||
let sanitized = super::sanitize_api_error(&error);
|
||||
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback {
|
||||
return self
|
||||
.chat_via_responses(api_key, system_prompt, message, model)
|
||||
.await
|
||||
|
|
@ -379,13 +371,27 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
|
||||
let choice = chat_response
|
||||
chat_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?;
|
||||
|
||||
Ok(map_response_message(choice.message))
|
||||
.map(|c| {
|
||||
// If tool_calls are present, serialize the full message as JSON
|
||||
// so parse_tool_calls can handle the OpenAI-style format
|
||||
if c.message.tool_calls.is_some()
|
||||
&& c.message
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map_or(false, |t| !t.is_empty())
|
||||
{
|
||||
serde_json::to_string(&c.message)
|
||||
.unwrap_or_else(|_| c.message.content.unwrap_or_default())
|
||||
} else {
|
||||
// No tool calls, return content as-is
|
||||
c.message.content.unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
|
|
@ -393,7 +399,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.",
|
||||
|
|
@ -413,6 +419,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
model: model.to_string(),
|
||||
messages: api_messages,
|
||||
temperature,
|
||||
stream: Some(false),
|
||||
};
|
||||
|
||||
let url = self.chat_completions_url();
|
||||
|
|
@ -425,7 +432,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
let status = response.status();
|
||||
|
||||
// Mirror chat_with_system: 404 may mean this provider uses the Responses API
|
||||
if status == reqwest::StatusCode::NOT_FOUND {
|
||||
if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback {
|
||||
// Extract system prompt and last user message for responses fallback
|
||||
let system = messages.iter().find(|m| m.role == "system");
|
||||
let last_user = messages.iter().rfind(|m| m.role == "user");
|
||||
|
|
@ -452,13 +459,71 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
|
||||
let choice = chat_response
|
||||
chat_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?;
|
||||
.map(|c| {
|
||||
// If tool_calls are present, serialize the full message as JSON
|
||||
// so parse_tool_calls can handle the OpenAI-style format
|
||||
if c.message.tool_calls.is_some()
|
||||
&& c.message
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map_or(false, |t| !t.is_empty())
|
||||
{
|
||||
serde_json::to_string(&c.message)
|
||||
.unwrap_or_else(|_| c.message.content.unwrap_or_default())
|
||||
} else {
|
||||
// No tool calls, return content as-is
|
||||
c.message.content.unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))
|
||||
}
|
||||
|
||||
Ok(map_response_message(choice.message))
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let text = self
|
||||
.chat_with_history(request.messages, model, temperature)
|
||||
.await?;
|
||||
|
||||
// Backward compatible path: chat_with_history may serialize tool_calls JSON into content.
|
||||
if let Ok(message) = serde_json::from_str::<ResponseMessage>(&text) {
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|tc| {
|
||||
let function = tc.function?;
|
||||
let name = function.name?;
|
||||
let arguments = function.arguments.unwrap_or_else(|| "{}".to_string());
|
||||
Some(ProviderToolCall {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
arguments,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
return Ok(ProviderChatResponse {
|
||||
text: message.content,
|
||||
tool_calls,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ProviderChatResponse {
|
||||
text: Some(text),
|
||||
tool_calls: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -517,7 +582,8 @@ mod tests {
|
|||
content: "hello".to_string(),
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
temperature: 0.4,
|
||||
stream: Some(false),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("llama-3.3-70b"));
|
||||
|
|
@ -542,20 +608,6 @@ mod tests {
|
|||
assert!(resp.choices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_with_tool_calls_maps_structured_data() {
|
||||
let json = r#"{"choices":[{"message":{"content":"Running checks","tool_calls":[{"id":"call_1","type":"function","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let choice = resp.choices.into_iter().next().unwrap();
|
||||
|
||||
let mapped = map_response_message(choice.message);
|
||||
assert_eq!(mapped.text.as_deref(), Some("Running checks"));
|
||||
assert_eq!(mapped.tool_calls.len(), 1);
|
||||
assert_eq!(mapped.tool_calls[0].id, "call_1");
|
||||
assert_eq!(mapped.tool_calls[0].name, "shell");
|
||||
assert_eq!(mapped.tool_calls[0].arguments, r#"{"command":"pwd"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x_api_key_auth_style() {
|
||||
let p = OpenAiCompatibleProvider::new(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! - Gemini CLI OAuth tokens (reuse existing ~/.gemini/ authentication)
|
||||
//! - Google Cloud ADC (`GOOGLE_APPLICATION_CREDENTIALS`)
|
||||
|
||||
use crate::providers::traits::{ChatResponse, Provider};
|
||||
use crate::providers::traits::Provider;
|
||||
use async_trait::async_trait;
|
||||
use directories::UserDirs;
|
||||
use reqwest::Client;
|
||||
|
|
@ -260,7 +260,7 @@ impl Provider for GeminiProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let auth = self.auth.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Gemini API key not found. Options:\n\
|
||||
|
|
@ -319,7 +319,6 @@ impl Provider for GeminiProvider {
|
|||
.and_then(|c| c.into_iter().next())
|
||||
.and_then(|c| c.content.parts.into_iter().next())
|
||||
.and_then(|p| p.text)
|
||||
.map(ChatResponse::with_text)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Gemini"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ pub mod router;
|
|||
pub mod traits;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use traits::{ChatMessage, ChatResponse, Provider, ToolCall};
|
||||
pub use traits::{
|
||||
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolCall,
|
||||
ToolResultMessage,
|
||||
};
|
||||
|
||||
use compatible::{AuthStyle, OpenAiCompatibleProvider};
|
||||
use reliable::ReliableProvider;
|
||||
|
|
@ -123,6 +126,9 @@ fn resolve_api_key(name: &str, api_key: Option<&str>) -> Option<String> {
|
|||
"glm" | "zhipu" => vec!["GLM_API_KEY"],
|
||||
"minimax" => vec!["MINIMAX_API_KEY"],
|
||||
"qianfan" | "baidu" => vec!["QIANFAN_API_KEY"],
|
||||
"qwen" | "dashscope" | "qwen-intl" | "dashscope-intl" | "qwen-us" | "dashscope-us" => {
|
||||
vec!["DASHSCOPE_API_KEY"]
|
||||
}
|
||||
"zai" | "z.ai" => vec!["ZAI_API_KEY"],
|
||||
"synthetic" => vec!["SYNTHETIC_API_KEY"],
|
||||
"opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
|
||||
|
|
@ -202,7 +208,7 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
|
|||
"cloudflare" | "cloudflare-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Cloudflare AI Gateway",
|
||||
"https://gateway.ai.cloudflare.com/v1",
|
||||
api_key,
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
"moonshot" | "kimi" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
|
|
@ -217,8 +223,8 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
|
|||
"zai" | "z.ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Z.AI", "https://api.z.ai/api/coding/paas/v4", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"GLM", "https://open.bigmodel.cn/api/paas/v4", key, AuthStyle::Bearer,
|
||||
"glm" | "zhipu" => Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback(
|
||||
"GLM", "https://api.z.ai/api/paas/v4", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"minimax" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"MiniMax",
|
||||
|
|
@ -229,12 +235,21 @@ pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<
|
|||
"bedrock" | "aws-bedrock" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Amazon Bedrock",
|
||||
"https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
api_key,
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
"qianfan" | "baidu" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"qwen" | "dashscope" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Qwen", "https://dashscope.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"qwen-intl" | "dashscope-intl" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"qwen-us" | "dashscope-us" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"Qwen", "https://dashscope-us.aliyuncs.com/compatible-mode/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
|
||||
// ── Extended ecosystem (community favorites) ─────────
|
||||
"groq" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
|
|
@ -421,6 +436,12 @@ pub fn create_routed_provider(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_api_key_prefers_explicit_argument() {
|
||||
let resolved = resolve_api_key("openrouter", Some(" explicit-key "));
|
||||
assert_eq!(resolved.as_deref(), Some("explicit-key"));
|
||||
}
|
||||
|
||||
// ── Primary providers ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
@ -521,6 +542,16 @@ mod tests {
|
|||
assert!(create_provider("baidu", Some("key")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_qwen() {
|
||||
assert!(create_provider("qwen", Some("key")).is_ok());
|
||||
assert!(create_provider("dashscope", Some("key")).is_ok());
|
||||
assert!(create_provider("qwen-intl", Some("key")).is_ok());
|
||||
assert!(create_provider("dashscope-intl", Some("key")).is_ok());
|
||||
assert!(create_provider("qwen-us", Some("key")).is_ok());
|
||||
assert!(create_provider("dashscope-us", Some("key")).is_ok());
|
||||
}
|
||||
|
||||
// ── Extended ecosystem ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
@ -749,6 +780,9 @@ mod tests {
|
|||
"minimax",
|
||||
"bedrock",
|
||||
"qianfan",
|
||||
"qwen",
|
||||
"qwen-intl",
|
||||
"qwen-us",
|
||||
"groq",
|
||||
"mistral",
|
||||
"xai",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider};
|
||||
use crate::providers::traits::Provider;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -61,7 +61,7 @@ impl Provider for OllamaProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if let Some(sys) = system_prompt {
|
||||
|
|
@ -93,9 +93,7 @@ impl Provider for OllamaProvider {
|
|||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
Ok(ProviderChatResponse::with_text(
|
||||
chat_response.message.content,
|
||||
))
|
||||
Ok(chat_response.message.content)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
use crate::providers::traits::{ChatResponse, Provider};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -22,7 +26,7 @@ struct Message {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiChatResponse {
|
||||
struct ChatResponse {
|
||||
choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +40,75 @@ struct ResponseMessage {
|
|||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
model: String,
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<NativeToolSpec>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolSpec {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
function: NativeToolFunctionSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolFunctionSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeToolCall {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
function: NativeFunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeFunctionCall {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChatResponse {
|
||||
choices: Vec<NativeChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChoice {
|
||||
message: NativeResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeResponseMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
impl OpenAiProvider {
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
Self {
|
||||
|
|
@ -47,6 +120,107 @@ impl OpenAiProvider {
|
|||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
tools.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| NativeToolSpec {
|
||||
kind: "function".to_string(),
|
||||
function: NativeToolFunctionSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.parameters.clone(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
if m.role == "assistant" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
if let Some(tool_calls_value) = value.get("tool_calls") {
|
||||
if let Ok(parsed_calls) =
|
||||
serde_json::from_value::<Vec<ProviderToolCall>>(
|
||||
tool_calls_value.clone(),
|
||||
)
|
||||
{
|
||||
let tool_calls = parsed_calls
|
||||
.into_iter()
|
||||
.map(|tc| NativeToolCall {
|
||||
id: Some(tc.id),
|
||||
kind: Some("function".to_string()),
|
||||
function: NativeFunctionCall {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
tool_call_id: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.role == "tool" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
let tool_call_id = value
|
||||
.get("tool_call_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "tool".to_string(),
|
||||
content,
|
||||
tool_call_id,
|
||||
tool_calls: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
NativeMessage {
|
||||
role: m.role.clone(),
|
||||
content: Some(m.content.clone()),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tc| ProviderToolCall {
|
||||
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ProviderChatResponse {
|
||||
text: message.content,
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -57,7 +231,7 @@ impl Provider for OpenAiProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
|
||||
})?;
|
||||
|
|
@ -94,15 +268,60 @@ impl Provider for OpenAiProvider {
|
|||
return Err(super::api_error("OpenAI", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let chat_response: ChatResponse = response.json().await?;
|
||||
|
||||
chat_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ChatResponse::with_text(c.message.content))
|
||||
.map(|c| c.message.content)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
|
||||
})?;
|
||||
|
||||
let tools = Self::convert_tools(request.tools);
|
||||
let native_request = NativeChatRequest {
|
||||
model: model.to_string(),
|
||||
messages: Self::convert_messages(request.messages),
|
||||
temperature,
|
||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||
tools,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post("https://api.openai.com/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.json(&native_request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("OpenAI", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let message = native_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))?;
|
||||
Ok(Self::parse_native_response(message))
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -184,7 +403,7 @@ mod tests {
|
|||
#[test]
|
||||
fn response_deserializes_single_choice() {
|
||||
let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.choices.len(), 1);
|
||||
assert_eq!(resp.choices[0].message.content, "Hi!");
|
||||
}
|
||||
|
|
@ -192,14 +411,14 @@ mod tests {
|
|||
#[test]
|
||||
fn response_deserializes_empty_choices() {
|
||||
let json = r#"{"choices":[]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.choices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_deserializes_multiple_choices() {
|
||||
let json = r#"{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.choices.len(), 2);
|
||||
assert_eq!(resp.choices[0].message.content, "A");
|
||||
}
|
||||
|
|
@ -207,7 +426,7 @@ mod tests {
|
|||
#[test]
|
||||
fn response_with_unicode() {
|
||||
let json = r#"{"choices":[{"message":{"content":"こんにちは 🦀"}}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.choices[0].message.content, "こんにちは 🦀");
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +434,7 @@ mod tests {
|
|||
fn response_with_long_content() {
|
||||
let long = "x".repeat(100_000);
|
||||
let json = format!(r#"{{"choices":[{{"message":{{"content":"{long}"}}}}]}}"#);
|
||||
let resp: ApiChatResponse = serde_json::from_str(&json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp.choices[0].message.content.len(), 100_000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
use crate::providers::traits::{ChatMessage, ChatResponse, Provider};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -36,6 +40,75 @@ struct ResponseMessage {
|
|||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
model: String,
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<NativeToolSpec>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolSpec {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
function: NativeToolFunctionSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolFunctionSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeToolCall {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
function: NativeFunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeFunctionCall {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChatResponse {
|
||||
choices: Vec<NativeChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChoice {
|
||||
message: NativeResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeResponseMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
impl OpenRouterProvider {
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
Self {
|
||||
|
|
@ -47,6 +120,111 @@ impl OpenRouterProvider {
|
|||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
let items = tools?;
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| NativeToolSpec {
|
||||
kind: "function".to_string(),
|
||||
function: NativeToolFunctionSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.parameters.clone(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
if m.role == "assistant" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
if let Some(tool_calls_value) = value.get("tool_calls") {
|
||||
if let Ok(parsed_calls) =
|
||||
serde_json::from_value::<Vec<ProviderToolCall>>(
|
||||
tool_calls_value.clone(),
|
||||
)
|
||||
{
|
||||
let tool_calls = parsed_calls
|
||||
.into_iter()
|
||||
.map(|tc| NativeToolCall {
|
||||
id: Some(tc.id),
|
||||
kind: Some("function".to_string()),
|
||||
function: NativeFunctionCall {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
tool_call_id: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.role == "tool" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
let tool_call_id = value
|
||||
.get("tool_call_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "tool".to_string(),
|
||||
content,
|
||||
tool_call_id,
|
||||
tool_calls: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
NativeMessage {
|
||||
role: m.role.clone(),
|
||||
content: Some(m.content.clone()),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tc| ProviderToolCall {
|
||||
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ProviderChatResponse {
|
||||
text: message.content,
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -71,7 +249,7 @@ impl Provider for OpenRouterProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?;
|
||||
|
||||
|
|
@ -118,7 +296,7 @@ impl Provider for OpenRouterProvider {
|
|||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ChatResponse::with_text(c.message.content))
|
||||
.map(|c| c.message.content)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +305,7 @@ impl Provider for OpenRouterProvider {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?;
|
||||
|
||||
|
|
@ -168,9 +346,61 @@ impl Provider for OpenRouterProvider {
|
|||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ChatResponse::with_text(c.message.content))
|
||||
.map(|c| c.message.content)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."
|
||||
)
|
||||
})?;
|
||||
|
||||
let tools = Self::convert_tools(request.tools);
|
||||
let native_request = NativeChatRequest {
|
||||
model: model.to_string(),
|
||||
messages: Self::convert_messages(request.messages),
|
||||
temperature,
|
||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||
tools,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header(
|
||||
"HTTP-Referer",
|
||||
"https://github.com/theonlyhennygod/zeroclaw",
|
||||
)
|
||||
.header("X-Title", "ZeroClaw")
|
||||
.json(&native_request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let message = native_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?;
|
||||
Ok(Self::parse_native_response(message))
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use super::traits::{ChatMessage, ChatResponse};
|
||||
use super::traits::ChatMessage;
|
||||
use super::Provider;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -57,7 +57,12 @@ fn parse_retry_after_ms(err: &anyhow::Error) -> Option<u64> {
|
|||
.take_while(|c| c.is_ascii_digit() || *c == '.')
|
||||
.collect();
|
||||
if let Ok(secs) = num_str.parse::<f64>() {
|
||||
return Some((secs * 1000.0) as u64);
|
||||
if secs.is_finite() && secs >= 0.0 {
|
||||
let millis = Duration::from_secs_f64(secs).as_millis();
|
||||
if let Ok(value) = u64::try_from(millis) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -151,7 +156,7 @@ impl Provider for ReliableProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
|
||||
|
|
@ -249,7 +254,7 @@ impl Provider for ReliableProvider {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
|
||||
|
|
@ -354,12 +359,12 @@ mod tests {
|
|||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if attempt <= self.fail_until_attempt {
|
||||
anyhow::bail!(self.error);
|
||||
}
|
||||
Ok(ChatResponse::with_text(self.response))
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
|
|
@ -367,12 +372,12 @@ mod tests {
|
|||
_messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if attempt <= self.fail_until_attempt {
|
||||
anyhow::bail!(self.error);
|
||||
}
|
||||
Ok(ChatResponse::with_text(self.response))
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -392,13 +397,13 @@ mod tests {
|
|||
_message: &str,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.models_seen.lock().unwrap().push(model.to_string());
|
||||
if self.fail_models.contains(&model) {
|
||||
anyhow::bail!("500 model {} unavailable", model);
|
||||
}
|
||||
Ok(ChatResponse::with_text(self.response))
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -421,8 +426,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "ok");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "ok");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
|
|
@ -443,8 +448,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "recovered");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "recovered");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
|
|
@ -478,8 +483,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "from fallback");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "from fallback");
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
|
@ -512,7 +517,7 @@ mod tests {
|
|||
);
|
||||
|
||||
let err = provider
|
||||
.chat("hello", "test", 0.0)
|
||||
.simple_chat("hello", "test", 0.0)
|
||||
.await
|
||||
.expect_err("all providers should fail");
|
||||
let msg = err.to_string();
|
||||
|
|
@ -567,8 +572,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "from fallback");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "from fallback");
|
||||
// Primary should have been called only once (no retries)
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
|
|
@ -596,7 +601,7 @@ mod tests {
|
|||
.chat_with_history(&messages, "test", 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "history ok");
|
||||
assert_eq!(result, "history ok");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
|
|
@ -635,7 +640,7 @@ mod tests {
|
|||
.chat_with_history(&messages, "test", 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "fallback ok");
|
||||
assert_eq!(result, "fallback ok");
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
|
@ -665,8 +670,11 @@ mod tests {
|
|||
)
|
||||
.with_model_fallbacks(fallbacks);
|
||||
|
||||
let result = provider.chat("hello", "claude-opus", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "ok from sonnet");
|
||||
let result = provider
|
||||
.simple_chat("hello", "claude-opus", 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "ok from sonnet");
|
||||
|
||||
let seen = mock.models_seen.lock().unwrap();
|
||||
assert_eq!(seen.len(), 2);
|
||||
|
|
@ -698,7 +706,7 @@ mod tests {
|
|||
.with_model_fallbacks(fallbacks);
|
||||
|
||||
let err = provider
|
||||
.chat("hello", "model-a", 0.0)
|
||||
.simple_chat("hello", "model-a", 0.0)
|
||||
.await
|
||||
.expect_err("all models should fail");
|
||||
assert!(err.to_string().contains("All providers/models failed"));
|
||||
|
|
@ -724,8 +732,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
// No model_fallbacks set — should work exactly as before
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "ok");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "ok");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
|
|
@ -822,7 +830,7 @@ mod tests {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.as_ref()
|
||||
.chat_with_system(system_prompt, message, model, temperature)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use super::traits::{ChatMessage, ChatResponse};
|
||||
use super::traits::{ChatMessage, ChatRequest, ChatResponse};
|
||||
use super::Provider;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -98,7 +98,7 @@ impl Provider for RouterProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let (provider_idx, resolved_model) = self.resolve(model);
|
||||
|
||||
let (provider_name, provider) = &self.providers[provider_idx];
|
||||
|
|
@ -118,7 +118,7 @@ impl Provider for RouterProvider {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let (provider_idx, resolved_model) = self.resolve(model);
|
||||
let (_, provider) = &self.providers[provider_idx];
|
||||
provider
|
||||
|
|
@ -126,6 +126,24 @@ impl Provider for RouterProvider {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let (provider_idx, resolved_model) = self.resolve(model);
|
||||
let (_, provider) = &self.providers[provider_idx];
|
||||
provider.chat(request, &resolved_model, temperature).await
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
self.providers
|
||||
.get(self.default_index)
|
||||
.map(|(_, p)| p.supports_native_tools())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn warmup(&self) -> anyhow::Result<()> {
|
||||
for (name, provider) in &self.providers {
|
||||
tracing::info!(provider = name, "Warming up routed provider");
|
||||
|
|
@ -175,10 +193,10 @@ mod tests {
|
|||
_message: &str,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
*self.last_model.lock().unwrap() = model.to_string();
|
||||
Ok(ChatResponse::with_text(self.response))
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +247,7 @@ mod tests {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.as_ref()
|
||||
.chat_with_system(system_prompt, message, model, temperature)
|
||||
.await
|
||||
|
|
@ -246,8 +264,11 @@ mod tests {
|
|||
],
|
||||
);
|
||||
|
||||
let result = router.chat("hello", "hint:reasoning", 0.5).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "smart-response");
|
||||
let result = router
|
||||
.simple_chat("hello", "hint:reasoning", 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "smart-response");
|
||||
assert_eq!(mocks[1].call_count(), 1);
|
||||
assert_eq!(mocks[1].last_model(), "claude-opus");
|
||||
assert_eq!(mocks[0].call_count(), 0);
|
||||
|
|
@ -260,8 +281,8 @@ mod tests {
|
|||
vec![("fast", "fast", "llama-3-70b")],
|
||||
);
|
||||
|
||||
let result = router.chat("hello", "hint:fast", 0.5).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "fast-response");
|
||||
let result = router.simple_chat("hello", "hint:fast", 0.5).await.unwrap();
|
||||
assert_eq!(result, "fast-response");
|
||||
assert_eq!(mocks[0].call_count(), 1);
|
||||
assert_eq!(mocks[0].last_model(), "llama-3-70b");
|
||||
}
|
||||
|
|
@ -273,8 +294,11 @@ mod tests {
|
|||
vec![],
|
||||
);
|
||||
|
||||
let result = router.chat("hello", "hint:nonexistent", 0.5).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "default-response");
|
||||
let result = router
|
||||
.simple_chat("hello", "hint:nonexistent", 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "default-response");
|
||||
assert_eq!(mocks[0].call_count(), 1);
|
||||
// Falls back to default with the hint as model name
|
||||
assert_eq!(mocks[0].last_model(), "hint:nonexistent");
|
||||
|
|
@ -291,10 +315,10 @@ mod tests {
|
|||
);
|
||||
|
||||
let result = router
|
||||
.chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5)
|
||||
.simple_chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "primary-response");
|
||||
assert_eq!(result, "primary-response");
|
||||
assert_eq!(mocks[0].call_count(), 1);
|
||||
assert_eq!(mocks[0].last_model(), "anthropic/claude-sonnet-4-20250514");
|
||||
}
|
||||
|
|
@ -355,7 +379,7 @@ mod tests {
|
|||
.chat_with_system(Some("system"), "hello", "model", 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "response");
|
||||
assert_eq!(result, "response");
|
||||
assert_eq!(mock.call_count(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -29,6 +30,13 @@ impl ChatMessage {
|
|||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: "tool".into(),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool call requested by the LLM.
|
||||
|
|
@ -49,14 +57,6 @@ pub struct ChatResponse {
|
|||
}
|
||||
|
||||
impl ChatResponse {
|
||||
/// Convenience: construct a plain text response with no tool calls.
|
||||
pub fn with_text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: Some(text.into()),
|
||||
tool_calls: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the LLM wants to invoke at least one tool.
|
||||
pub fn has_tool_calls(&self) -> bool {
|
||||
!self.tool_calls.is_empty()
|
||||
|
|
@ -68,6 +68,13 @@ impl ChatResponse {
|
|||
}
|
||||
}
|
||||
|
||||
/// Request payload for provider chat calls.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ChatRequest<'a> {
|
||||
pub messages: &'a [ChatMessage],
|
||||
pub tools: Option<&'a [ToolSpec]>,
|
||||
}
|
||||
|
||||
/// A tool result to feed back to the LLM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResultMessage {
|
||||
|
|
@ -77,7 +84,7 @@ pub struct ToolResultMessage {
|
|||
|
||||
/// A message in a multi-turn conversation, including tool interactions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum ConversationMessage {
|
||||
/// Regular chat message (system, user, assistant).
|
||||
Chat(ChatMessage),
|
||||
|
|
@ -86,29 +93,35 @@ pub enum ConversationMessage {
|
|||
text: Option<String>,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
},
|
||||
/// Result of a tool execution, fed back to the LLM.
|
||||
ToolResult(ToolResultMessage),
|
||||
/// Results of tool executions, fed back to the LLM.
|
||||
ToolResults(Vec<ToolResultMessage>),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Provider: Send + Sync {
|
||||
async fn chat(
|
||||
/// Simple one-shot chat (single user message, no explicit system prompt).
|
||||
///
|
||||
/// This is the preferred API for non-agentic direct interactions.
|
||||
async fn simple_chat(
|
||||
&self,
|
||||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.chat_with_system(None, message, model, temperature)
|
||||
.await
|
||||
}
|
||||
|
||||
/// One-shot chat with optional system prompt.
|
||||
///
|
||||
/// Kept for compatibility and advanced one-shot prompting.
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse>;
|
||||
) -> anyhow::Result<String>;
|
||||
|
||||
/// Multi-turn conversation. Default implementation extracts the last user
|
||||
/// message and delegates to `chat_with_system`.
|
||||
|
|
@ -117,7 +130,7 @@ pub trait Provider: Send + Sync {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let system = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
|
|
@ -131,6 +144,27 @@ pub trait Provider: Send + Sync {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Structured chat API for agent loop callers.
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let text = self
|
||||
.chat_with_history(request.messages, model, temperature)
|
||||
.await?;
|
||||
Ok(ChatResponse {
|
||||
text: Some(text),
|
||||
tool_calls: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether provider supports native tool calls over API.
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Warm up the HTTP connection pool (TLS handshake, DNS, HTTP/2 setup).
|
||||
/// Default implementation is a no-op; providers with HTTP clients should override.
|
||||
async fn warmup(&self) -> anyhow::Result<()> {
|
||||
|
|
@ -153,6 +187,9 @@ mod tests {
|
|||
|
||||
let asst = ChatMessage::assistant("Hi there");
|
||||
assert_eq!(asst.role, "assistant");
|
||||
|
||||
let tool = ChatMessage::tool("{}");
|
||||
assert_eq!(tool.role, "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -194,11 +231,11 @@ mod tests {
|
|||
let json = serde_json::to_string(&chat).unwrap();
|
||||
assert!(json.contains("\"type\":\"Chat\""));
|
||||
|
||||
let tool_result = ConversationMessage::ToolResult(ToolResultMessage {
|
||||
let tool_result = ConversationMessage::ToolResults(vec![ToolResultMessage {
|
||||
tool_call_id: "1".into(),
|
||||
content: "done".into(),
|
||||
});
|
||||
}]);
|
||||
let json = serde_json::to_string(&tool_result).unwrap();
|
||||
assert!(json.contains("\"type\":\"ToolResult\""));
|
||||
assert!(json.contains("\"type\":\"ToolResults\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
397
src/rag/mod.rs
Normal file
397
src/rag/mod.rs
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
//! RAG pipeline for hardware datasheet retrieval.
|
||||
//!
|
||||
//! Supports:
|
||||
//! - Markdown and text datasheets (always)
|
||||
//! - PDF ingestion (with `rag-pdf` feature)
|
||||
//! - Pin/alias tables (e.g. `red_led: 13`) for explicit lookup
|
||||
//! - Keyword retrieval (default) or semantic search via embeddings (optional)
|
||||
|
||||
use crate::memory::chunker;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// A chunk of datasheet content with board metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DatasheetChunk {
|
||||
/// Board this chunk applies to (e.g. "nucleo-f401re", "rpi-gpio"), or None for generic.
|
||||
pub board: Option<String>,
|
||||
/// Source file path (for debugging).
|
||||
pub source: String,
|
||||
/// Chunk content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Pin alias: human-readable name → pin number (e.g. "red_led" → 13).
|
||||
pub type PinAliases = HashMap<String, u32>;
|
||||
|
||||
/// Parse pin aliases from markdown. Looks for:
|
||||
/// - `## Pin Aliases` section with `alias: pin` lines
|
||||
/// - Markdown table `| alias | pin |`
|
||||
fn parse_pin_aliases(content: &str) -> PinAliases {
|
||||
let mut aliases = PinAliases::new();
|
||||
let content_lower = content.to_lowercase();
|
||||
|
||||
// Find ## Pin Aliases section
|
||||
let section_markers = ["## pin aliases", "## pin alias", "## pins"];
|
||||
let mut in_section = false;
|
||||
let mut section_start = 0;
|
||||
|
||||
for marker in section_markers {
|
||||
if let Some(pos) = content_lower.find(marker) {
|
||||
in_section = true;
|
||||
section_start = pos + marker.len();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !in_section {
|
||||
return aliases;
|
||||
}
|
||||
|
||||
let rest = &content[section_start..];
|
||||
let section_end = rest
|
||||
.find("\n## ")
|
||||
.map(|i| section_start + i)
|
||||
.unwrap_or(content.len());
|
||||
let section = &content[section_start..section_end];
|
||||
|
||||
// Parse "alias: pin" or "alias = pin" lines
|
||||
for line in section.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Table row: | red_led | 13 | (skip header | alias | pin | and separator |---|)
|
||||
if line.starts_with('|') {
|
||||
let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect();
|
||||
if parts.len() >= 3 {
|
||||
let alias = parts[1].trim().to_lowercase().replace(' ', "_");
|
||||
let pin_str = parts[2].trim();
|
||||
// Skip header row and separator (|---|)
|
||||
if alias.eq("alias")
|
||||
|| alias.eq("pin")
|
||||
|| pin_str.eq("pin")
|
||||
|| alias.contains("---")
|
||||
|| pin_str.contains("---")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Ok(pin) = pin_str.parse::<u32>() {
|
||||
if !alias.is_empty() {
|
||||
aliases.insert(alias, pin);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Key: value
|
||||
if let Some((k, v)) = line.split_once(':').or_else(|| line.split_once('=')) {
|
||||
let alias = k.trim().to_lowercase().replace(' ', "_");
|
||||
if let Ok(pin) = v.trim().parse::<u32>() {
|
||||
if !alias.is_empty() {
|
||||
aliases.insert(alias, pin);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aliases
|
||||
}
|
||||
|
||||
fn collect_md_txt_paths(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_md_txt_paths(&path, out);
|
||||
} else if path.is_file() {
|
||||
let ext = path.extension().and_then(|e| e.to_str());
|
||||
if ext == Some("md") || ext == Some("txt") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rag-pdf")]
|
||||
fn collect_pdf_paths(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_pdf_paths(&path, out);
|
||||
} else if path.is_file() {
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("pdf") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "rag-pdf")]
|
||||
fn extract_pdf_text(path: &Path) -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
pdf_extract::extract_text_from_mem(&bytes).ok()
|
||||
}
|
||||
|
||||
/// Hardware RAG index — loads and retrieves datasheet chunks.
|
||||
pub struct HardwareRag {
|
||||
chunks: Vec<DatasheetChunk>,
|
||||
/// Per-board pin aliases (board -> alias -> pin).
|
||||
pin_aliases: HashMap<String, PinAliases>,
|
||||
}
|
||||
|
||||
impl HardwareRag {
|
||||
/// Load datasheets from a directory. Expects .md, .txt, and optionally .pdf (with rag-pdf).
|
||||
/// Filename (without extension) is used as board tag.
|
||||
/// Supports `## Pin Aliases` section for explicit alias→pin mapping.
|
||||
pub fn load(workspace_dir: &Path, datasheet_dir: &str) -> anyhow::Result<Self> {
|
||||
let base = workspace_dir.join(datasheet_dir);
|
||||
if !base.exists() || !base.is_dir() {
|
||||
return Ok(Self {
|
||||
chunks: Vec::new(),
|
||||
pin_aliases: HashMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut paths: Vec<std::path::PathBuf> = Vec::new();
|
||||
collect_md_txt_paths(&base, &mut paths);
|
||||
#[cfg(feature = "rag-pdf")]
|
||||
collect_pdf_paths(&base, &mut paths);
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut pin_aliases: HashMap<String, PinAliases> = HashMap::new();
|
||||
let max_tokens = 512;
|
||||
|
||||
for path in paths {
|
||||
let content = if path.extension().and_then(|e| e.to_str()) == Some("pdf") {
|
||||
#[cfg(feature = "rag-pdf")]
|
||||
{
|
||||
extract_pdf_text(&path).unwrap_or_default()
|
||||
}
|
||||
#[cfg(not(feature = "rag-pdf"))]
|
||||
{
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
std::fs::read_to_string(&path).unwrap_or_default()
|
||||
};
|
||||
|
||||
if content.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let board = infer_board_from_path(&path, &base);
|
||||
let source = path
|
||||
.strip_prefix(workspace_dir)
|
||||
.unwrap_or(&path)
|
||||
.display()
|
||||
.to_string();
|
||||
|
||||
// Parse pin aliases from full content
|
||||
let aliases = parse_pin_aliases(&content);
|
||||
if let Some(ref b) = board {
|
||||
if !aliases.is_empty() {
|
||||
pin_aliases.insert(b.clone(), aliases);
|
||||
}
|
||||
}
|
||||
|
||||
for chunk in chunker::chunk_markdown(&content, max_tokens) {
|
||||
chunks.push(DatasheetChunk {
|
||||
board: board.clone(),
|
||||
source: source.clone(),
|
||||
content: chunk.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
chunks,
|
||||
pin_aliases,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get pin aliases for a board (e.g. "red_led" -> 13).
|
||||
pub fn pin_aliases_for_board(&self, board: &str) -> Option<&PinAliases> {
|
||||
self.pin_aliases.get(board)
|
||||
}
|
||||
|
||||
/// Build pin-alias context for query. When user says "red led", inject "red_led: 13" for matching boards.
|
||||
pub fn pin_alias_context(&self, query: &str, boards: &[String]) -> String {
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_words: Vec<&str> = query_lower
|
||||
.split_whitespace()
|
||||
.filter(|w| w.len() > 1)
|
||||
.collect();
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for board in boards {
|
||||
if let Some(aliases) = self.pin_aliases.get(board) {
|
||||
for (alias, pin) in aliases {
|
||||
let alias_words: Vec<&str> = alias.split('_').collect();
|
||||
let matches = query_words
|
||||
.iter()
|
||||
.any(|qw| alias_words.iter().any(|aw| *aw == *qw))
|
||||
|| query_lower.contains(&alias.replace('_', " "));
|
||||
if matches {
|
||||
lines.push(format!("{board}: {alias} = pin {pin}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
format!("[Pin aliases for query]\n{}\n\n", lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Retrieve chunks relevant to the query and boards.
|
||||
/// Uses keyword matching and board filter. Pin-alias context is built separately via `pin_alias_context`.
|
||||
pub fn retrieve(&self, query: &str, boards: &[String], limit: usize) -> Vec<&DatasheetChunk> {
|
||||
if self.chunks.is_empty() || limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let query_terms: Vec<&str> = query_lower
|
||||
.split_whitespace()
|
||||
.filter(|w| w.len() > 2)
|
||||
.collect();
|
||||
|
||||
let mut scored: Vec<(&DatasheetChunk, f32)> = Vec::new();
|
||||
for chunk in &self.chunks {
|
||||
let content_lower = chunk.content.to_lowercase();
|
||||
let mut score = 0.0f32;
|
||||
|
||||
for term in &query_terms {
|
||||
if content_lower.contains(term) {
|
||||
score += 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
if score > 0.0 {
|
||||
let board_match = chunk.board.as_ref().map_or(false, |b| boards.contains(b));
|
||||
if board_match {
|
||||
score += 2.0;
|
||||
}
|
||||
scored.push((chunk, score));
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(limit);
|
||||
scored.into_iter().map(|(c, _)| c).collect()
|
||||
}
|
||||
|
||||
/// Number of indexed chunks.
|
||||
pub fn len(&self) -> usize {
|
||||
self.chunks.len()
|
||||
}
|
||||
|
||||
/// True if no chunks are indexed.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.chunks.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer board tag from file path. `nucleo-f401re.md` → Some("nucleo-f401re").
|
||||
fn infer_board_from_path(path: &Path, base: &Path) -> Option<String> {
|
||||
let rel = path.strip_prefix(base).ok()?;
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
|
||||
if stem == "generic" || stem.starts_with("generic_") {
|
||||
return None;
|
||||
}
|
||||
if rel.parent().and_then(|p| p.to_str()) == Some("_generic") {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(stem.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_pin_aliases_key_value() {
|
||||
let md = r#"## Pin Aliases
|
||||
red_led: 13
|
||||
builtin_led: 13
|
||||
user_led: 5"#;
|
||||
let a = parse_pin_aliases(md);
|
||||
assert_eq!(a.get("red_led"), Some(&13));
|
||||
assert_eq!(a.get("builtin_led"), Some(&13));
|
||||
assert_eq!(a.get("user_led"), Some(&5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pin_aliases_table() {
|
||||
let md = r#"## Pin Aliases
|
||||
| alias | pin |
|
||||
|-------|-----|
|
||||
| red_led | 13 |
|
||||
| builtin_led | 13 |"#;
|
||||
let a = parse_pin_aliases(md);
|
||||
assert_eq!(a.get("red_led"), Some(&13));
|
||||
assert_eq!(a.get("builtin_led"), Some(&13));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pin_aliases_empty() {
|
||||
let a = parse_pin_aliases("No aliases here");
|
||||
assert!(a.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_board_from_path_nucleo() {
|
||||
let base = std::path::Path::new("/base");
|
||||
let path = std::path::Path::new("/base/nucleo-f401re.md");
|
||||
assert_eq!(
|
||||
infer_board_from_path(path, base),
|
||||
Some("nucleo-f401re".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infer_board_generic_none() {
|
||||
let base = std::path::Path::new("/base");
|
||||
let path = std::path::Path::new("/base/generic.md");
|
||||
assert_eq!(infer_board_from_path(path, base), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_rag_load_and_retrieve() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("datasheets");
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let content = r#"# Test Board
|
||||
## Pin Aliases
|
||||
red_led: 13
|
||||
## GPIO
|
||||
Pin 13: LED
|
||||
"#;
|
||||
std::fs::write(base.join("test-board.md"), content).unwrap();
|
||||
|
||||
let rag = HardwareRag::load(tmp.path(), "datasheets").unwrap();
|
||||
assert!(!rag.is_empty());
|
||||
let boards = vec!["test-board".to_string()];
|
||||
let chunks = rag.retrieve("led", &boards, 5);
|
||||
assert!(!chunks.is_empty());
|
||||
let ctx = rag.pin_alias_context("red led", &boards);
|
||||
assert!(ctx.contains("13"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hardware_rag_load_empty_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("empty_ds");
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
let rag = HardwareRag::load(tmp.path(), "empty_ds").unwrap();
|
||||
assert!(rag.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +150,18 @@ pub struct AuditLogger {
|
|||
buffer: Mutex<Vec<AuditEvent>>,
|
||||
}
|
||||
|
||||
/// Structured command execution details for audit logging.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandExecutionLog<'a> {
|
||||
pub channel: &'a str,
|
||||
pub command: &'a str,
|
||||
pub risk_level: &'a str,
|
||||
pub approved: bool,
|
||||
pub allowed: bool,
|
||||
pub success: bool,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
impl AuditLogger {
|
||||
/// Create a new audit logger
|
||||
pub fn new(config: AuditConfig, zeroclaw_dir: PathBuf) -> Result<Self> {
|
||||
|
|
@ -183,7 +195,23 @@ impl AuditLogger {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Log a command execution event
|
||||
/// Log a command execution event.
|
||||
pub fn log_command_event(&self, entry: CommandExecutionLog<'_>) -> Result<()> {
|
||||
let event = AuditEvent::new(AuditEventType::CommandExecution)
|
||||
.with_actor(entry.channel.to_string(), None, None)
|
||||
.with_action(
|
||||
entry.command.to_string(),
|
||||
entry.risk_level.to_string(),
|
||||
entry.approved,
|
||||
entry.allowed,
|
||||
)
|
||||
.with_result(entry.success, None, entry.duration_ms, None);
|
||||
|
||||
self.log(&event)
|
||||
}
|
||||
|
||||
/// Backward-compatible helper to log a command execution event.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn log_command(
|
||||
&self,
|
||||
channel: &str,
|
||||
|
|
@ -194,24 +222,22 @@ impl AuditLogger {
|
|||
success: bool,
|
||||
duration_ms: u64,
|
||||
) -> Result<()> {
|
||||
let event = AuditEvent::new(AuditEventType::CommandExecution)
|
||||
.with_actor(channel.to_string(), None, None)
|
||||
.with_action(
|
||||
command.to_string(),
|
||||
risk_level.to_string(),
|
||||
approved,
|
||||
allowed,
|
||||
)
|
||||
.with_result(success, None, duration_ms, None);
|
||||
|
||||
self.log(&event)
|
||||
self.log_command_event(CommandExecutionLog {
|
||||
channel,
|
||||
command,
|
||||
risk_level,
|
||||
approved,
|
||||
allowed,
|
||||
success,
|
||||
duration_ms,
|
||||
})
|
||||
}
|
||||
|
||||
/// Rotate log if it exceeds max size
|
||||
fn rotate_if_needed(&self) -> Result<()> {
|
||||
if let Ok(metadata) = std::fs::metadata(&self.log_path) {
|
||||
let current_size_mb = metadata.len() / (1024 * 1024);
|
||||
if current_size_mb >= self.config.max_size_mb as u64 {
|
||||
if current_size_mb >= u64::from(self.config.max_size_mb) {
|
||||
self.rotate()?;
|
||||
}
|
||||
}
|
||||
|
|
@ -283,7 +309,8 @@ mod tests {
|
|||
|
||||
let json = serde_json::to_string(&event);
|
||||
assert!(json.is_ok());
|
||||
let parsed: AuditEvent = serde_json::from_str(&json.unwrap().as_str()).expect("parse");
|
||||
let json = json.expect("serialize");
|
||||
let parsed: AuditEvent = serde_json::from_str(json.as_str()).expect("parse");
|
||||
assert!(parsed.actor.is_some());
|
||||
assert!(parsed.action.is_some());
|
||||
assert!(parsed.result.is_some());
|
||||
|
|
|
|||
|
|
@ -3,18 +3,48 @@
|
|||
//! By default this uses Vercel's `agent-browser` CLI for automation.
|
||||
//! Optionally, a Rust-native backend can be enabled at build time via
|
||||
//! `--features browser-native` and selected through config.
|
||||
//! Computer-use (OS-level) actions are supported via an optional sidecar endpoint.
|
||||
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::security::SecurityPolicy;
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::process::Command;
|
||||
use tracing::debug;
|
||||
|
||||
/// Browser automation tool using agent-browser CLI
|
||||
/// Computer-use sidecar settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ComputerUseConfig {
|
||||
pub endpoint: String,
|
||||
pub api_key: Option<String>,
|
||||
pub timeout_ms: u64,
|
||||
pub allow_remote_endpoint: bool,
|
||||
pub window_allowlist: Vec<String>,
|
||||
pub max_coordinate_x: Option<i64>,
|
||||
pub max_coordinate_y: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for ComputerUseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
endpoint: "http://127.0.0.1:8787/v1/actions".into(),
|
||||
api_key: None,
|
||||
timeout_ms: 15_000,
|
||||
allow_remote_endpoint: false,
|
||||
window_allowlist: Vec::new(),
|
||||
max_coordinate_x: None,
|
||||
max_coordinate_y: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser automation tool using pluggable backends.
|
||||
pub struct BrowserTool {
|
||||
security: Arc<SecurityPolicy>,
|
||||
allowed_domains: Vec<String>,
|
||||
|
|
@ -23,6 +53,7 @@ pub struct BrowserTool {
|
|||
native_headless: bool,
|
||||
native_webdriver_url: String,
|
||||
native_chrome_path: Option<String>,
|
||||
computer_use: ComputerUseConfig,
|
||||
#[cfg(feature = "browser-native")]
|
||||
native_state: tokio::sync::Mutex<native_backend::NativeBrowserState>,
|
||||
}
|
||||
|
|
@ -31,6 +62,7 @@ pub struct BrowserTool {
|
|||
enum BrowserBackendKind {
|
||||
AgentBrowser,
|
||||
RustNative,
|
||||
ComputerUse,
|
||||
Auto,
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +70,7 @@ enum BrowserBackendKind {
|
|||
enum ResolvedBackend {
|
||||
AgentBrowser,
|
||||
RustNative,
|
||||
ComputerUse,
|
||||
}
|
||||
|
||||
impl BrowserBackendKind {
|
||||
|
|
@ -46,9 +79,10 @@ impl BrowserBackendKind {
|
|||
match key.as_str() {
|
||||
"agent_browser" | "agentbrowser" => Ok(Self::AgentBrowser),
|
||||
"rust_native" | "native" => Ok(Self::RustNative),
|
||||
"computer_use" | "computeruse" => Ok(Self::ComputerUse),
|
||||
"auto" => Ok(Self::Auto),
|
||||
_ => anyhow::bail!(
|
||||
"Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', or 'auto'"
|
||||
"Unsupported browser backend '{raw}'. Use 'agent_browser', 'rust_native', 'computer_use', or 'auto'"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +91,7 @@ impl BrowserBackendKind {
|
|||
match self {
|
||||
Self::AgentBrowser => "agent_browser",
|
||||
Self::RustNative => "rust_native",
|
||||
Self::ComputerUse => "computer_use",
|
||||
Self::Auto => "auto",
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +105,17 @@ struct AgentBrowserResponse {
|
|||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Response format from computer-use sidecar.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ComputerUseResponse {
|
||||
#[serde(default)]
|
||||
success: Option<bool>,
|
||||
#[serde(default)]
|
||||
data: Option<Value>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Supported browser actions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
|
@ -151,9 +197,11 @@ impl BrowserTool {
|
|||
true,
|
||||
"http://127.0.0.1:9515".into(),
|
||||
None,
|
||||
ComputerUseConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_with_backend(
|
||||
security: Arc<SecurityPolicy>,
|
||||
allowed_domains: Vec<String>,
|
||||
|
|
@ -162,6 +210,7 @@ impl BrowserTool {
|
|||
native_headless: bool,
|
||||
native_webdriver_url: String,
|
||||
native_chrome_path: Option<String>,
|
||||
computer_use: ComputerUseConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
security,
|
||||
|
|
@ -171,6 +220,7 @@ impl BrowserTool {
|
|||
native_headless,
|
||||
native_webdriver_url,
|
||||
native_chrome_path,
|
||||
computer_use,
|
||||
#[cfg(feature = "browser-native")]
|
||||
native_state: tokio::sync::Mutex::new(native_backend::NativeBrowserState::default()),
|
||||
}
|
||||
|
|
@ -216,6 +266,52 @@ impl BrowserTool {
|
|||
}
|
||||
}
|
||||
|
||||
fn computer_use_endpoint_url(&self) -> anyhow::Result<reqwest::Url> {
|
||||
if self.computer_use.timeout_ms == 0 {
|
||||
anyhow::bail!("browser.computer_use.timeout_ms must be > 0");
|
||||
}
|
||||
|
||||
let endpoint = self.computer_use.endpoint.trim();
|
||||
if endpoint.is_empty() {
|
||||
anyhow::bail!("browser.computer_use.endpoint cannot be empty");
|
||||
}
|
||||
|
||||
let parsed = reqwest::Url::parse(endpoint).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid browser.computer_use.endpoint: '{endpoint}'. Expected http(s) URL"
|
||||
)
|
||||
})?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
anyhow::bail!("browser.computer_use.endpoint must use http:// or https://");
|
||||
}
|
||||
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("browser.computer_use.endpoint must include host"))?;
|
||||
|
||||
let host_is_private = is_private_host(host);
|
||||
if !self.computer_use.allow_remote_endpoint && !host_is_private {
|
||||
anyhow::bail!(
|
||||
"browser.computer_use.endpoint host '{host}' is public. Set browser.computer_use.allow_remote_endpoint=true to allow it"
|
||||
);
|
||||
}
|
||||
|
||||
if self.computer_use.allow_remote_endpoint && !host_is_private && scheme != "https" {
|
||||
anyhow::bail!(
|
||||
"browser.computer_use.endpoint must use https:// when allow_remote_endpoint=true and host is public"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn computer_use_available(&self) -> anyhow::Result<bool> {
|
||||
let endpoint = self.computer_use_endpoint_url()?;
|
||||
Ok(endpoint_reachable(&endpoint, Duration::from_millis(500)))
|
||||
}
|
||||
|
||||
async fn resolve_backend(&self) -> anyhow::Result<ResolvedBackend> {
|
||||
let configured = self.configured_backend()?;
|
||||
|
||||
|
|
@ -243,6 +339,14 @@ impl BrowserTool {
|
|||
}
|
||||
Ok(ResolvedBackend::RustNative)
|
||||
}
|
||||
BrowserBackendKind::ComputerUse => {
|
||||
if !self.computer_use_available()? {
|
||||
anyhow::bail!(
|
||||
"browser.backend='computer_use' but sidecar endpoint is unreachable. Check browser.computer_use.endpoint and sidecar status"
|
||||
);
|
||||
}
|
||||
Ok(ResolvedBackend::ComputerUse)
|
||||
}
|
||||
BrowserBackendKind::Auto => {
|
||||
if Self::rust_native_compiled() && self.rust_native_available() {
|
||||
return Ok(ResolvedBackend::RustNative);
|
||||
|
|
@ -251,14 +355,31 @@ impl BrowserTool {
|
|||
return Ok(ResolvedBackend::AgentBrowser);
|
||||
}
|
||||
|
||||
let computer_use_err = match self.computer_use_available() {
|
||||
Ok(true) => return Ok(ResolvedBackend::ComputerUse),
|
||||
Ok(false) => None,
|
||||
Err(err) => Some(err.to_string()),
|
||||
};
|
||||
|
||||
if Self::rust_native_compiled() {
|
||||
if let Some(err) = computer_use_err {
|
||||
anyhow::bail!(
|
||||
"browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable, computer-use invalid: {err})"
|
||||
);
|
||||
}
|
||||
anyhow::bail!(
|
||||
"browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable)"
|
||||
"browser.backend='auto' found no usable backend (agent-browser missing, rust-native unavailable, computer-use sidecar unreachable)"
|
||||
)
|
||||
}
|
||||
|
||||
if let Some(err) = computer_use_err {
|
||||
anyhow::bail!(
|
||||
"browser.backend='auto' needs agent-browser CLI, browser-native, or valid computer-use sidecar (error: {err})"
|
||||
);
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"browser.backend='auto' needs agent-browser CLI, or build with --features browser-native"
|
||||
"browser.backend='auto' needs agent-browser CLI, browser-native, or computer-use sidecar"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -272,9 +393,10 @@ impl BrowserTool {
|
|||
anyhow::bail!("URL cannot be empty");
|
||||
}
|
||||
|
||||
// Allow file:// URLs for local testing
|
||||
// Block file:// URLs — browser file access bypasses all SSRF and
|
||||
// domain-allowlist controls and can exfiltrate arbitrary local files.
|
||||
if url.starts_with("file://") {
|
||||
return Ok(());
|
||||
anyhow::bail!("file:// URLs are not allowed in browser automation");
|
||||
}
|
||||
|
||||
if !url.starts_with("https://") && !url.starts_with("http://") {
|
||||
|
|
@ -523,6 +645,179 @@ impl BrowserTool {
|
|||
}
|
||||
}
|
||||
|
||||
fn validate_coordinate(&self, key: &str, value: i64, max: Option<i64>) -> anyhow::Result<()> {
|
||||
if value < 0 {
|
||||
anyhow::bail!("'{key}' must be >= 0")
|
||||
}
|
||||
if let Some(limit) = max {
|
||||
if limit < 0 {
|
||||
anyhow::bail!("Configured coordinate limit for '{key}' must be >= 0")
|
||||
}
|
||||
if value > limit {
|
||||
anyhow::bail!("'{key}'={value} exceeds configured limit {limit}")
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_required_i64(
|
||||
&self,
|
||||
params: &serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
params
|
||||
.get(key)
|
||||
.and_then(Value::as_i64)
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing or invalid '{key}' parameter"))
|
||||
}
|
||||
|
||||
fn validate_computer_use_action(
|
||||
&self,
|
||||
action: &str,
|
||||
params: &serde_json::Map<String, Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
match action {
|
||||
"open" => {
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'url' for open action"))?;
|
||||
self.validate_url(url)?;
|
||||
}
|
||||
"mouse_move" | "mouse_click" => {
|
||||
let x = self.read_required_i64(params, "x")?;
|
||||
let y = self.read_required_i64(params, "y")?;
|
||||
self.validate_coordinate("x", x, self.computer_use.max_coordinate_x)?;
|
||||
self.validate_coordinate("y", y, self.computer_use.max_coordinate_y)?;
|
||||
}
|
||||
"mouse_drag" => {
|
||||
let from_x = self.read_required_i64(params, "from_x")?;
|
||||
let from_y = self.read_required_i64(params, "from_y")?;
|
||||
let to_x = self.read_required_i64(params, "to_x")?;
|
||||
let to_y = self.read_required_i64(params, "to_y")?;
|
||||
self.validate_coordinate("from_x", from_x, self.computer_use.max_coordinate_x)?;
|
||||
self.validate_coordinate("to_x", to_x, self.computer_use.max_coordinate_x)?;
|
||||
self.validate_coordinate("from_y", from_y, self.computer_use.max_coordinate_y)?;
|
||||
self.validate_coordinate("to_y", to_y, self.computer_use.max_coordinate_y)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute_computer_use_action(
|
||||
&self,
|
||||
action: &str,
|
||||
args: &Value,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let endpoint = self.computer_use_endpoint_url()?;
|
||||
|
||||
let mut params = args
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("browser args must be a JSON object"))?;
|
||||
params.remove("action");
|
||||
|
||||
self.validate_computer_use_action(action, ¶ms)?;
|
||||
|
||||
let payload = json!({
|
||||
"action": action,
|
||||
"params": params,
|
||||
"policy": {
|
||||
"allowed_domains": self.allowed_domains,
|
||||
"window_allowlist": self.computer_use.window_allowlist,
|
||||
"max_coordinate_x": self.computer_use.max_coordinate_x,
|
||||
"max_coordinate_y": self.computer_use.max_coordinate_y,
|
||||
},
|
||||
"metadata": {
|
||||
"session_name": self.session_name,
|
||||
"source": "zeroclaw.browser",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}
|
||||
});
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client
|
||||
.post(endpoint)
|
||||
.timeout(Duration::from_millis(self.computer_use.timeout_ms))
|
||||
.json(&payload);
|
||||
|
||||
if let Some(api_key) = self.computer_use.api_key.as_deref() {
|
||||
let token = api_key.trim();
|
||||
if !token.is_empty() {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request.send().await.with_context(|| {
|
||||
format!(
|
||||
"Failed to call computer-use sidecar at {}",
|
||||
self.computer_use.endpoint
|
||||
)
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.context("Failed to read computer-use sidecar response body")?;
|
||||
|
||||
if let Ok(parsed) = serde_json::from_str::<ComputerUseResponse>(&body) {
|
||||
if status.is_success() && parsed.success.unwrap_or(true) {
|
||||
let output = parsed
|
||||
.data
|
||||
.map(|data| serde_json::to_string_pretty(&data).unwrap_or_default())
|
||||
.unwrap_or_else(|| {
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"backend": "computer_use",
|
||||
"action": action,
|
||||
"ok": true,
|
||||
}))
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let error = parsed.error.or_else(|| {
|
||||
if status.is_success() && parsed.success == Some(false) {
|
||||
Some("computer-use sidecar returned success=false".to_string())
|
||||
} else {
|
||||
Some(format!(
|
||||
"computer-use sidecar request failed with status {status}"
|
||||
))
|
||||
}
|
||||
});
|
||||
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: body,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"computer-use sidecar request failed with status {status}: {}",
|
||||
body.trim()
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_action(
|
||||
&self,
|
||||
action: BrowserAction,
|
||||
|
|
@ -531,6 +826,9 @@ impl BrowserTool {
|
|||
match backend {
|
||||
ResolvedBackend::AgentBrowser => self.execute_agent_browser_action(action).await,
|
||||
ResolvedBackend::RustNative => self.execute_rust_native_action(action).await,
|
||||
ResolvedBackend::ComputerUse => anyhow::bail!(
|
||||
"Internal error: computer_use backend must be handled before BrowserAction parsing"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -564,10 +862,12 @@ impl Tool for BrowserTool {
|
|||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Web browser automation with pluggable backends (agent-browser or rust-native). \
|
||||
Supports navigation, clicking, filling forms, screenshots, and page snapshots. \
|
||||
Use 'snapshot' to map interactive elements to refs (@e1, @e2), then use refs for \
|
||||
precise interaction. Enforces browser.allowed_domains for open actions."
|
||||
concat!(
|
||||
"Web/browser automation with pluggable backends (agent-browser, rust-native, computer_use). ",
|
||||
"Supports DOM actions plus optional OS-level actions (mouse_move, mouse_click, mouse_drag, ",
|
||||
"key_type, key_press, screen_capture) through a computer-use sidecar. Use 'snapshot' to map ",
|
||||
"interactive elements to refs (@e1, @e2). Enforces browser.allowed_domains for open actions."
|
||||
)
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
|
|
@ -578,8 +878,10 @@ impl Tool for BrowserTool {
|
|||
"type": "string",
|
||||
"enum": ["open", "snapshot", "click", "fill", "type", "get_text",
|
||||
"get_title", "get_url", "screenshot", "wait", "press",
|
||||
"hover", "scroll", "is_visible", "close", "find"],
|
||||
"description": "Browser action to perform"
|
||||
"hover", "scroll", "is_visible", "close", "find",
|
||||
"mouse_move", "mouse_click", "mouse_drag", "key_type",
|
||||
"key_press", "screen_capture"],
|
||||
"description": "Browser action to perform (OS-level actions require backend=computer_use)"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
|
|
@ -601,6 +903,35 @@ impl Tool for BrowserTool {
|
|||
"type": "string",
|
||||
"description": "Key to press (Enter, Tab, Escape, etc.)"
|
||||
},
|
||||
"x": {
|
||||
"type": "integer",
|
||||
"description": "Screen X coordinate (computer_use: mouse_move/mouse_click)"
|
||||
},
|
||||
"y": {
|
||||
"type": "integer",
|
||||
"description": "Screen Y coordinate (computer_use: mouse_move/mouse_click)"
|
||||
},
|
||||
"from_x": {
|
||||
"type": "integer",
|
||||
"description": "Drag source X coordinate (computer_use: mouse_drag)"
|
||||
},
|
||||
"from_y": {
|
||||
"type": "integer",
|
||||
"description": "Drag source Y coordinate (computer_use: mouse_drag)"
|
||||
},
|
||||
"to_x": {
|
||||
"type": "integer",
|
||||
"description": "Drag target X coordinate (computer_use: mouse_drag)"
|
||||
},
|
||||
"to_y": {
|
||||
"type": "integer",
|
||||
"description": "Drag target Y coordinate (computer_use: mouse_drag)"
|
||||
},
|
||||
"button": {
|
||||
"type": "string",
|
||||
"enum": ["left", "right", "middle"],
|
||||
"description": "Mouse button for computer_use mouse_click"
|
||||
},
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"enum": ["up", "down", "left", "right"],
|
||||
|
|
@ -688,6 +1019,18 @@ impl Tool for BrowserTool {
|
|||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?;
|
||||
|
||||
if !is_supported_browser_action(action_str) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Unknown action: {action_str}")),
|
||||
});
|
||||
}
|
||||
|
||||
if backend == ResolvedBackend::ComputerUse {
|
||||
return self.execute_computer_use_action(action_str, &args).await;
|
||||
}
|
||||
|
||||
let action = match action_str {
|
||||
"open" => {
|
||||
let url = args
|
||||
|
|
@ -839,7 +1182,14 @@ impl Tool for BrowserTool {
|
|||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!("Unknown action: {action_str}")),
|
||||
error: Some(format!(
|
||||
"Action '{action_str}' is unavailable for backend '{}'",
|
||||
match backend {
|
||||
ResolvedBackend::AgentBrowser => "agent_browser",
|
||||
ResolvedBackend::RustNative => "rust_native",
|
||||
ResolvedBackend::ComputerUse => "computer_use",
|
||||
}
|
||||
)),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1523,6 +1873,34 @@ mod native_backend {
|
|||
|
||||
// ── Helper functions ─────────────────────────────────────────────
|
||||
|
||||
fn is_supported_browser_action(action: &str) -> bool {
|
||||
matches!(
|
||||
action,
|
||||
"open"
|
||||
| "snapshot"
|
||||
| "click"
|
||||
| "fill"
|
||||
| "type"
|
||||
| "get_text"
|
||||
| "get_title"
|
||||
| "get_url"
|
||||
| "screenshot"
|
||||
| "wait"
|
||||
| "press"
|
||||
| "hover"
|
||||
| "scroll"
|
||||
| "is_visible"
|
||||
| "close"
|
||||
| "find"
|
||||
| "mouse_move"
|
||||
| "mouse_click"
|
||||
| "mouse_drag"
|
||||
| "key_type"
|
||||
| "key_press"
|
||||
| "screen_capture"
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_domains(domains: Vec<String>) -> Vec<String> {
|
||||
domains
|
||||
.into_iter()
|
||||
|
|
@ -1531,6 +1909,30 @@ fn normalize_domains(domains: Vec<String>) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn endpoint_reachable(endpoint: &reqwest::Url, timeout: Duration) -> bool {
|
||||
let host = match endpoint.host_str() {
|
||||
Some(host) if !host.is_empty() => host,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
let port = match endpoint.port_or_known_default() {
|
||||
Some(port) => port,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let mut addrs = match (host, port).to_socket_addrs() {
|
||||
Ok(addrs) => addrs,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let addr = match addrs.next() {
|
||||
Some(addr) => addr,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
std::net::TcpStream::connect_timeout(&addr, timeout).is_ok()
|
||||
}
|
||||
|
||||
fn extract_host(url_str: &str) -> anyhow::Result<String> {
|
||||
// Simple host extraction without url crate
|
||||
let url = url_str.trim();
|
||||
|
|
@ -1565,49 +1967,63 @@ fn is_private_host(host: &str) -> bool {
|
|||
.and_then(|h| h.strip_suffix(']'))
|
||||
.unwrap_or(host);
|
||||
|
||||
if bare == "localhost" {
|
||||
if bare == "localhost" || bare.ends_with(".localhost") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// .local TLD (mDNS)
|
||||
if bare
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.is_some_and(|label| label == "local")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Parse as IP address to catch all representations (decimal, hex, octal, mapped)
|
||||
if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
|
||||
return match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_broadcast()
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let segs = v6.segments();
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
// Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918
|
||||
|| (segs[0] & 0xfe00) == 0xfc00
|
||||
// Link-local (fe80::/10)
|
||||
|| (segs[0] & 0xffc0) == 0xfe80
|
||||
// IPv4-mapped addresses (::ffff:127.0.0.1)
|
||||
|| v6.to_ipv4_mapped().is_some_and(|v4| {
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_broadcast()
|
||||
})
|
||||
}
|
||||
std::net::IpAddr::V4(v4) => is_non_global_v4(v4),
|
||||
std::net::IpAddr::V6(v6) => is_non_global_v6(v6),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback string patterns for hostnames that look like IPs but don't parse
|
||||
// (e.g., partial addresses used in DNS names).
|
||||
let string_patterns = [
|
||||
"127.", "10.", "192.168.", "0.0.0.0", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.",
|
||||
"172.28.", "172.29.", "172.30.", "172.31.",
|
||||
];
|
||||
false
|
||||
}
|
||||
|
||||
string_patterns.iter().any(|p| bare.starts_with(p))
|
||||
/// Returns `true` for any IPv4 address that is not globally routable.
|
||||
fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool {
|
||||
let [a, b, _, _] = v4.octets();
|
||||
v4.is_loopback()
|
||||
|| v4.is_private()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_broadcast()
|
||||
|| v4.is_multicast()
|
||||
// Shared address space (100.64/10)
|
||||
|| (a == 100 && (64..=127).contains(&b))
|
||||
// Reserved (240.0.0.0/4)
|
||||
|| a >= 240
|
||||
// Documentation (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24)
|
||||
|| (a == 192 && b == 0)
|
||||
|| (a == 198 && b == 51)
|
||||
|| (a == 203 && b == 0)
|
||||
// Benchmarking (198.18.0.0/15)
|
||||
|| (a == 198 && (18..=19).contains(&b))
|
||||
}
|
||||
|
||||
/// Returns `true` for any IPv6 address that is not globally routable.
|
||||
fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
|
||||
let segs = v6.segments();
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_multicast()
|
||||
// Unique-local (fc00::/7) — IPv6 equivalent of RFC 1918
|
||||
|| (segs[0] & 0xfe00) == 0xfc00
|
||||
// Link-local (fe80::/10)
|
||||
|| (segs[0] & 0xffc0) == 0xfe80
|
||||
// IPv4-mapped addresses
|
||||
|| v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4))
|
||||
}
|
||||
|
||||
fn host_matches_allowlist(host: &str, allowed: &[String]) -> bool {
|
||||
|
|
@ -1669,6 +2085,8 @@ mod tests {
|
|||
#[test]
|
||||
fn is_private_host_detects_local() {
|
||||
assert!(is_private_host("localhost"));
|
||||
assert!(is_private_host("app.localhost"));
|
||||
assert!(is_private_host("printer.local"));
|
||||
assert!(is_private_host("127.0.0.1"));
|
||||
assert!(is_private_host("192.168.1.1"));
|
||||
assert!(is_private_host("10.0.0.1"));
|
||||
|
|
@ -1676,6 +2094,18 @@ mod tests {
|
|||
assert!(!is_private_host("google.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_private_host_blocks_multicast_and_reserved() {
|
||||
assert!(is_private_host("224.0.0.1")); // multicast
|
||||
assert!(is_private_host("255.255.255.255")); // broadcast
|
||||
assert!(is_private_host("100.64.0.1")); // shared address space
|
||||
assert!(is_private_host("240.0.0.1")); // reserved
|
||||
assert!(is_private_host("192.0.2.1")); // documentation
|
||||
assert!(is_private_host("198.51.100.1")); // documentation
|
||||
assert!(is_private_host("203.0.113.1")); // documentation
|
||||
assert!(is_private_host("198.18.0.1")); // benchmarking
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_private_host_catches_ipv6() {
|
||||
assert!(is_private_host("::1"));
|
||||
|
|
@ -1746,6 +2176,10 @@ mod tests {
|
|||
BrowserBackendKind::parse("rust-native").unwrap(),
|
||||
BrowserBackendKind::RustNative
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserBackendKind::parse("computer_use").unwrap(),
|
||||
BrowserBackendKind::ComputerUse
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserBackendKind::parse("auto").unwrap(),
|
||||
BrowserBackendKind::Auto
|
||||
|
|
@ -1778,10 +2212,100 @@ mod tests {
|
|||
true,
|
||||
"http://127.0.0.1:9515".into(),
|
||||
None,
|
||||
ComputerUseConfig::default(),
|
||||
);
|
||||
assert_eq!(tool.configured_backend().unwrap(), BrowserBackendKind::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_tool_accepts_computer_use_backend_config() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tool = BrowserTool::new_with_backend(
|
||||
security,
|
||||
vec!["example.com".into()],
|
||||
None,
|
||||
"computer_use".into(),
|
||||
true,
|
||||
"http://127.0.0.1:9515".into(),
|
||||
None,
|
||||
ComputerUseConfig::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
tool.configured_backend().unwrap(),
|
||||
BrowserBackendKind::ComputerUse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_use_endpoint_rejects_public_http_by_default() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tool = BrowserTool::new_with_backend(
|
||||
security,
|
||||
vec!["example.com".into()],
|
||||
None,
|
||||
"computer_use".into(),
|
||||
true,
|
||||
"http://127.0.0.1:9515".into(),
|
||||
None,
|
||||
ComputerUseConfig {
|
||||
endpoint: "http://computer-use.example.com/v1/actions".into(),
|
||||
..ComputerUseConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(tool.computer_use_endpoint_url().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_use_endpoint_requires_https_for_public_remote() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tool = BrowserTool::new_with_backend(
|
||||
security,
|
||||
vec!["example.com".into()],
|
||||
None,
|
||||
"computer_use".into(),
|
||||
true,
|
||||
"http://127.0.0.1:9515".into(),
|
||||
None,
|
||||
ComputerUseConfig {
|
||||
endpoint: "https://computer-use.example.com/v1/actions".into(),
|
||||
allow_remote_endpoint: true,
|
||||
..ComputerUseConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(tool.computer_use_endpoint_url().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computer_use_coordinate_validation_applies_limits() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let tool = BrowserTool::new_with_backend(
|
||||
security,
|
||||
vec!["example.com".into()],
|
||||
None,
|
||||
"computer_use".into(),
|
||||
true,
|
||||
"http://127.0.0.1:9515".into(),
|
||||
None,
|
||||
ComputerUseConfig {
|
||||
max_coordinate_x: Some(100),
|
||||
max_coordinate_y: Some(100),
|
||||
..ComputerUseConfig::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert!(tool
|
||||
.validate_coordinate("x", 50, tool.computer_use.max_coordinate_x)
|
||||
.is_ok());
|
||||
assert!(tool
|
||||
.validate_coordinate("x", 101, tool.computer_use.max_coordinate_x)
|
||||
.is_err());
|
||||
assert!(tool
|
||||
.validate_coordinate("y", -1, tool.computer_use.max_coordinate_y)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_tool_name() {
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
|
|
@ -1808,8 +2332,8 @@ mod tests {
|
|||
// Invalid - not https
|
||||
assert!(tool.validate_url("ftp://example.com").is_err());
|
||||
|
||||
// File URLs allowed
|
||||
assert!(tool.validate_url("file:///tmp/test.html").is_ok());
|
||||
// file:// URLs blocked (local file exfiltration risk)
|
||||
assert!(tool.validate_url("file:///tmp/test.html").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -902,8 +902,8 @@ mod tests {
|
|||
let json_str = r#"{"name": "GMAIL_SEND_EMAIL_WITH_ATTACHMENT", "appName": "gmail", "description": "Send email with attachment & special chars: <>'\"\"", "enabled": true}"#;
|
||||
let action: ComposioAction = serde_json::from_str(json_str).unwrap();
|
||||
assert_eq!(action.name, "GMAIL_SEND_EMAIL_WITH_ATTACHMENT");
|
||||
assert!(action.description.as_ref().unwrap().contains("&"));
|
||||
assert!(action.description.as_ref().unwrap().contains("<"));
|
||||
assert!(action.description.as_ref().unwrap().contains('&'));
|
||||
assert!(action.description.as_ref().unwrap().contains('<'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -221,14 +221,9 @@ impl Tool for DelegateTool {
|
|||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
let has_tool_calls = response.has_tool_calls();
|
||||
let mut rendered = response.text.unwrap_or_default();
|
||||
let mut rendered = response;
|
||||
if rendered.trim().is_empty() {
|
||||
if has_tool_calls {
|
||||
rendered = "[Tool-only response; no text content]".to_string();
|
||||
} else {
|
||||
rendered = "[Empty response]".to_string();
|
||||
}
|
||||
rendered = "[Empty response]".to_string();
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ use super::traits::{Tool, ToolResult};
|
|||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Git operations tool for structured repository management.
|
||||
|
|
@ -29,7 +31,7 @@ impl GitOperationsTool {
|
|||
|| arg_lower.starts_with("--upload-pack=")
|
||||
|| arg_lower.starts_with("--receive-pack=")
|
||||
|| arg_lower.contains("$(")
|
||||
|| arg_lower.contains("`")
|
||||
|| arg_lower.contains('`')
|
||||
|| arg.contains('|')
|
||||
|| arg.contains(';')
|
||||
{
|
||||
|
|
@ -88,10 +90,8 @@ impl GitOperationsTool {
|
|||
branch = line.trim_start_matches("# branch.head ").to_string();
|
||||
} else if let Some(rest) = line.strip_prefix("1 ") {
|
||||
// Ordinary changed entry
|
||||
let parts: Vec<&str> = rest.split(' ').collect();
|
||||
if parts.len() >= 2 {
|
||||
let path = parts.get(1).unwrap_or(&"");
|
||||
let staging = parts.get(0).unwrap_or(&"");
|
||||
let mut parts = rest.splitn(3, ' ');
|
||||
if let (Some(staging), Some(path)) = (parts.next(), parts.next()) {
|
||||
if !staging.is_empty() {
|
||||
let status_char = staging.chars().next().unwrap_or(' ');
|
||||
if status_char != '.' && status_char != ' ' {
|
||||
|
|
@ -201,7 +201,8 @@ impl GitOperationsTool {
|
|||
}
|
||||
|
||||
async fn git_log(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
let limit_raw = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10);
|
||||
let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000);
|
||||
let limit_str = limit.to_string();
|
||||
|
||||
let output = self
|
||||
|
|
@ -381,7 +382,9 @@ impl GitOperationsTool {
|
|||
"pop" => self.run_git_command(&["stash", "pop"]).await,
|
||||
"list" => self.run_git_command(&["stash", "list"]).await,
|
||||
"drop" => {
|
||||
let index = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0) as i32;
|
||||
let index_raw = args.get("index").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let index = i32::try_from(index_raw)
|
||||
.map_err(|_| anyhow::anyhow!("stash index too large: {index_raw}"))?;
|
||||
self.run_git_command(&["stash", "drop", &format!("stash@{{{index}}}")])
|
||||
.await
|
||||
}
|
||||
|
|
@ -514,12 +517,7 @@ impl Tool for GitOperationsTool {
|
|||
error: Some("Action blocked: read-only mode".into()),
|
||||
});
|
||||
}
|
||||
AutonomyLevel::Supervised => {
|
||||
// Allow but require tracking
|
||||
}
|
||||
AutonomyLevel::Full => {
|
||||
// Allow freely
|
||||
}
|
||||
AutonomyLevel::Supervised | AutonomyLevel::Full => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
205
src/tools/hardware_board_info.rs
Normal file
205
src/tools/hardware_board_info.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//! Hardware board info tool — returns chip name, architecture, memory map for Telegram/agent.
|
||||
//!
|
||||
//! Use when user asks "what board do I have?", "board info", "connected hardware", etc.
|
||||
//! Uses probe-rs for Nucleo when available; otherwise static datasheet info.
|
||||
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
/// Static board info (datasheets). Used when probe-rs is unavailable.
|
||||
const BOARD_INFO: &[(&str, &str, &str)] = &[
|
||||
(
|
||||
"nucleo-f401re",
|
||||
"STM32F401RET6",
|
||||
"ARM Cortex-M4, 84 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).",
|
||||
),
|
||||
(
|
||||
"nucleo-f411re",
|
||||
"STM32F411RET6",
|
||||
"ARM Cortex-M4, 100 MHz. Flash: 512 KB, RAM: 128 KB. User LED on PA5 (pin 13).",
|
||||
),
|
||||
(
|
||||
"arduino-uno",
|
||||
"ATmega328P",
|
||||
"8-bit AVR, 16 MHz. Flash: 16 KB, SRAM: 2 KB. Built-in LED on pin 13.",
|
||||
),
|
||||
(
|
||||
"arduino-uno-q",
|
||||
"STM32U585 + Qualcomm",
|
||||
"Dual-core: STM32 (MCU) + Linux (aarch64). GPIO via Bridge app on port 9999.",
|
||||
),
|
||||
(
|
||||
"esp32",
|
||||
"ESP32",
|
||||
"Dual-core Xtensa LX6, 240 MHz. Flash: 4 MB typical. Built-in LED on GPIO 2.",
|
||||
),
|
||||
(
|
||||
"rpi-gpio",
|
||||
"Raspberry Pi",
|
||||
"ARM Linux. Native GPIO via sysfs/rppal. No fixed LED pin.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Tool: return full board info (chip, architecture, memory map) for agent/Telegram.
|
||||
pub struct HardwareBoardInfoTool {
|
||||
boards: Vec<String>,
|
||||
}
|
||||
|
||||
impl HardwareBoardInfoTool {
|
||||
pub fn new(boards: Vec<String>) -> Self {
|
||||
Self { boards }
|
||||
}
|
||||
|
||||
fn static_info_for_board(&self, board: &str) -> Option<String> {
|
||||
BOARD_INFO
|
||||
.iter()
|
||||
.find(|(b, _, _)| *b == board)
|
||||
.map(|(_, chip, desc)| {
|
||||
format!(
|
||||
"**Board:** {}\n**Chip:** {}\n**Description:** {}",
|
||||
board, chip, desc
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HardwareBoardInfoTool {
|
||||
fn name(&self) -> &str {
|
||||
"hardware_board_info"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Return full board info (chip, architecture, memory map) for connected hardware. Use when: user asks for 'board info', 'what board do I have', 'connected hardware', 'chip info', 'what hardware', or 'memory map'."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"board": {
|
||||
"type": "string",
|
||||
"description": "Optional board name (e.g. nucleo-f401re). If omitted, returns info for first configured board."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let board = args
|
||||
.get("board")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| self.boards.first().cloned());
|
||||
|
||||
let board = board.as_deref().unwrap_or("unknown");
|
||||
|
||||
if self.boards.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"No peripherals configured. Add boards to config.toml [peripherals.boards]."
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
#[cfg(feature = "probe")]
|
||||
if board == "nucleo-f401re" || board == "nucleo-f411re" {
|
||||
let chip = if board == "nucleo-f411re" {
|
||||
"STM32F411RETx"
|
||||
} else {
|
||||
"STM32F401RETx"
|
||||
};
|
||||
match probe_board_info(chip) {
|
||||
Ok(info) => {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: info,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
output.push_str(&format!(
|
||||
"probe-rs attach failed: {}. Using static info.\n\n",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(info) = self.static_info_for_board(board) {
|
||||
output.push_str(&info);
|
||||
if let Some(mem) = memory_map_static(board) {
|
||||
output.push_str(&format!("\n\n**Memory map:**\n{}", mem));
|
||||
}
|
||||
} else {
|
||||
output.push_str(&format!(
|
||||
"Board '{}' configured. No static info available.",
|
||||
board
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "probe")]
|
||||
fn probe_board_info(chip: &str) -> anyhow::Result<String> {
|
||||
use probe_rs::config::MemoryRegion;
|
||||
use probe_rs::{Session, SessionConfig};
|
||||
|
||||
let session = Session::auto_attach(chip, SessionConfig::default())
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
let target = session.target();
|
||||
let arch = session.architecture();
|
||||
|
||||
let mut out = format!(
|
||||
"**Board:** {}\n**Chip:** {}\n**Architecture:** {:?}\n\n**Memory map:**\n",
|
||||
chip, target.name, arch
|
||||
);
|
||||
for region in target.memory_map.iter() {
|
||||
match region {
|
||||
MemoryRegion::Ram(ram) => {
|
||||
let (start, end) = (ram.range.start, ram.range.end);
|
||||
out.push_str(&format!(
|
||||
"RAM: 0x{:08X} - 0x{:08X} ({} KB)\n",
|
||||
start,
|
||||
end,
|
||||
(end - start) / 1024
|
||||
));
|
||||
}
|
||||
MemoryRegion::Nvm(flash) => {
|
||||
let (start, end) = (flash.range.start, flash.range.end);
|
||||
out.push_str(&format!(
|
||||
"Flash: 0x{:08X} - 0x{:08X} ({} KB)\n",
|
||||
start,
|
||||
end,
|
||||
(end - start) / 1024
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push_str("\n(Info read via USB/SWD — no firmware on target needed.)");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn memory_map_static(board: &str) -> Option<&'static str> {
|
||||
match board {
|
||||
"nucleo-f401re" | "nucleo-f411re" => Some(
|
||||
"Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)",
|
||||
),
|
||||
"arduino-uno" => Some("Flash: 16 KB, SRAM: 2 KB, EEPROM: 1 KB"),
|
||||
"esp32" => Some("Flash: 4 MB, IRAM/DRAM per ESP-IDF layout"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
205
src/tools/hardware_memory_map.rs
Normal file
205
src/tools/hardware_memory_map.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//! Hardware memory map tool — returns flash/RAM address ranges for connected boards.
|
||||
//!
|
||||
//! Phase B: When user asks "what are the upper and lower memory addresses?", this tool
|
||||
//! returns the memory map. Uses probe-rs for Nucleo/STM32 when available; otherwise
|
||||
//! returns static maps from datasheets.
|
||||
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
/// Known memory maps (from datasheets). Used when probe-rs is unavailable.
|
||||
const MEMORY_MAPS: &[(&str, &str)] = &[
|
||||
(
|
||||
"nucleo-f401re",
|
||||
"Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F401RET6, ARM Cortex-M4",
|
||||
),
|
||||
(
|
||||
"nucleo-f411re",
|
||||
"Flash: 0x0800_0000 - 0x0807_FFFF (512 KB)\nRAM: 0x2000_0000 - 0x2001_FFFF (128 KB)\nSTM32F411RET6, ARM Cortex-M4",
|
||||
),
|
||||
(
|
||||
"arduino-uno",
|
||||
"Flash: 0x0000 - 0x3FFF (16 KB, ATmega328P)\nSRAM: 0x0100 - 0x08FF (2 KB)\nEEPROM: 0x0000 - 0x03FF (1 KB)",
|
||||
),
|
||||
(
|
||||
"arduino-mega",
|
||||
"Flash: 0x0000 - 0x3FFFF (256 KB, ATmega2560)\nSRAM: 0x0200 - 0x21FF (8 KB)\nEEPROM: 0x0000 - 0x0FFF (4 KB)",
|
||||
),
|
||||
(
|
||||
"esp32",
|
||||
"Flash: 0x3F40_0000 - 0x3F7F_FFFF (4 MB typical)\nIRAM: 0x4000_0000 - 0x4005_FFFF\nDRAM: 0x3FFB_0000 - 0x3FFF_FFFF",
|
||||
),
|
||||
];
|
||||
|
||||
/// Tool: report hardware memory map for connected boards.
|
||||
pub struct HardwareMemoryMapTool {
|
||||
boards: Vec<String>,
|
||||
}
|
||||
|
||||
impl HardwareMemoryMapTool {
|
||||
pub fn new(boards: Vec<String>) -> Self {
|
||||
Self { boards }
|
||||
}
|
||||
|
||||
fn static_map_for_board(&self, board: &str) -> Option<&'static str> {
|
||||
MEMORY_MAPS
|
||||
.iter()
|
||||
.find(|(b, _)| *b == board)
|
||||
.map(|(_, m)| *m)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HardwareMemoryMapTool {
|
||||
fn name(&self) -> &str {
|
||||
"hardware_memory_map"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Return the memory map (flash and RAM address ranges) for connected hardware. Use when: user asks for 'upper and lower memory addresses', 'memory map', 'address space', or 'readable addresses'. Returns flash/RAM ranges from datasheets."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"board": {
|
||||
"type": "string",
|
||||
"description": "Optional board name (e.g. nucleo-f401re, arduino-uno). If omitted, returns map for first configured board."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
let board = args
|
||||
.get("board")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| self.boards.first().cloned());
|
||||
|
||||
let board = board.as_deref().unwrap_or("unknown");
|
||||
|
||||
if self.boards.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"No peripherals configured. Add boards to config.toml [peripherals.boards]."
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
#[cfg(feature = "probe")]
|
||||
let probe_ok = {
|
||||
if board == "nucleo-f401re" || board == "nucleo-f411re" {
|
||||
let chip = if board == "nucleo-f411re" {
|
||||
"STM32F411RETx"
|
||||
} else {
|
||||
"STM32F401RETx"
|
||||
};
|
||||
match probe_rs_memory_map(chip) {
|
||||
Ok(probe_msg) => {
|
||||
output.push_str(&format!("**{}** (via probe-rs):\n{}\n", board, probe_msg));
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
output.push_str(&format!("Probe-rs failed: {}. ", e));
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "probe"))]
|
||||
let probe_ok = false;
|
||||
|
||||
if !probe_ok {
|
||||
if let Some(map) = self.static_map_for_board(board) {
|
||||
output.push_str(&format!("**{}** (from datasheet):\n{}", board, map));
|
||||
} else {
|
||||
let known: Vec<&str> = MEMORY_MAPS.iter().map(|(b, _)| *b).collect();
|
||||
output.push_str(&format!(
|
||||
"No memory map for board '{}'. Known boards: {}",
|
||||
board,
|
||||
known.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "probe")]
|
||||
fn probe_rs_memory_map(chip: &str) -> anyhow::Result<String> {
|
||||
use probe_rs::config::MemoryRegion;
|
||||
use probe_rs::{Session, SessionConfig};
|
||||
|
||||
let session = Session::auto_attach(chip, SessionConfig::default())
|
||||
.map_err(|e| anyhow::anyhow!("probe-rs attach failed: {}", e))?;
|
||||
|
||||
let target = session.target();
|
||||
let mut out = String::new();
|
||||
|
||||
for region in target.memory_map.iter() {
|
||||
match region {
|
||||
MemoryRegion::Ram(ram) => {
|
||||
let start = ram.range.start;
|
||||
let end = ram.range.end;
|
||||
let size_kb = (end - start) / 1024;
|
||||
out.push_str(&format!(
|
||||
"RAM: 0x{:08X} - 0x{:08X} ({} KB)\n",
|
||||
start, end, size_kb
|
||||
));
|
||||
}
|
||||
MemoryRegion::Nvm(flash) => {
|
||||
let start = flash.range.start;
|
||||
let end = flash.range.end;
|
||||
let size_kb = (end - start) / 1024;
|
||||
out.push_str(&format!(
|
||||
"Flash: 0x{:08X} - 0x{:08X} ({} KB)\n",
|
||||
start, end, size_kb
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
out = "Could not read memory regions from probe.".to_string();
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn static_map_nucleo() {
|
||||
let tool = HardwareMemoryMapTool::new(vec!["nucleo-f401re".into()]);
|
||||
assert!(tool.static_map_for_board("nucleo-f401re").is_some());
|
||||
assert!(tool
|
||||
.static_map_for_board("nucleo-f401re")
|
||||
.unwrap()
|
||||
.contains("Flash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_map_arduino() {
|
||||
let tool = HardwareMemoryMapTool::new(vec!["arduino-uno".into()]);
|
||||
assert!(tool.static_map_for_board("arduino-uno").is_some());
|
||||
}
|
||||
}
|
||||
181
src/tools/hardware_memory_read.rs
Normal file
181
src/tools/hardware_memory_read.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
//! Hardware memory read tool — read actual memory/register values from Nucleo via probe-rs.
|
||||
//!
|
||||
//! Use when user asks to "read register values", "read memory at address", "dump lower memory", etc.
|
||||
//! Requires probe feature and Nucleo connected via USB.
|
||||
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
/// RAM base for Nucleo-F401RE (STM32F401)
|
||||
const NUCLEO_RAM_BASE: u64 = 0x2000_0000;
|
||||
|
||||
/// Tool: read memory at address from connected Nucleo via probe-rs.
|
||||
pub struct HardwareMemoryReadTool {
|
||||
boards: Vec<String>,
|
||||
}
|
||||
|
||||
impl HardwareMemoryReadTool {
|
||||
pub fn new(boards: Vec<String>) -> Self {
|
||||
Self { boards }
|
||||
}
|
||||
|
||||
fn chip_for_board(board: &str) -> Option<&'static str> {
|
||||
match board {
|
||||
"nucleo-f401re" => Some("STM32F401RETx"),
|
||||
"nucleo-f411re" => Some("STM32F411RETx"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for HardwareMemoryReadTool {
|
||||
fn name(&self) -> &str {
|
||||
"hardware_memory_read"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Read actual memory/register values from Nucleo via USB. Use when: user asks to 'read register values', 'read memory at address', 'dump memory', 'lower memory 0-126', or 'give address and value'. Returns hex dump. Requires Nucleo connected via USB and probe feature. Params: address (hex, e.g. 0x20000000 for RAM start), length (bytes, default 128)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string",
|
||||
"description": "Memory address in hex (e.g. 0x20000000 for RAM start). Default: 0x20000000 (RAM base)."
|
||||
},
|
||||
"length": {
|
||||
"type": "integer",
|
||||
"description": "Number of bytes to read (default 128, max 256)."
|
||||
},
|
||||
"board": {
|
||||
"type": "string",
|
||||
"description": "Board name (nucleo-f401re). Optional if only one configured."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
if self.boards.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"No peripherals configured. Add nucleo-f401re to config.toml [peripherals.boards]."
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let board = args
|
||||
.get("board")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| self.boards.first().cloned())
|
||||
.unwrap_or_else(|| "nucleo-f401re".into());
|
||||
|
||||
let chip = Self::chip_for_board(&board);
|
||||
if chip.is_none() {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Memory read only supports nucleo-f401re, nucleo-f411re. Got: {}",
|
||||
board
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
let address_str = args
|
||||
.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0x20000000");
|
||||
let address = parse_hex_address(address_str).unwrap_or(NUCLEO_RAM_BASE);
|
||||
|
||||
let length = args.get("length").and_then(|v| v.as_u64()).unwrap_or(128) as usize;
|
||||
let length = length.min(256).max(1);
|
||||
|
||||
#[cfg(feature = "probe")]
|
||||
{
|
||||
match probe_read_memory(chip.unwrap(), address, length) {
|
||||
Ok(output) => {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"probe-rs read failed: {}. Ensure Nucleo is connected via USB and built with --features probe.",
|
||||
e
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "probe"))]
|
||||
{
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(
|
||||
"Memory read requires probe feature. Build with: cargo build --features hardware,probe"
|
||||
.into(),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex_address(s: &str) -> Option<u64> {
|
||||
let s = s.trim().trim_start_matches("0x").trim_start_matches("0X");
|
||||
u64::from_str_radix(s, 16).ok()
|
||||
}
|
||||
|
||||
#[cfg(feature = "probe")]
|
||||
fn probe_read_memory(chip: &str, address: u64, length: usize) -> anyhow::Result<String> {
|
||||
use probe_rs::MemoryInterface;
|
||||
use probe_rs::Session;
|
||||
use probe_rs::SessionConfig;
|
||||
|
||||
let mut session = Session::auto_attach(chip, SessionConfig::default())
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let mut core = session.core(0)?;
|
||||
let mut buf = vec![0u8; length];
|
||||
core.read_8(address, &mut buf)
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
// Format as hex dump: address | bytes (16 per line)
|
||||
let mut out = format!("Memory read from 0x{:08X} ({} bytes):\n\n", address, length);
|
||||
const COLS: usize = 16;
|
||||
for (i, chunk) in buf.chunks(COLS).enumerate() {
|
||||
let addr = address + (i * COLS) as u64;
|
||||
let hex: String = chunk
|
||||
.iter()
|
||||
.map(|b| format!("{:02X}", b))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let ascii: String = chunk
|
||||
.iter()
|
||||
.map(|&b| {
|
||||
if b.is_ascii_graphic() || b == b' ' {
|
||||
b as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&format!("0x{:08X} {:48} {}\n", addr, hex, ascii));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
|
@ -377,39 +377,58 @@ fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool {
|
|||
}
|
||||
|
||||
fn is_private_or_local_host(host: &str) -> bool {
|
||||
let has_local_tld = host
|
||||
// Strip brackets from IPv6 addresses like [::1]
|
||||
let bare = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|h| h.strip_suffix(']'))
|
||||
.unwrap_or(host);
|
||||
|
||||
let has_local_tld = bare
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.is_some_and(|label| label == "local");
|
||||
|
||||
if host == "localhost" || host.ends_with(".localhost") || has_local_tld || host == "::1" {
|
||||
if bare == "localhost" || bare.ends_with(".localhost") || has_local_tld {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some([a, b, _, _]) = parse_ipv4(host) {
|
||||
return a == 0
|
||||
|| a == 10
|
||||
|| a == 127
|
||||
|| (a == 169 && b == 254)
|
||||
|| (a == 172 && (16..=31).contains(&b))
|
||||
|| (a == 192 && b == 168)
|
||||
|| (a == 100 && (64..=127).contains(&b));
|
||||
if let Ok(ip) = bare.parse::<std::net::IpAddr>() {
|
||||
return match ip {
|
||||
std::net::IpAddr::V4(v4) => is_non_global_v4(v4),
|
||||
std::net::IpAddr::V6(v6) => is_non_global_v6(v6),
|
||||
};
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_ipv4(host: &str) -> Option<[u8; 4]> {
|
||||
let parts: Vec<&str> = host.split('.').collect();
|
||||
if parts.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
/// Returns true if the IPv4 address is not globally routable.
|
||||
fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool {
|
||||
let [a, b, c, _] = v4.octets();
|
||||
v4.is_loopback() // 127.0.0.0/8
|
||||
|| v4.is_private() // 10/8, 172.16/12, 192.168/16
|
||||
|| v4.is_link_local() // 169.254.0.0/16
|
||||
|| v4.is_unspecified() // 0.0.0.0
|
||||
|| v4.is_broadcast() // 255.255.255.255
|
||||
|| v4.is_multicast() // 224.0.0.0/4
|
||||
|| (a == 100 && (64..=127).contains(&b)) // Shared address space (RFC 6598)
|
||||
|| a >= 240 // Reserved (240.0.0.0/4, except broadcast)
|
||||
|| (a == 192 && b == 0 && (c == 0 || c == 2)) // IETF assignments + TEST-NET-1
|
||||
|| (a == 198 && b == 51) // Documentation (198.51.100.0/24)
|
||||
|| (a == 203 && b == 0) // Documentation (203.0.113.0/24)
|
||||
|| (a == 198 && (18..=19).contains(&b)) // Benchmarking (198.18.0.0/15)
|
||||
}
|
||||
|
||||
let mut octets = [0_u8; 4];
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
octets[i] = part.parse::<u8>().ok()?;
|
||||
}
|
||||
Some(octets)
|
||||
/// Returns true if the IPv6 address is not globally routable.
|
||||
fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool {
|
||||
let segs = v6.segments();
|
||||
v6.is_loopback() // ::1
|
||||
|| v6.is_unspecified() // ::
|
||||
|| v6.is_multicast() // ff00::/8
|
||||
|| (segs[0] & 0xfe00) == 0xfc00 // Unique-local (fc00::/7)
|
||||
|| (segs[0] & 0xffc0) == 0xfe80 // Link-local (fe80::/10)
|
||||
|| (segs[0] == 0x2001 && segs[1] == 0x0db8) // Documentation (2001:db8::/32)
|
||||
|| v6.to_ipv4_mapped().is_some_and(|v4| is_non_global_v4(v4))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -546,15 +565,86 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ipv4_valid() {
|
||||
assert_eq!(parse_ipv4("1.2.3.4"), Some([1, 2, 3, 4]));
|
||||
fn blocks_multicast_ipv4() {
|
||||
assert!(is_private_or_local_host("224.0.0.1"));
|
||||
assert!(is_private_or_local_host("239.255.255.255"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ipv4_invalid() {
|
||||
assert_eq!(parse_ipv4("1.2.3"), None);
|
||||
assert_eq!(parse_ipv4("1.2.3.999"), None);
|
||||
assert_eq!(parse_ipv4("not-an-ip"), None);
|
||||
fn blocks_broadcast() {
|
||||
assert!(is_private_or_local_host("255.255.255.255"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_reserved_ipv4() {
|
||||
assert!(is_private_or_local_host("240.0.0.1"));
|
||||
assert!(is_private_or_local_host("250.1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_documentation_ranges() {
|
||||
assert!(is_private_or_local_host("192.0.2.1")); // TEST-NET-1
|
||||
assert!(is_private_or_local_host("198.51.100.1")); // TEST-NET-2
|
||||
assert!(is_private_or_local_host("203.0.113.1")); // TEST-NET-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_benchmarking_range() {
|
||||
assert!(is_private_or_local_host("198.18.0.1"));
|
||||
assert!(is_private_or_local_host("198.19.255.255"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv6_localhost() {
|
||||
assert!(is_private_or_local_host("::1"));
|
||||
assert!(is_private_or_local_host("[::1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv6_multicast() {
|
||||
assert!(is_private_or_local_host("ff02::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv6_link_local() {
|
||||
assert!(is_private_or_local_host("fe80::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv6_unique_local() {
|
||||
assert!(is_private_or_local_host("fd00::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv4_mapped_ipv6() {
|
||||
assert!(is_private_or_local_host("::ffff:127.0.0.1"));
|
||||
assert!(is_private_or_local_host("::ffff:192.168.1.1"));
|
||||
assert!(is_private_or_local_host("::ffff:10.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_ipv4() {
|
||||
assert!(!is_private_or_local_host("8.8.8.8"));
|
||||
assert!(!is_private_or_local_host("1.1.1.1"));
|
||||
assert!(!is_private_or_local_host("93.184.216.34"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_ipv6_documentation_range() {
|
||||
assert!(is_private_or_local_host("2001:db8::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_ipv6() {
|
||||
assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_shared_address_space() {
|
||||
assert!(is_private_or_local_host("100.64.0.1"));
|
||||
assert!(is_private_or_local_host("100.127.255.255"));
|
||||
assert!(!is_private_or_local_host("100.63.0.1")); // Just below range
|
||||
assert!(!is_private_or_local_host("100.128.0.1")); // Just above range
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ pub mod delegate;
|
|||
pub mod file_read;
|
||||
pub mod file_write;
|
||||
pub mod git_operations;
|
||||
pub mod hardware_board_info;
|
||||
pub mod hardware_memory_map;
|
||||
pub mod hardware_memory_read;
|
||||
pub mod http_request;
|
||||
pub mod image_info;
|
||||
pub mod memory_forget;
|
||||
|
|
@ -15,13 +18,16 @@ pub mod screenshot;
|
|||
pub mod shell;
|
||||
pub mod traits;
|
||||
|
||||
pub use browser::BrowserTool;
|
||||
pub use browser::{BrowserTool, ComputerUseConfig};
|
||||
pub use browser_open::BrowserOpenTool;
|
||||
pub use composio::ComposioTool;
|
||||
pub use delegate::DelegateTool;
|
||||
pub use file_read::FileReadTool;
|
||||
pub use file_write::FileWriteTool;
|
||||
pub use git_operations::GitOperationsTool;
|
||||
pub use hardware_board_info::HardwareBoardInfoTool;
|
||||
pub use hardware_memory_map::HardwareMemoryMapTool;
|
||||
pub use hardware_memory_read::HardwareMemoryReadTool;
|
||||
pub use http_request::HttpRequestTool;
|
||||
pub use image_info::ImageInfoTool;
|
||||
pub use memory_forget::MemoryForgetTool;
|
||||
|
|
@ -131,6 +137,15 @@ pub fn all_tools_with_runtime(
|
|||
browser_config.native_headless,
|
||||
browser_config.native_webdriver_url.clone(),
|
||||
browser_config.native_chrome_path.clone(),
|
||||
ComputerUseConfig {
|
||||
endpoint: browser_config.computer_use.endpoint.clone(),
|
||||
api_key: browser_config.computer_use.api_key.clone(),
|
||||
timeout_ms: browser_config.computer_use.timeout_ms,
|
||||
allow_remote_endpoint: browser_config.computer_use.allow_remote_endpoint,
|
||||
window_allowlist: browser_config.computer_use.window_allowlist.clone(),
|
||||
max_coordinate_x: browser_config.computer_use.max_coordinate_x,
|
||||
max_coordinate_y: browser_config.computer_use.max_coordinate_y,
|
||||
},
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
@ -155,8 +170,12 @@ pub fn all_tools_with_runtime(
|
|||
|
||||
// Add delegation tool when agents are configured
|
||||
if !agents.is_empty() {
|
||||
let delegate_agents: HashMap<String, DelegateAgentConfig> = agents
|
||||
.iter()
|
||||
.map(|(name, cfg)| (name.clone(), cfg.clone()))
|
||||
.collect();
|
||||
tools.push(Box::new(DelegateTool::new(
|
||||
agents.clone(),
|
||||
delegate_agents,
|
||||
fallback_api_key.map(String::from),
|
||||
)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue