feat(hardware): enhance hardware boot process and tool registration

This commit is contained in:
ehushubhamshaw 2026-02-19 17:44:42 -05:00
parent bea65163f7
commit 3ad608c397
7 changed files with 241 additions and 90 deletions

View File

@ -271,7 +271,7 @@ impl Agent {
}
// ── Hardware registry tools (Phase 4 ToolRegistry + plugins) ──
let hw_boot = crate::hardware::boot().await?;
let hw_boot = crate::hardware::boot(&config.peripherals).await?;
if !hw_boot.tools.is_empty() {
let existing: std::collections::HashSet<String> =
tools.iter().map(|t| t.name().to_string()).collect();

View File

@ -1060,6 +1060,7 @@ pub(crate) async fn run_tool_call_loop(
});
let start = Instant::now();
let result = if let Some(tool) = find_tool(tools_registry, &call.name) {
tracing::info!(tool = %call.name, args = %call.arguments, "dispatching tool");
match tool.execute(call.arguments.clone()).await {
Ok(r) => {
observer.record_event(&ObserverEvent::ToolCall {
@ -1213,7 +1214,9 @@ pub async fn run(
}
// ── Hardware registry tools (Phase 4 ToolRegistry + plugins) ──
let hw_boot = crate::hardware::boot().await?;
let hw_boot = crate::hardware::boot(&config.peripherals).await?;
let hw_device_summary = hw_boot.device_summary.clone();
let mut hw_added_tool_names: Vec<String> = Vec::new();
if !hw_boot.tools.is_empty() {
// Deduplicate: peripheral tools take precedence for names like gpio_read/gpio_write.
let existing: std::collections::HashSet<String> =
@ -1224,6 +1227,7 @@ pub async fn run(
.filter(|t| !existing.contains(t.name()))
.collect();
if !new_hw_tools.is_empty() {
hw_added_tool_names = new_hw_tools.iter().map(|t| t.name().to_string()).collect();
tracing::info!(count = new_hw_tools.len(), "Hardware registry tools added");
tools_registry.extend(new_hw_tools);
}
@ -1353,33 +1357,35 @@ pub async fn run(
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.",
"Read the current state of a GPIO pin (returns 0 or 1). 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.",
"Set a GPIO pin HIGH (1) or LOW (0). Use this to turn on/off LEDs and control output pins. Example: gpio_write(device=pico0, pin=25, value=1) turns on the Pico onboard LED.",
));
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.",
));
}
// ── Ensure hardware::boot() tools appear in tool_descs (even without peripherals config) ──
{
let existing_desc_names: std::collections::HashSet<&str> =
tool_descs.iter().map(|(name, _)| *name).collect();
for tool in &tools_registry {
if hw_added_tool_names.contains(&tool.name().to_string())
&& !existing_desc_names.contains(tool.name())
{
// Leak a &'static str from owned description so it lives long enough
// for the tool_descs Vec<(&str, &str)> lifetime.
let leaked_desc: &'static str = Box::leak(tool.description().to_string().into_boxed_str());
let leaked_name: &'static str = Box::leak(tool.name().to_string().into_boxed_str());
tool_descs.push((leaked_name, leaked_desc));
}
}
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
@ -1394,6 +1400,15 @@ pub async fn run(
bootstrap_max_chars,
);
// Inject hardware device summary if available
if !hw_device_summary.is_empty()
&& hw_device_summary != "No hardware devices connected."
{
system_prompt.push_str("\n## Connected Hardware Devices\n\n");
system_prompt.push_str(&hw_device_summary);
system_prompt.push('\n');
}
// Append structured tool-use instructions with schemas
system_prompt.push_str(&build_tool_instructions(&tools_registry));
@ -1677,7 +1692,9 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
tools_registry.extend(peripheral_tools);
// ── Hardware registry tools (Phase 4 ToolRegistry + plugins) ──
let hw_boot = crate::hardware::boot().await?;
let hw_boot = crate::hardware::boot(&config.peripherals).await?;
let hw_device_summary = hw_boot.device_summary.clone();
let mut hw_added_tool_names: Vec<String> = Vec::new();
if !hw_boot.tools.is_empty() {
let existing: std::collections::HashSet<String> =
tools_registry.iter().map(|t| t.name().to_string()).collect();
@ -1687,6 +1704,7 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
.filter(|t| !existing.contains(t.name()))
.collect();
if !new_hw_tools.is_empty() {
hw_added_tool_names = new_hw_tools.iter().map(|t| t.name().to_string()).collect();
tracing::info!(count = new_hw_tools.len(), "Hardware registry tools added (process_message)");
tools_registry.extend(new_hw_tools);
}
@ -1739,32 +1757,32 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
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_read", "Read the current state of a GPIO pin (returns 0 or 1)."));
tool_descs.push((
"gpio_write",
"Set GPIO pin high or low on connected hardware.",
"Set a GPIO pin HIGH (1) or LOW (0). Use this to turn on/off LEDs and control output pins.",
));
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.",
));
}
// ── Ensure hardware::boot() tools appear in tool_descs ──
{
let existing_desc_names: std::collections::HashSet<&str> =
tool_descs.iter().map(|(name, _)| *name).collect();
for tool in &tools_registry {
if hw_added_tool_names.contains(&tool.name().to_string())
&& !existing_desc_names.contains(tool.name())
{
let leaked_desc: &'static str = Box::leak(tool.description().to_string().into_boxed_str());
let leaked_name: &'static str = Box::leak(tool.name().to_string().into_boxed_str());
tool_descs.push((leaked_name, leaked_desc));
}
}
}
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
@ -1778,6 +1796,16 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
Some(&config.identity),
bootstrap_max_chars,
);
// Inject hardware device summary if available
if !hw_device_summary.is_empty()
&& hw_device_summary != "No hardware devices connected."
{
system_prompt.push_str("\n## Connected Hardware Devices\n\n");
system_prompt.push_str(&hw_device_summary);
system_prompt.push('\n');
}
system_prompt.push_str(&build_tool_instructions(&tools_registry));
let mem_context = build_context(mem.as_ref(), message, config.memory.min_relevance_score).await;

View File

@ -552,19 +552,19 @@ pub fn build_system_prompt(
*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",
You HAVE direct access to connected hardware. The user owns this system and has configured it.\n\
All hardware tools (gpio_read, gpio_write) are AUTHORIZED and NOT blocked by security.\n\
When they ask to control LEDs or interact with hardware, 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\
Available hardware tools:\n\
- gpio_write: set a GPIO pin HIGH or LOW. Use this to turn on/off LEDs and control output pins.\n\
- gpio_read: read the current state of a GPIO pin.\n\n\
To turn on the Pico onboard LED: gpio_write(device=pico0, pin=25, value=1)\n\
To turn it off: gpio_write(device=pico0, pin=25, value=0)\n\n",
);
}

View File

@ -69,15 +69,11 @@ impl Tool for GpioWriteTool {
"description": "1 = HIGH (on), 0 = LOW (off)"
}
},
"required": ["device", "pin", "value"]
"required": ["pin", "value"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let device_alias = args
.get("device")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required parameter: device"))?;
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
@ -96,7 +92,49 @@ impl Tool for GpioWriteTool {
}
let registry = self.registry.read().await;
let ctx = registry.context(device_alias).ok_or_else(|| {
// Resolve device alias: use provided value or auto-select the sole GPIO device.
let device_alias: String = match args.get("device").and_then(|v| v.as_str()) {
Some(a) => a.to_string(),
None => {
let gpio_aliases: Vec<String> = registry
.aliases()
.into_iter()
.filter(|a| {
registry
.context(a)
.map(|c| c.capabilities.gpio)
.unwrap_or(false)
})
.map(|a| a.to_string())
.collect();
match gpio_aliases.as_slice() {
[single] => single.clone(),
[] => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"no GPIO-capable device found; specify \"device\" parameter"
.to_string(),
),
});
}
_ => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"multiple devices available ({}); specify \"device\" parameter",
gpio_aliases.join(", ")
)),
});
}
}
}
};
let ctx = registry.context(&device_alias).ok_or_else(|| {
anyhow::anyhow!(
"device '{}' not found or has no transport attached",
device_alias
@ -174,22 +212,60 @@ impl Tool for GpioReadTool {
"description": "GPIO pin number to read"
}
},
"required": ["device", "pin"]
"required": ["pin"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let device_alias = args
.get("device")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required parameter: device"))?;
let pin = args
.get("pin")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("missing required parameter: pin"))?;
let registry = self.registry.read().await;
let ctx = registry.context(device_alias).ok_or_else(|| {
// Resolve device alias: use provided value or auto-select the sole GPIO device.
let device_alias: String = match args.get("device").and_then(|v| v.as_str()) {
Some(a) => a.to_string(),
None => {
let gpio_aliases: Vec<String> = registry
.aliases()
.into_iter()
.filter(|a| {
registry
.context(a)
.map(|c| c.capabilities.gpio)
.unwrap_or(false)
})
.map(|a| a.to_string())
.collect();
match gpio_aliases.as_slice() {
[single] => single.clone(),
[] => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(
"no GPIO-capable device found; specify \"device\" parameter"
.to_string(),
),
});
}
_ => {
return Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"multiple devices available ({}); specify \"device\" parameter",
gpio_aliases.join(", ")
)),
});
}
}
}
};
let ctx = registry.context(&device_alias).ok_or_else(|| {
anyhow::anyhow!(
"device '{}' not found or has no transport attached",
device_alias

View File

@ -52,19 +52,62 @@ pub struct HardwareBootResult {
/// Boot the hardware subsystem: discover devices + load tool registry.
///
/// With the `hardware` feature: enumerates USB-serial devices, registers them
/// in a [`DeviceRegistry`], and loads GPIO + plugin tools.
/// With the `hardware` feature: enumerates USB-serial devices, then
/// pre-registers any config-specified serial boards not already found by
/// discovery. [`HardwareSerialTransport`] opens the port lazily per-send,
/// so this succeeds even when the port doesn't exist at startup.
///
/// Without the feature: loads plugin tools from `~/.zeroclaw/tools/` only,
/// with an empty device registry (GPIO tools will report "no device found"
/// if called, which is correct).
///
/// Plugin loading errors are logged as warnings and never abort boot.
#[cfg(feature = "hardware")]
pub async fn boot() -> anyhow::Result<HardwareBootResult> {
let devices = std::sync::Arc::new(
tokio::sync::RwLock::new(DeviceRegistry::discover().await),
);
pub async fn boot(peripherals: &crate::config::PeripheralsConfig) -> anyhow::Result<HardwareBootResult> {
use device::DeviceCapabilities;
let mut registry_inner = DeviceRegistry::discover().await;
// Pre-register config-specified serial boards not already found by USB
// discovery. Transport opens lazily, so the port need not exist at boot.
if peripherals.enabled {
let discovered_paths: std::collections::HashSet<String> = registry_inner
.all()
.iter()
.filter_map(|d| d.device_path.clone())
.collect();
for board in &peripherals.boards {
if board.transport != "serial" {
continue;
}
let path = match &board.path {
Some(p) if !p.is_empty() => p.clone(),
_ => continue,
};
if discovered_paths.contains(&path) {
continue; // already registered by USB discovery
}
let alias = registry_inner.register(
&board.board,
None,
None,
Some(path.clone()),
None,
);
let transport = std::sync::Arc::new(
HardwareSerialTransport::new(&path, board.baud),
) as std::sync::Arc<dyn transport::Transport>;
let caps = DeviceCapabilities { gpio: true, ..DeviceCapabilities::default() };
registry_inner.attach_transport(&alias, transport, caps);
tracing::info!(
board = %board.board,
path = %path,
alias = %alias,
"pre-registered config board with lazy serial transport"
);
}
}
let devices = std::sync::Arc::new(tokio::sync::RwLock::new(registry_inner));
let registry = ToolRegistry::load(devices.clone()).await?;
let device_summary = {
let reg = devices.read().await;
@ -82,7 +125,7 @@ pub async fn boot() -> anyhow::Result<HardwareBootResult> {
/// Fallback when the `hardware` feature is disabled — plugins only.
#[cfg(not(feature = "hardware"))]
pub async fn boot() -> anyhow::Result<HardwareBootResult> {
pub async fn boot(_peripherals: &crate::config::PeripheralsConfig) -> anyhow::Result<HardwareBootResult> {
let devices = std::sync::Arc::new(
tokio::sync::RwLock::new(DeviceRegistry::new()),
);

View File

@ -117,6 +117,10 @@ impl Transport for HardwareSerialTransport {
)));
}
let json = serde_json::to_string(cmd)
.unwrap_or_else(|_| "<serialize-error>".to_string());
tracing::info!(port = %self.port_path, cmd = %json, "serial send");
tokio::time::timeout(
std::time::Duration::from_secs(SEND_TIMEOUT_SECS),
do_send(&self.port_path, self.baud_rate, cmd),

View File

@ -9,7 +9,6 @@ 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;
@ -30,19 +29,21 @@ 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
});
/// JSON request/response over serial — ZeroClaw wire protocol.
///
/// Wire format (must match firmware):
/// Host → Device: `{"cmd":"gpio_write","params":{"pin":25,"value":1}}\n`
/// Device → Host: `{"ok":true,"data":{"pin":25,"value":1,"state":"HIGH"}}\n`
async fn send_request(port: &mut SerialStream, cmd: &str, params: Value) -> anyhow::Result<Value> {
let req = json!({ "cmd": cmd, "params": params });
let line = format!("{}\n", req);
tracing::info!(
cmd = %cmd,
bytes = %line.trim(),
"serial write"
);
port.write_all(line.as_bytes()).await?;
port.flush().await?;
@ -55,11 +56,8 @@ async fn send_request(port: &mut SerialStream, cmd: &str, args: Value) -> anyhow
buf.push(b[0]);
}
let line_str = String::from_utf8_lossy(&buf);
tracing::info!(response = %line_str.trim(), "serial read");
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)
}
@ -84,15 +82,17 @@ impl SerialTransport {
})??;
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());
// Firmware responds with "data" object; stringify it for the tool output.
let output = if resp["data"].is_null() || resp["data"].is_object() {
resp["data"].to_string()
} else {
resp["data"].as_str().map(String::from).unwrap_or_else(|| resp["data"].to_string())
};
let error = resp["error"].as_str().map(String::from);
Ok(ToolResult {
success: ok,
output: result,
output,
error,
})
}