mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e44185e48 | ||
|
|
72623c9e5b | ||
|
|
6895adbcc9 | ||
|
|
f1480f471b | ||
|
|
9db949746f | ||
|
|
61a123a746 | ||
|
|
0e981429ee | ||
|
|
1b38a64e15 | ||
|
|
2e5f8b60d5 | ||
|
|
f0a0642e7d | ||
|
|
ca8d5c6b5e |
@@ -39,7 +39,6 @@ permissions:
|
||||
# If there's a prerelease-style suffix to the version, then the release(s)
|
||||
# will be marked as a prerelease.
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
tags:
|
||||
- '**[0-9]+.[0-9]+.[0-9]+*'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
target/
|
||||
|
||||
|
||||
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.0](https://github.com/nearai/ironclaw/compare/v0.2.0...v0.3.0) - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- direct api key and cheap model ([#116](https://github.com/nearai/ironclaw/pull/116))
|
||||
|
||||
## [0.2.0](https://github.com/nearai/ironclaw/compare/v0.1.3...v0.2.0) - 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- mark Ollama + OpenAI-compatible as implemented ([#102](https://github.com/nearai/ironclaw/pull/102))
|
||||
- multi-provider inference + libSQL onboarding selection ([#92](https://github.com/nearai/ironclaw/pull/92))
|
||||
- add multi-provider LLM failover with retry backoff ([#28](https://github.com/nearai/ironclaw/pull/28))
|
||||
- add libSQL/Turso embedded database backend ([#47](https://github.com/nearai/ironclaw/pull/47))
|
||||
- Move debug log truncation from agent loop to REPL channel ([#65](https://github.com/nearai/ironclaw/pull/65))
|
||||
|
||||
### Fixed
|
||||
|
||||
- shell destructive-command check bypassed by Value::Object arguments ([#72](https://github.com/nearai/ironclaw/pull/72))
|
||||
- propagate real tool_call_id instead of hardcoded placeholder ([#73](https://github.com/nearai/ironclaw/pull/73))
|
||||
- Fix wasm tool schemas and runtime ([#42](https://github.com/nearai/ironclaw/pull/42))
|
||||
- flatten tool messages for NEAR AI cloud-api compatibility ([#41](https://github.com/nearai/ironclaw/pull/41))
|
||||
- security hardening across all layers ([#35](https://github.com/nearai/ironclaw/pull/35))
|
||||
|
||||
### Other
|
||||
|
||||
- Explicitly enable cargo-dist caching for binary artifacts building
|
||||
- Skip building binary artifacts on every PR
|
||||
- add module specification rules to CLAUDE.md
|
||||
- add setup/onboarding specification (src/setup/README.md)
|
||||
- deduplicate tool code and remove dead stubs ([#98](https://github.com/nearai/ironclaw/pull/98))
|
||||
- Reformat architecture diagram in README ([#64](https://github.com/nearai/ironclaw/pull/64))
|
||||
- Add review discipline guidelines to CLAUDE.md ([#68](https://github.com/nearai/ironclaw/pull/68))
|
||||
- Bump MSRV to 1.92, add GCP deployment files ([#40](https://github.com/nearai/ironclaw/pull/40))
|
||||
- Add OpenAI-compatible HTTP API (/v1/chat/completions, /v1/models) ([#31](https://github.com/nearai/ironclaw/pull/31))
|
||||
|
||||
## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12
|
||||
|
||||
### Other
|
||||
|
||||
@@ -630,6 +630,22 @@ RUST_LOG=ironclaw::agent=debug cargo run
|
||||
RUST_LOG=ironclaw=debug,tower_http=debug cargo run
|
||||
```
|
||||
|
||||
## Module Specifications
|
||||
|
||||
Some modules have a `README.md` that serves as the authoritative specification
|
||||
for that module's behavior. When modifying code in a module that has a spec:
|
||||
|
||||
1. **Read the spec first** before making changes
|
||||
2. **Code follows spec**: if the spec says X, the code must do X
|
||||
3. **Update both sides**: if you change behavior, update the spec to match;
|
||||
if you're implementing a spec change, update the code to match
|
||||
4. **Spec is the tiebreaker**: when code and spec disagree, the spec is correct
|
||||
(unless the spec is clearly outdated, in which case fix the spec first)
|
||||
|
||||
| Module | Spec File |
|
||||
|--------|-----------|
|
||||
| `src/setup/` | `src/setup/README.md` |
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use `crate::` imports, not `super::`
|
||||
|
||||
Generated
+1
-1
@@ -2490,7 +2490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.1.3"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.1.3"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
@@ -183,11 +183,13 @@ windows-archive = ".tar.gz"
|
||||
# The archive format to use for non-windows builds (defaults .tar.xz)
|
||||
unix-archive = ".tar.gz"
|
||||
# Which actions to run on pull requests
|
||||
pr-run-mode = "upload"
|
||||
pr-run-mode = "skip"
|
||||
# Path that installers should place binaries in
|
||||
install-path = "CARGO_HOME"
|
||||
# Whether to install an updater program
|
||||
install-updater = true
|
||||
# Cache intermediate build artifacts to speed up the release pipelines
|
||||
cache-builds = true
|
||||
|
||||
[workspace.metadata.dist.github-custom-runners]
|
||||
aarch64-unknown-linux-gnu = "ubuntu-24.04-arm"
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
||||
| Google Gemini | ✅ | ❌ | P3 | |
|
||||
| OpenRouter | ✅ | ❌ | P3 | |
|
||||
| Ollama (local) | ✅ | ❌ | P2 | Local models |
|
||||
| Ollama (local) | ✅ | ✅ | - | via `rig::providers::ollama` (full support) |
|
||||
| node-llama-cpp | ✅ | ➖ | - | N/A for Rust |
|
||||
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "discord-channel"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Discord channel for IronClaw"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.41.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Discord Channel for IronClaw
|
||||
|
||||
WASM channel for Discord integration - handle slash commands and button interactions via webhooks.
|
||||
|
||||
## Features
|
||||
|
||||
- **Slash Commands** - Process Discord slash commands
|
||||
- **Button Interactions** - Handle button clicks
|
||||
- **Thread Support** - Respond in threads
|
||||
- **DM Support** - Handle direct messages
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a Discord Application at <https://discord.com/developers/applications>
|
||||
2. Create a Bot and get the token
|
||||
3. Set up Interactions URL to point to your IronClaw instance
|
||||
4. Copy the Application ID and Public Key
|
||||
5. Store in IronClaw secrets:
|
||||
|
||||
```bash
|
||||
ironclaw secret set discord_bot_token YOUR_BOT_TOKEN
|
||||
```
|
||||
|
||||
**Note:** The `discord_bot_token` secret is the only value read directly by this
|
||||
Discord channel WASM component. The `discord_app_id` and `discord_public_key`
|
||||
secrets are used by the IronClaw host (for example, to verify Discord
|
||||
interaction signatures and manage slash command registration) and are not
|
||||
accessed from the WASM module itself.
|
||||
|
||||
## Discord Configuration
|
||||
|
||||
### Register Slash Commands
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bot YOUR_BOT_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://discord.com/api/v10/applications/YOUR_APP_ID/commands \
|
||||
-d '{
|
||||
"name": "ask",
|
||||
"description": "Ask the AI agent",
|
||||
"options": [{
|
||||
"name": "question",
|
||||
"description": "Your question",
|
||||
"type": 3,
|
||||
"required": true
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
### Set Interactions Endpoint
|
||||
|
||||
In your Discord app settings, set:
|
||||
|
||||
- Interactions Endpoint URL: `https://your-ironclaw.com/webhook/discord`
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Slash Command
|
||||
|
||||
User types: `/ask question: What is the weather?`
|
||||
|
||||
The agent receives:
|
||||
|
||||
```text
|
||||
User: @username
|
||||
Content: /ask question: What is the weather?
|
||||
```
|
||||
|
||||
### Button Click
|
||||
|
||||
When a user clicks a button in a message, the agent receives:
|
||||
|
||||
```text
|
||||
User: @username
|
||||
Content: [Button clicked] Original message content
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
If an internal error occurs (e.g., metadata serialization failure), the tool attempts to send an ephemeral message to the user:
|
||||
|
||||
```text
|
||||
❌ Internal Error: Failed to process command metadata.
|
||||
```
|
||||
|
||||
Check the host logs for detailed error information.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Embeds
|
||||
|
||||
To send embeds, include an `embeds` array in the `metadata_json` field of the agent's response. The structure should match the Discord API `embed` object.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Invalid Signature"
|
||||
|
||||
- Check that `discord_public_key` is set correctly in IronClaw secrets.
|
||||
- This validation happens on the host before reaching the WASM.
|
||||
|
||||
### "401 Unauthorized"
|
||||
|
||||
- Check that `discord_bot_token` is set correctly in IronClaw secrets.
|
||||
- Ensure the bot is added to the server.
|
||||
|
||||
### "Interaction Failed"
|
||||
|
||||
- The interaction might have timed out (Discord requires a response within 3 seconds).
|
||||
- The `interactions_endpoint_url` might be unreachable.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd channels-src/discord
|
||||
cargo build --target wasm32-wasi --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT/Apache-2.0
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"type": "channel",
|
||||
"name": "discord",
|
||||
"description": "Discord Gateway/Webhook channel for handling slash commands, buttons, and messages",
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{ "host": "discord.com", "path_prefix": "/api/v10" }
|
||||
],
|
||||
"credentials": {
|
||||
"discord_bot_token": {
|
||||
"secret_name": "discord_bot_token",
|
||||
"location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " },
|
||||
"host_patterns": ["discord.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 3600
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["discord_bot_token", "discord_*"]
|
||||
},
|
||||
"channel": {
|
||||
"allowed_paths": ["/webhook/discord"],
|
||||
"allow_polling": false,
|
||||
"callback_timeout_secs": 45,
|
||||
"workspace_prefix": "channels/discord/",
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"require_signature_verification": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Discord Gateway/Webhook channel for IronClaw.
|
||||
//!
|
||||
//! This WASM component implements the channel interface for handling Discord
|
||||
//! interactions via webhooks and sending messages back to Discord.
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - URL verification for Discord interactions
|
||||
//! - Slash command handling
|
||||
//! - Message event parsing (@mentions, DMs)
|
||||
//! - Thread support for conversations
|
||||
//! - Response posting via Discord Web API
|
||||
//! - Automatic message truncation (> 2000 chars)
|
||||
//!
|
||||
//! # Security
|
||||
//!
|
||||
//! - Signature validation is handled by the host (webhook secrets)
|
||||
//! - Bot token is injected by host during HTTP requests
|
||||
//! - WASM never sees raw credentials
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-channel",
|
||||
path: "../../wit/channel.wit",
|
||||
});
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use exports::near::agent::channel::{
|
||||
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
|
||||
OutgoingHttpResponse, StatusUpdate,
|
||||
};
|
||||
use near::agent::channel_host::{self, EmittedMessage};
|
||||
|
||||
/// Discord interaction wrapper.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscordInteraction {
|
||||
/// Interaction type (1=Ping, 2=ApplicationCommand, 3=MessageComponent)
|
||||
#[serde(rename = "type")]
|
||||
interaction_type: u8,
|
||||
|
||||
/// Interaction ID
|
||||
id: String,
|
||||
|
||||
/// Application ID
|
||||
application_id: String,
|
||||
|
||||
/// Guild ID (if in server)
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
guild_id: Option<String>,
|
||||
|
||||
/// Channel ID
|
||||
channel_id: Option<String>,
|
||||
|
||||
/// Member info (if in server)
|
||||
member: Option<DiscordMember>,
|
||||
|
||||
/// User info (if DM)
|
||||
user: Option<DiscordUser>,
|
||||
|
||||
/// Command data (for slash commands)
|
||||
data: Option<DiscordCommandData>,
|
||||
|
||||
/// Message (for component interactions)
|
||||
message: Option<DiscordMessage>,
|
||||
|
||||
/// Token for responding
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordMember {
|
||||
user: DiscordUser,
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
nick: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordUser {
|
||||
id: String,
|
||||
username: String,
|
||||
global_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordCommandData {
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
id: String,
|
||||
name: String,
|
||||
options: Option<Vec<DiscordCommandOption>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordCommandOption {
|
||||
name: String,
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DiscordMessage {
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
id: String,
|
||||
content: String,
|
||||
channel_id: String,
|
||||
#[allow(dead_code)] // Part of API payload, currently unused
|
||||
author: DiscordUser,
|
||||
}
|
||||
|
||||
/// Metadata stored with emitted messages for response routing.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct DiscordMessageMetadata {
|
||||
/// Discord channel ID
|
||||
channel_id: String,
|
||||
|
||||
/// Interaction ID for followups
|
||||
interaction_id: String,
|
||||
|
||||
/// Interaction token for responding
|
||||
token: String,
|
||||
|
||||
/// Application ID
|
||||
application_id: String,
|
||||
|
||||
/// Thread ID (for forum threads)
|
||||
thread_id: Option<String>,
|
||||
}
|
||||
|
||||
struct DiscordChannel;
|
||||
|
||||
impl Guest for DiscordChannel {
|
||||
fn on_start(_config_json: String) -> Result<ChannelConfig, String> {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Discord channel starting");
|
||||
|
||||
Ok(ChannelConfig {
|
||||
display_name: "Discord".to_string(),
|
||||
http_endpoints: vec![HttpEndpointConfig {
|
||||
path: "/webhook/discord".to_string(),
|
||||
methods: vec!["POST".to_string()],
|
||||
require_secret: true,
|
||||
}],
|
||||
poll: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse {
|
||||
let body_str = match std::str::from_utf8(&req.body) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"}));
|
||||
}
|
||||
};
|
||||
|
||||
let interaction: DiscordInteraction = match serde_json::from_str(body_str) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to parse Discord interaction: {}", e),
|
||||
);
|
||||
return json_response(400, serde_json::json!({"error": "Invalid interaction"}));
|
||||
}
|
||||
};
|
||||
|
||||
match interaction.interaction_type {
|
||||
// Ping - Discord verification
|
||||
1 => {
|
||||
channel_host::log(channel_host::LogLevel::Info, "Responding to Discord ping");
|
||||
json_response(200, serde_json::json!({"type": 1}))
|
||||
}
|
||||
|
||||
// Application Command (slash command)
|
||||
2 => {
|
||||
handle_slash_command(&interaction);
|
||||
json_response(
|
||||
200,
|
||||
serde_json::json!({
|
||||
"type": 5,
|
||||
"data": {
|
||||
"content": "🤔 Thinking..."
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Message Component (buttons, selects)
|
||||
3 => {
|
||||
if let Some(ref message) = interaction.message {
|
||||
handle_message_component(&interaction, message);
|
||||
}
|
||||
json_response(200, serde_json::json!({"type": 6}))
|
||||
}
|
||||
|
||||
_ => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Warn,
|
||||
&format!(
|
||||
"Unknown Discord interaction type: {}",
|
||||
interaction.interaction_type
|
||||
),
|
||||
);
|
||||
json_response(200, serde_json::json!({"type": 6}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_poll() {}
|
||||
|
||||
fn on_respond(response: AgentResponse) -> Result<(), String> {
|
||||
let metadata: DiscordMessageMetadata = serde_json::from_str(&response.metadata_json)
|
||||
.map_err(|e| format!("Failed to parse metadata: {}", e))?;
|
||||
|
||||
// Use webhook endpoint for followup
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
metadata.application_id, metadata.token
|
||||
);
|
||||
|
||||
// Truncate content to 2000 characters to comply with Discord limits
|
||||
let content = truncate_message(&response.content);
|
||||
|
||||
let mut payload = serde_json::json!({
|
||||
"content": content,
|
||||
});
|
||||
|
||||
// Check for embeds in metadata
|
||||
if let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&response.metadata_json) {
|
||||
if let Some(embeds) = meta_json.get("embeds") {
|
||||
payload["embeds"] = embeds.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let payload_bytes =
|
||||
serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?;
|
||||
|
||||
let headers = serde_json::json!({
|
||||
"Content-Type": "application/json"
|
||||
});
|
||||
|
||||
let result = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
Some(&payload_bytes),
|
||||
None,
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok(http_response) => {
|
||||
if http_response.status >= 200 && http_response.status < 300 {
|
||||
channel_host::log(channel_host::LogLevel::Debug, "Posted followup to Discord");
|
||||
Ok(())
|
||||
} else {
|
||||
let body_str = String::from_utf8_lossy(&http_response.body);
|
||||
Err(format!(
|
||||
"Discord API error: {} - {}",
|
||||
http_response.status, body_str
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("HTTP request failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_status(_update: StatusUpdate) {}
|
||||
|
||||
fn on_shutdown() {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Info,
|
||||
"Discord channel shutting down",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_slash_command(interaction: &DiscordInteraction) {
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
.map(|m| &m.user)
|
||||
.or(interaction.user.as_ref());
|
||||
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
||||
let user_name = user
|
||||
.map(|u| {
|
||||
u.global_name
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&u.username)
|
||||
.clone()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let channel_id = interaction.channel_id.clone().unwrap_or_default();
|
||||
|
||||
let command_name = interaction
|
||||
.data
|
||||
.as_ref()
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_default();
|
||||
let options = interaction.data.as_ref().and_then(|d| d.options.clone());
|
||||
|
||||
let content = if let Some(opts) = options {
|
||||
let opt_str = opts
|
||||
.iter()
|
||||
.map(|o| format!("{}: {}", o.name, o.value))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("/{} {}", command_name, opt_str)
|
||||
} else {
|
||||
format!("/{}", command_name)
|
||||
};
|
||||
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: channel_id.clone(),
|
||||
interaction_id: interaction.id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
application_id: interaction.application_id.clone(),
|
||||
thread_id: None,
|
||||
};
|
||||
|
||||
let metadata_json = match serde_json::to_string(&metadata) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize metadata: {}", e),
|
||||
);
|
||||
// Attempt to notify user of internal error
|
||||
let url = format!(
|
||||
"https://discord.com/api/v10/webhooks/{}/{}",
|
||||
interaction.application_id, interaction.token
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"content": "❌ Internal Error: Failed to process command metadata.",
|
||||
"flags": 64 // Ephemeral
|
||||
});
|
||||
let _ = channel_host::http_request(
|
||||
"POST",
|
||||
&url,
|
||||
&serde_json::json!({"Content-Type": "application/json"}).to_string(),
|
||||
Some(&serde_json::to_vec(&payload).unwrap_or_default()),
|
||||
None,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: Some(user_name),
|
||||
content,
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_message_component(interaction: &DiscordInteraction, message: &DiscordMessage) {
|
||||
// Check member first (for server contexts), then user (for DMs)
|
||||
let user = interaction
|
||||
.member
|
||||
.as_ref()
|
||||
.map(|m| &m.user)
|
||||
.or(interaction.user.as_ref());
|
||||
let user_id = user.map(|u| u.id.clone()).unwrap_or_default();
|
||||
let user_name = user
|
||||
.map(|u| {
|
||||
u.global_name
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&u.username)
|
||||
.clone()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let channel_id = message.channel_id.clone();
|
||||
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: channel_id.clone(),
|
||||
interaction_id: interaction.id.clone(),
|
||||
token: interaction.token.clone(),
|
||||
application_id: interaction.application_id.clone(),
|
||||
thread_id: None,
|
||||
};
|
||||
|
||||
let metadata_json = match serde_json::to_string(&metadata) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
channel_host::log(
|
||||
channel_host::LogLevel::Error,
|
||||
&format!("Failed to serialize metadata: {}", e),
|
||||
);
|
||||
return; // Don't emit message if metadata can't be serialized
|
||||
}
|
||||
};
|
||||
|
||||
channel_host::emit_message(&EmittedMessage {
|
||||
user_id,
|
||||
user_name: Some(user_name),
|
||||
content: format!("[Button clicked] {}", message.content),
|
||||
thread_id: None,
|
||||
metadata_json,
|
||||
});
|
||||
}
|
||||
|
||||
fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse {
|
||||
let body = serde_json::to_vec(&value).unwrap_or_default();
|
||||
let headers = serde_json::json!({"Content-Type": "application/json"});
|
||||
|
||||
OutgoingHttpResponse {
|
||||
status,
|
||||
headers_json: headers.to_string(),
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
export!(DiscordChannel);
|
||||
|
||||
fn truncate_message(content: &str) -> String {
|
||||
if content.len() <= 2000 {
|
||||
content.to_string()
|
||||
} else {
|
||||
let max_bytes = 1990;
|
||||
let cutoff = content
|
||||
.char_indices()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.take_while(|&end| end <= max_bytes)
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let mut truncated = content[..cutoff].to_string();
|
||||
truncated.push_str("\n... (truncated)");
|
||||
truncated
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_truncate_message() {
|
||||
let short = "Hello world";
|
||||
assert_eq!(truncate_message(short), short);
|
||||
|
||||
let long = "a".repeat(2005);
|
||||
let truncated = truncate_message(&long);
|
||||
assert_eq!(truncated.len(), 2006); // 1990 + 16 chars suffix
|
||||
assert!(truncated.ends_with("\n... (truncated)"));
|
||||
|
||||
// Test with multibyte characters (Euro sign is 3 bytes)
|
||||
// 1000 chars * 3 bytes = 3000 bytes
|
||||
let multi = "€".repeat(1000);
|
||||
let truncated_multi = truncate_message(&multi);
|
||||
|
||||
// 1990 bytes limit. 1990 / 3 = 663 with remainder 1.
|
||||
// Should truncate at 663 chars (1989 bytes).
|
||||
// Suffix is 16 bytes. Total: 1989 + 16 = 2005 bytes.
|
||||
assert!(truncated_multi.len() <= 2006);
|
||||
assert!(truncated_multi.len() >= 2006 - 4); // Allow for max utf8 char width variance
|
||||
assert!(truncated_multi.ends_with("\n... (truncated)"));
|
||||
|
||||
let content_part = &truncated_multi[..truncated_multi.len() - 16];
|
||||
assert!(content_part.chars().all(|c| c == '€'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_serialization() {
|
||||
let metadata = DiscordMessageMetadata {
|
||||
channel_id: "123".into(),
|
||||
interaction_id: "456".into(),
|
||||
token: "abc".into(),
|
||||
application_id: "789".into(),
|
||||
thread_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&metadata).unwrap();
|
||||
let parsed: DiscordMessageMetadata = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.channel_id, "123");
|
||||
assert_eq!(parsed.interaction_id, "456");
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,9 @@ enum AgenticLoopResult {
|
||||
pub struct AgentDeps {
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub llm: Arc<dyn LlmProvider>,
|
||||
/// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation).
|
||||
/// Falls back to the main `llm` if None.
|
||||
pub cheap_llm: Option<Arc<dyn LlmProvider>>,
|
||||
pub safety: Arc<SafetyLayer>,
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub workspace: Option<Arc<Workspace>>,
|
||||
@@ -138,6 +141,11 @@ impl Agent {
|
||||
&self.deps.llm
|
||||
}
|
||||
|
||||
/// Get the cheap/fast LLM provider, falling back to the main one.
|
||||
fn cheap_llm(&self) -> &Arc<dyn LlmProvider> {
|
||||
self.deps.cheap_llm.as_ref().unwrap_or(&self.deps.llm)
|
||||
}
|
||||
|
||||
fn safety(&self) -> &Arc<SafetyLayer> {
|
||||
&self.deps.safety
|
||||
}
|
||||
@@ -301,7 +309,7 @@ impl Agent {
|
||||
Some(spawn_heartbeat(
|
||||
config,
|
||||
workspace.clone(),
|
||||
self.llm().clone(),
|
||||
self.cheap_llm().clone(),
|
||||
Some(notify_tx),
|
||||
))
|
||||
} else {
|
||||
|
||||
@@ -388,6 +388,9 @@ impl std::str::FromStr for NearAiApiMode {
|
||||
pub struct NearAiConfig {
|
||||
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
|
||||
pub model: String,
|
||||
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
|
||||
/// Falls back to the main model if not set.
|
||||
pub cheap_model: Option<String>,
|
||||
/// Base URL for the NEAR AI API (default: https://api.near.ai)
|
||||
pub base_url: String,
|
||||
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
|
||||
@@ -454,6 +457,7 @@ impl LlmConfig {
|
||||
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
|
||||
.to_string()
|
||||
}),
|
||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||
base_url: optional_env("NEARAI_BASE_URL")?
|
||||
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
|
||||
@@ -20,10 +20,6 @@ impl CostEstimator {
|
||||
|
||||
// Default tool costs (in USD or equivalent)
|
||||
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call
|
||||
tool_costs.insert("marketplace".to_string(), dec!(0.01)); // Gas costs
|
||||
tool_costs.insert("ecommerce".to_string(), dec!(0.001)); // API call
|
||||
tool_costs.insert("taskrabbit".to_string(), dec!(0.0)); // Cost comes from task itself
|
||||
tool_costs.insert("restaurant".to_string(), dec!(0.001)); // API call
|
||||
tool_costs.insert("echo".to_string(), dec!(0.0)); // Free
|
||||
tool_costs.insert("time".to_string(), dec!(0.0)); // Free
|
||||
tool_costs.insert("json".to_string(), dec!(0.0)); // Free
|
||||
@@ -74,7 +70,7 @@ mod tests {
|
||||
let estimator = CostEstimator::new();
|
||||
|
||||
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
|
||||
assert_eq!(estimator.estimate_tool("marketplace"), dec!(0.01));
|
||||
assert_eq!(estimator.estimate_tool("http"), dec!(0.0001));
|
||||
assert!(estimator.estimate_tool("unknown") > dec!(0.0));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@ impl TimeEstimator {
|
||||
|
||||
// Default tool durations
|
||||
tool_durations.insert("http".to_string(), Duration::from_secs(5));
|
||||
tool_durations.insert("marketplace".to_string(), Duration::from_secs(10));
|
||||
tool_durations.insert("ecommerce".to_string(), Duration::from_secs(8));
|
||||
tool_durations.insert("taskrabbit".to_string(), Duration::from_secs(30)); // Just API, not task itself
|
||||
tool_durations.insert("restaurant".to_string(), Duration::from_secs(5));
|
||||
tool_durations.insert("echo".to_string(), Duration::from_millis(10));
|
||||
tool_durations.insert("time".to_string(), Duration::from_millis(1));
|
||||
tool_durations.insert("json".to_string(), Duration::from_millis(5));
|
||||
|
||||
+103
@@ -183,3 +183,106 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
|
||||
);
|
||||
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
|
||||
}
|
||||
|
||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||
///
|
||||
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
|
||||
/// Currently only supports NEAR AI backends (Responses and ChatCompletions modes).
|
||||
pub fn create_cheap_llm_provider(
|
||||
config: &LlmConfig,
|
||||
session: Arc<SessionManager>,
|
||||
) -> Result<Option<Arc<dyn LlmProvider>>, LlmError> {
|
||||
let Some(ref cheap_model) = config.nearai.cheap_model else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if config.backend != LlmBackend::NearAi {
|
||||
tracing::warn!(
|
||||
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is {:?}, not NearAi. \
|
||||
Cheap model setting will be ignored.",
|
||||
config.backend
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut cheap_config = config.nearai.clone();
|
||||
cheap_config.model = cheap_model.clone();
|
||||
|
||||
tracing::info!("Cheap LLM provider: {}", cheap_model);
|
||||
|
||||
match cheap_config.api_mode {
|
||||
NearAiApiMode::Responses => Ok(Some(Arc::new(NearAiProvider::new(cheap_config, session)))),
|
||||
NearAiApiMode::ChatCompletions => {
|
||||
Ok(Some(Arc::new(NearAiChatProvider::new(cheap_config)?)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{LlmBackend, NearAiApiMode, NearAiConfig};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_nearai_config() -> NearAiConfig {
|
||||
NearAiConfig {
|
||||
model: "test-model".to_string(),
|
||||
cheap_model: None,
|
||||
base_url: "https://api.near.ai".to_string(),
|
||||
auth_base_url: "https://private.near.ai".to_string(),
|
||||
session_path: PathBuf::from("/tmp/test-session.json"),
|
||||
api_mode: NearAiApiMode::Responses,
|
||||
api_key: None,
|
||||
fallback_model: None,
|
||||
max_retries: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_llm_config() -> LlmConfig {
|
||||
LlmConfig {
|
||||
backend: LlmBackend::NearAi,
|
||||
nearai: test_nearai_config(),
|
||||
openai: None,
|
||||
anthropic: None,
|
||||
ollama: None,
|
||||
openai_compatible: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_returns_none_when_not_configured() {
|
||||
let config = test_llm_config();
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
|
||||
let result = create_cheap_llm_provider(&config, session);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_creates_provider_when_configured() {
|
||||
let mut config = test_llm_config();
|
||||
config.nearai.cheap_model = Some("cheap-test-model".to_string());
|
||||
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
let result = create_cheap_llm_provider(&config, session);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let provider = result.unwrap();
|
||||
assert!(provider.is_some());
|
||||
assert_eq!(provider.unwrap().model_name(), "cheap-test-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() {
|
||||
let mut config = test_llm_config();
|
||||
config.backend = LlmBackend::OpenAi;
|
||||
config.nearai.cheap_model = Some("cheap-test-model".to_string());
|
||||
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
let result = create_cheap_llm_provider(&config, session);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+26
-4
@@ -23,8 +23,8 @@ use ironclaw::{
|
||||
context::ContextManager,
|
||||
extensions::ExtensionManager,
|
||||
llm::{
|
||||
FailoverProvider, LlmProvider, SessionConfig, create_llm_provider,
|
||||
create_llm_provider_with_config, create_session_manager,
|
||||
FailoverProvider, LlmProvider, SessionConfig, create_cheap_llm_provider,
|
||||
create_llm_provider, create_llm_provider_with_config, create_session_manager,
|
||||
},
|
||||
orchestrator::{
|
||||
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
|
||||
@@ -307,8 +307,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
let session = create_session_manager(session_config).await;
|
||||
|
||||
// Ensure we're authenticated before proceeding (only needed for NEAR AI backend)
|
||||
if config.llm.backend == ironclaw::config::LlmBackend::NearAi {
|
||||
// Session-based auth is only needed for NEAR AI backend without an API key.
|
||||
// ChatCompletions mode with an API key skips session auth entirely.
|
||||
if config.llm.backend == ironclaw::config::LlmBackend::NearAi
|
||||
&& config.llm.nearai.api_key.is_none()
|
||||
{
|
||||
session.ensure_authenticated().await?;
|
||||
}
|
||||
|
||||
@@ -534,6 +537,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
llm
|
||||
};
|
||||
|
||||
// Initialize cheap LLM provider for lightweight tasks (heartbeat, evaluation)
|
||||
let cheap_llm = create_cheap_llm_provider(&config.llm, session.clone())?;
|
||||
if let Some(ref cheap) = cheap_llm {
|
||||
tracing::info!("Cheap LLM provider initialized: {}", cheap.model_name());
|
||||
}
|
||||
|
||||
// Initialize safety layer
|
||||
let safety = Arc::new(SafetyLayer::new(&config.safety));
|
||||
tracing::info!("Safety layer initialized");
|
||||
@@ -1185,6 +1194,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let deps = AgentDeps {
|
||||
store: db,
|
||||
llm,
|
||||
cheap_llm,
|
||||
safety,
|
||||
tools,
|
||||
workspace,
|
||||
@@ -1229,6 +1239,18 @@ fn check_onboard_needed() -> Option<&'static str> {
|
||||
return Some("Database not configured");
|
||||
}
|
||||
|
||||
// First run (onboarding never completed and no session).
|
||||
// Reads NEARAI_API_KEY env var directly because this function runs
|
||||
// before Config is loaded -- Config::from_env() may fail without a
|
||||
// database URL, which is what triggers onboarding in the first place.
|
||||
if std::env::var("NEARAI_API_KEY").is_err() {
|
||||
let settings = ironclaw::settings::Settings::load();
|
||||
let session_path = ironclaw::llm::session::default_session_path();
|
||||
if !settings.onboard_completed && !session_path.exists() {
|
||||
return Some("First run");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ async fn report_complete(
|
||||
State(state): State<OrchestratorState>,
|
||||
Path(job_id): Path<Uuid>,
|
||||
Json(report): Json<CompletionReport>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
if report.success {
|
||||
tracing::info!(
|
||||
job_id = %job_id,
|
||||
@@ -223,7 +223,7 @@ async fn report_complete(
|
||||
};
|
||||
let _ = state.job_manager.complete_job(job_id, result).await;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
Ok(Json(serde_json::json!({"status": "ok"})))
|
||||
}
|
||||
|
||||
// -- Sandbox job event handlers --
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
# Setup / Onboarding Specification
|
||||
|
||||
This document is the authoritative specification for IronClaw's onboarding
|
||||
wizard. Any code change to `src/setup/` **must** keep this document in sync.
|
||||
If a future contributor or coding agent modifies setup behavior, update this
|
||||
file first, then adjust the code to match.
|
||||
|
||||
---
|
||||
|
||||
## Entry Points
|
||||
|
||||
```
|
||||
ironclaw onboard [--skip-auth] [--channels-only]
|
||||
```
|
||||
|
||||
Explicit invocation. Loads `.env` files, runs the wizard, exits.
|
||||
|
||||
```
|
||||
ironclaw (first run, no database configured)
|
||||
```
|
||||
|
||||
Auto-detection via `check_onboard_needed()` in `main.rs`. Triggers when
|
||||
none of these are true:
|
||||
- `DATABASE_URL` env var is set
|
||||
- `LIBSQL_PATH` env var is set
|
||||
- `~/.ironclaw/ironclaw.db` exists on disk
|
||||
|
||||
The `--no-onboard` CLI flag suppresses auto-detection.
|
||||
|
||||
---
|
||||
|
||||
## Startup Sequence (main.rs)
|
||||
|
||||
```
|
||||
1. Parse CLI args
|
||||
2. If Command::Onboard → load .env, run wizard, exit
|
||||
3. If Command::Run or no command:
|
||||
a. Load .env files (dotenvy::dotenv() then load_ironclaw_env())
|
||||
b. check_onboard_needed() → run wizard if needed
|
||||
c. Config::from_env() → build config from env vars
|
||||
d. Create SessionManager → load session token
|
||||
e. ensure_authenticated() → validate session (NEAR AI only)
|
||||
f. ... rest of agent startup
|
||||
```
|
||||
|
||||
**Critical ordering:** `.env` files must be loaded (step 3a) before
|
||||
`Config::from_env()` (step 3c) because bootstrap vars like
|
||||
`DATABASE_BACKEND` live in `~/.ironclaw/.env`.
|
||||
|
||||
---
|
||||
|
||||
## The 7-Step Wizard
|
||||
|
||||
### Overview
|
||||
|
||||
```
|
||||
Step 1: Database Connection
|
||||
Step 2: Security (master key)
|
||||
Step 3: Inference Provider ← skipped if --skip-auth
|
||||
Step 4: Model Selection
|
||||
Step 5: Embeddings
|
||||
Step 6: Channel Configuration
|
||||
Step 7: Background Tasks (heartbeat)
|
||||
↓
|
||||
save_and_summarize()
|
||||
```
|
||||
|
||||
`--channels-only` mode runs only Step 6, skipping everything else.
|
||||
|
||||
---
|
||||
|
||||
### Step 1: Database Connection
|
||||
|
||||
**Module:** `wizard.rs` → `step_database()`
|
||||
|
||||
**Goal:** Select backend, establish connection, run migrations.
|
||||
|
||||
**Decision tree:**
|
||||
|
||||
```
|
||||
Both features compiled?
|
||||
├─ Yes → DATABASE_BACKEND env var set?
|
||||
│ ├─ Yes → use that backend
|
||||
│ └─ No → interactive selection (PostgreSQL vs libSQL)
|
||||
├─ Only postgres feature → step_database_postgres()
|
||||
└─ Only libsql feature → step_database_libsql()
|
||||
```
|
||||
|
||||
**PostgreSQL path** (`step_database_postgres`):
|
||||
1. Check `DATABASE_URL` from env or settings
|
||||
2. Test connection (creates `deadpool_postgres::Pool`)
|
||||
3. Optionally run refinery migrations
|
||||
4. Store pool in `self.db_pool`
|
||||
|
||||
**libSQL path** (`step_database_libsql`):
|
||||
1. Offer local path (default: `~/.ironclaw/ironclaw.db`)
|
||||
2. Optional Turso cloud sync (URL + auth token)
|
||||
3. Test connection (creates `LibSqlBackend`)
|
||||
4. Always run migrations (idempotent CREATE IF NOT EXISTS)
|
||||
5. Store backend in `self.db_backend`
|
||||
|
||||
**Invariant:** After Step 1, exactly one of `self.db_pool` or
|
||||
`self.db_backend` is `Some`. This is required for settings persistence
|
||||
in `save_and_summarize()`.
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Security (Master Key)
|
||||
|
||||
**Module:** `wizard.rs` → `step_security()`
|
||||
|
||||
**Goal:** Configure encryption for API tokens and secrets.
|
||||
|
||||
**Decision tree:**
|
||||
|
||||
```
|
||||
SECRETS_MASTER_KEY env var set?
|
||||
├─ Yes → use env var, done
|
||||
└─ No → try get_master_key() from OS keychain
|
||||
├─ Ok(bytes) → cache in self.secrets_crypto, ask "use existing?"
|
||||
│ ├─ Yes → done (keychain)
|
||||
│ └─ No → clear cache, fall through to options
|
||||
└─ Err → fall through to options
|
||||
├─ OS Keychain: generate + store + build SecretsCrypto
|
||||
├─ Env variable: generate + print export command
|
||||
└─ Skip: disable secrets features
|
||||
```
|
||||
|
||||
**CRITICAL CAVEAT: macOS Keychain Dialogs**
|
||||
|
||||
On macOS, `security_framework::get_generic_password()` can trigger TWO
|
||||
system dialogs:
|
||||
1. "Enter your password to unlock the keychain" (keychain locked)
|
||||
2. "Allow ironclaw to access this keychain item" (per-app authorization)
|
||||
|
||||
This is OS-level behavior we cannot prevent. To minimize pain:
|
||||
|
||||
- **Use `get_master_key()` not `has_master_key()`** in step 2. Both call
|
||||
the same underlying API, but `get_master_key()` returns the key bytes
|
||||
so we can cache them. `has_master_key()` throws them away, forcing a
|
||||
second keychain access later.
|
||||
|
||||
- **Build `SecretsCrypto` eagerly.** When the keychain key is retrieved,
|
||||
immediately construct `SecretsCrypto` and store in `self.secrets_crypto`.
|
||||
Later calls to `init_secrets_context()` check this field first, avoiding
|
||||
redundant keychain probes.
|
||||
|
||||
- **Never probe the keychain in read-only commands** (e.g., `ironclaw status`).
|
||||
The status command reports "env not set (keychain may be configured)"
|
||||
rather than triggering system dialogs.
|
||||
|
||||
**Invariant:** After Step 2, `self.secrets_crypto` is `Some` if the user
|
||||
chose Keychain or generated a new key. It may be `None` if the user chose
|
||||
env-var mode or skipped secrets.
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Inference Provider
|
||||
|
||||
**Module:** `wizard.rs` → `step_inference_provider()`
|
||||
|
||||
**Goal:** Choose LLM backend and authenticate.
|
||||
|
||||
**Providers:**
|
||||
|
||||
| Provider | Auth Method | Secret Name | Env Var |
|
||||
|----------|-------------|-------------|---------|
|
||||
| NEAR AI | Browser OAuth | (session token) | `NEARAI_SESSION_TOKEN` |
|
||||
| Anthropic | API key | `anthropic_api_key` | `ANTHROPIC_API_KEY` |
|
||||
| OpenAI | API key | `openai_api_key` | `OPENAI_API_KEY` |
|
||||
| Ollama | None | - | - |
|
||||
| OpenAI-compatible | Optional API key | `llm_compatible_api_key` | `LLM_API_KEY` |
|
||||
|
||||
**API-key providers** (`setup_api_key_provider`):
|
||||
1. Check env var → if set, ask to reuse, persist to secrets store
|
||||
2. Otherwise prompt for key entry via `secret_input()`
|
||||
3. Store encrypted in secrets via `init_secrets_context()`
|
||||
4. **Cache key in `self.llm_api_key`** for model fetching in Step 4
|
||||
|
||||
**NEAR AI** (`setup_nearai`):
|
||||
- Calls `session_manager.ensure_authenticated()` which opens browser
|
||||
- Session token saved to `~/.ironclaw/session.json`
|
||||
|
||||
**`self.llm_api_key` caching:** The wizard caches the API key as
|
||||
`Option<SecretString>` so that Step 4 (model fetching) and Step 5
|
||||
(embeddings) can use it without re-reading from the secrets store or
|
||||
mutating environment variables.
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Model Selection
|
||||
|
||||
**Module:** `wizard.rs` → `step_model_selection()`
|
||||
|
||||
**Goal:** Choose which model to use.
|
||||
|
||||
**Flow:**
|
||||
1. If model already set → offer to keep it
|
||||
2. Fetch models from provider API (5-second timeout)
|
||||
3. On timeout or error → use static fallback list
|
||||
4. Present list + "Custom model ID" escape hatch
|
||||
5. Store in `self.settings.selected_model`
|
||||
|
||||
**Model fetchers pass the cached API key explicitly:**
|
||||
```rust
|
||||
let cached = self.llm_api_key.as_ref().map(|k| k.expose_secret().to_string());
|
||||
let models = fetch_anthropic_models(cached.as_deref()).await;
|
||||
```
|
||||
|
||||
This avoids mutating environment variables. The fetcher checks the explicit
|
||||
key first, then falls back to the standard env var.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Embeddings
|
||||
|
||||
**Module:** `wizard.rs` → `step_embeddings()`
|
||||
|
||||
**Goal:** Configure semantic search for workspace memory.
|
||||
|
||||
**Flow:**
|
||||
1. Ask "Enable semantic search?" (default: yes)
|
||||
2. Detect available providers:
|
||||
- NEAR AI: if backend is `nearai` OR valid session exists
|
||||
- OpenAI: if `OPENAI_API_KEY` in env OR (backend is `openai` AND cached key)
|
||||
3. If both available → let user choose
|
||||
4. If only one → use it
|
||||
5. If neither → disable embeddings
|
||||
|
||||
**Default model:** `text-embedding-3-small` (for both providers)
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Channel Configuration
|
||||
|
||||
**Module:** `wizard.rs` → `step_channels()`, delegating to `channels.rs`
|
||||
|
||||
**Goal:** Enable input channels (TUI, HTTP, Telegram, etc.).
|
||||
|
||||
**Sub-steps:**
|
||||
|
||||
```
|
||||
6a. Tunnel setup (if webhook channels needed)
|
||||
6b. Discover WASM channels from ~/.ironclaw/channels/
|
||||
6c. Multi-select: CLI/TUI, HTTP, discovered channels, bundled channels
|
||||
6d. Install missing bundled channels (copy WASM binaries)
|
||||
6e. Initialize SecretsContext (for token storage)
|
||||
6f. Setup HTTP webhook (if selected)
|
||||
6g. Setup each WASM channel (secrets, owner binding)
|
||||
```
|
||||
|
||||
**Tunnel setup** (`setup_tunnel`):
|
||||
- Options: ngrok, Cloudflare Tunnel, localtunnel, custom URL
|
||||
- Validates HTTPS requirement
|
||||
- Stored in `self.settings.tunnel.public_url`
|
||||
|
||||
**WASM channel setup** (`setup_wasm_channel`):
|
||||
- Reads `capabilities.json` for `setup.required_secrets`
|
||||
- For each secret: check existing, prompt or auto-generate, validate regex
|
||||
- Save each secret via `SecretsContext`
|
||||
|
||||
**Telegram special case** (`setup_telegram`):
|
||||
- Validates bot token via Telegram `getMe` API
|
||||
- Owner binding: polls `getUpdates` for 120s to capture sender's user ID
|
||||
- Optional webhook secret generation
|
||||
|
||||
**SecretsContext creation** (`init_secrets_context`):
|
||||
1. Check `self.secrets_crypto` (set in Step 2) → use if available
|
||||
2. Else try `SECRETS_MASTER_KEY` env var
|
||||
3. Else try `get_master_key()` from keychain (only in `channels_only` mode)
|
||||
4. Create backend-appropriate secrets store (respects selected database backend)
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Heartbeat
|
||||
|
||||
**Module:** `wizard.rs` → `step_heartbeat()`
|
||||
|
||||
**Goal:** Configure periodic background execution.
|
||||
|
||||
**Flow:**
|
||||
1. Ask "Enable heartbeat?" (default: no)
|
||||
2. If yes: interval in minutes (default: 30), notification channel
|
||||
3. Store in `self.settings.heartbeat`
|
||||
|
||||
---
|
||||
|
||||
## Settings Persistence
|
||||
|
||||
### Two-Layer Architecture
|
||||
|
||||
Settings are persisted in two places:
|
||||
|
||||
**Layer 1: `~/.ironclaw/.env`** (bootstrap vars)
|
||||
|
||||
Contains only the settings needed BEFORE database connection. Written by
|
||||
`save_bootstrap_env()` in `bootstrap.rs`.
|
||||
|
||||
```env
|
||||
DATABASE_BACKEND="libsql"
|
||||
LIBSQL_PATH="/Users/name/.ironclaw/ironclaw.db"
|
||||
```
|
||||
|
||||
Or for PostgreSQL:
|
||||
```env
|
||||
DATABASE_BACKEND="postgres"
|
||||
DATABASE_URL="postgres://user:pass@localhost/ironclaw"
|
||||
```
|
||||
|
||||
**Why separate?** Chicken-and-egg: you need `DATABASE_BACKEND` to know
|
||||
which database to connect to, so it can't be stored in the database.
|
||||
|
||||
**Layer 2: Database settings table** (everything else)
|
||||
|
||||
All other settings are stored as key-value pairs in the `settings` table,
|
||||
keyed by `(user_id, key)`. Written by `set_all_settings()`.
|
||||
|
||||
Settings are serialized via `Settings::to_db_map()` as dotted paths:
|
||||
```
|
||||
database_backend = "libsql"
|
||||
llm_backend = "nearai"
|
||||
selected_model = "anthropic/claude-sonnet-4-5"
|
||||
embeddings.enabled = "true"
|
||||
embeddings.provider = "nearai"
|
||||
channels.http_enabled = "true"
|
||||
heartbeat.enabled = "true"
|
||||
heartbeat.interval_secs = "300"
|
||||
```
|
||||
|
||||
### save_and_summarize()
|
||||
|
||||
Final step of the wizard:
|
||||
|
||||
```
|
||||
1. Mark onboard_completed = true
|
||||
2. Write ALL settings to database (try postgres pool, then libSQL backend)
|
||||
3. Write bootstrap vars to ~/.ironclaw/.env:
|
||||
- DATABASE_BACKEND (always)
|
||||
- DATABASE_URL (if postgres)
|
||||
- LIBSQL_PATH (if libsql)
|
||||
- LIBSQL_URL (if turso sync)
|
||||
4. Print configuration summary
|
||||
```
|
||||
|
||||
**Invariant:** Both Layer 1 and Layer 2 must be written. If the database
|
||||
write fails, the wizard returns an error and the `.env` file is not written.
|
||||
|
||||
### Legacy Migration
|
||||
|
||||
`bootstrap.rs` handles one-time upgrades from older config formats:
|
||||
- `bootstrap.json` → extracts `DATABASE_URL`, writes `.env`, renames to `.migrated`
|
||||
- `settings.json` → migrated to database via `migrate_disk_to_db()`
|
||||
|
||||
---
|
||||
|
||||
## Settings Struct
|
||||
|
||||
**Module:** `settings.rs`
|
||||
|
||||
```rust
|
||||
pub struct Settings {
|
||||
// Meta
|
||||
pub onboard_completed: bool,
|
||||
|
||||
// Step 1: Database
|
||||
pub database_backend: Option<String>, // "postgres" | "libsql"
|
||||
pub database_url: Option<String>,
|
||||
pub libsql_path: Option<String>,
|
||||
pub libsql_url: Option<String>,
|
||||
|
||||
// Step 2: Security
|
||||
pub secrets_master_key_source: KeySource, // Keychain | Env | None
|
||||
|
||||
// Step 3: Inference
|
||||
pub llm_backend: Option<String>, // "nearai" | "anthropic" | "openai" | "ollama" | "openai_compatible"
|
||||
pub ollama_base_url: Option<String>,
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
|
||||
// Step 4: Model
|
||||
pub selected_model: Option<String>,
|
||||
|
||||
// Step 5: Embeddings
|
||||
pub embeddings: EmbeddingsSettings, // enabled, provider, model
|
||||
|
||||
// Step 6: Channels
|
||||
pub tunnel: TunnelSettings, // provider, public_url
|
||||
pub channels: ChannelSettings, // http config, telegram owner, etc.
|
||||
|
||||
// Step 7: Heartbeat
|
||||
pub heartbeat: HeartbeatSettings, // enabled, interval, notify
|
||||
|
||||
// Advanced (not in wizard, set via `ironclaw config set`)
|
||||
pub agent: AgentSettings,
|
||||
pub wasm: WasmSettings,
|
||||
pub sandbox: SandboxSettings,
|
||||
pub safety: SafetySettings,
|
||||
pub builder: BuilderSettings,
|
||||
}
|
||||
```
|
||||
|
||||
**KeySource enum:** `Keychain | Env | None`
|
||||
|
||||
---
|
||||
|
||||
## Secrets Flow
|
||||
|
||||
### SecretsContext
|
||||
|
||||
Thin wrapper for setup-time secret operations:
|
||||
|
||||
```rust
|
||||
pub struct SecretsContext {
|
||||
store: Arc<dyn SecretsStore>,
|
||||
user_id: String,
|
||||
}
|
||||
```
|
||||
|
||||
Created by `init_secrets_context()` which:
|
||||
1. Gets `SecretsCrypto` from `self.secrets_crypto` or loads from keychain/env
|
||||
2. Creates the appropriate backend store:
|
||||
- If both features compiled: respects `self.settings.database_backend`
|
||||
- Tries selected backend first, falls back to the other
|
||||
3. Returns `SecretsContext` wrapping the store
|
||||
|
||||
### Secret Storage
|
||||
|
||||
Secrets are encrypted with AES-256-GCM using the master key, then stored
|
||||
in the database `secrets` table. The wizard writes secrets like:
|
||||
|
||||
```
|
||||
telegram_bot_token → encrypted bot token
|
||||
telegram_webhook_secret → encrypted webhook HMAC secret
|
||||
anthropic_api_key → encrypted API key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prompt Utilities
|
||||
|
||||
**Module:** `prompts.rs`
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `select_one(label, options)` | Numbered single-choice menu |
|
||||
| `select_many(label, options, defaults)` | Checkbox multi-select (raw terminal mode) |
|
||||
| `input(label)` | Single line text input |
|
||||
| `optional_input(label, hint)` | Text input that can be empty |
|
||||
| `secret_input(label)` | Hidden input (shows `*` per char), returns `SecretString` |
|
||||
| `confirm(label, default)` | `[Y/n]` or `[y/N]` prompt |
|
||||
| `print_header(text)` | Bold section header with underline |
|
||||
| `print_step(n, total, text)` | `[1/7] Step Name` |
|
||||
| `print_success(text)` | Green checkmark prefix |
|
||||
| `print_error(text)` | Red X prefix |
|
||||
| `print_info(text)` | Blue info prefix |
|
||||
|
||||
`select_many` uses `crossterm` raw mode for arrow key navigation.
|
||||
Must properly restore terminal state on all exit paths.
|
||||
|
||||
---
|
||||
|
||||
## Platform Caveats
|
||||
|
||||
### macOS Keychain
|
||||
|
||||
- `get_generic_password()` triggers system dialogs (unlock + authorize)
|
||||
- Two dialogs per call is normal, not a bug
|
||||
- Cache the result after first access to avoid repeat prompts
|
||||
- Never probe keychain in read-only commands (`status`, `--help`)
|
||||
- Service name: `"ironclaw"`, account: `"master_key"`
|
||||
|
||||
### Linux Secret Service
|
||||
|
||||
- Uses GNOME Keyring or KWallet via `secret-service` crate
|
||||
- May need `gnome-keyring` daemon running
|
||||
- Collection unlock may prompt for password
|
||||
|
||||
### URL Passwords
|
||||
|
||||
- `#` is common in URL-encoded passwords (`%23` decoded)
|
||||
- `.env` values must be double-quoted to preserve `#`
|
||||
- Display masked: `postgres://user:****@host/db`
|
||||
|
||||
### Telegram API
|
||||
|
||||
- Bot token format: `123456:ABC-DEF...`
|
||||
- Token goes in URL path: `https://api.telegram.org/bot{TOKEN}/method`
|
||||
- Webhook secret header: `X-Telegram-Bot-Api-Secret-Token`
|
||||
- Owner binding polls `getUpdates` (must delete webhook first)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `mod tests {}` at the bottom of each file.
|
||||
|
||||
**What to test when modifying setup:**
|
||||
|
||||
- Settings round-trip: `to_db_map()` then `from_db_map()` preserves values
|
||||
- Bootstrap `.env`: dotenvy can parse what `save_bootstrap_env()` writes
|
||||
- Model fetchers: static fallback works when API is unreachable
|
||||
- Channel discovery: handles missing dir, invalid JSON, deduplication
|
||||
- Prompt functions: not tested (interactive I/O), but ensure error paths
|
||||
don't panic
|
||||
|
||||
**Run setup tests:**
|
||||
```bash
|
||||
cargo test --lib -- setup
|
||||
cargo test --lib -- bootstrap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Modification Checklist
|
||||
|
||||
When changing the onboarding flow:
|
||||
|
||||
1. Update this README first with the intended behavior change
|
||||
2. If adding a new wizard step:
|
||||
- Add to the step enum in `run()`, adjust `total_steps`
|
||||
- Add corresponding settings fields to `Settings`
|
||||
- Add `to_db_map` / `from_db_map` serialization
|
||||
- If the setting is needed before DB connection, add to `save_bootstrap_env()`
|
||||
3. If adding a new provider or channel:
|
||||
- Add to the selection menu in the appropriate step
|
||||
- Add authentication flow (API key or OAuth)
|
||||
- Add model fetcher with static fallback + 5s timeout
|
||||
4. If touching keychain:
|
||||
- Cache the result, never call `get_master_key()` twice
|
||||
- Test on macOS (dialog behavior differs from Linux)
|
||||
5. If touching secrets:
|
||||
- Ensure `init_secrets_context()` respects the selected database backend
|
||||
- Test with both postgres and libsql features
|
||||
6. Run the full shipping checklist:
|
||||
```bash
|
||||
cargo fmt
|
||||
cargo clippy --all --benches --tests --examples --all-features -- -D warnings
|
||||
cargo test --lib -- setup bootstrap
|
||||
```
|
||||
7. Test a fresh onboarding: `rm -rf ~/.ironclaw && cargo run`
|
||||
@@ -1014,6 +1014,7 @@ impl SetupWizard {
|
||||
backend: crate::config::LlmBackend::NearAi,
|
||||
nearai: crate::config::NearAiConfig {
|
||||
model: "dummy".to_string(),
|
||||
cheap_model: None,
|
||||
base_url,
|
||||
auth_base_url,
|
||||
session_path: crate::llm::session::default_session_path(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
/// Simple echo tool for testing.
|
||||
pub struct EchoTool;
|
||||
@@ -38,12 +38,7 @@ impl Tool for EchoTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'message' parameter".to_string())
|
||||
})?;
|
||||
let message = require_str(¶ms, "message")?;
|
||||
|
||||
Ok(ToolOutput::text(message, start.elapsed()))
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
//! E-commerce tool for shopping and price comparison.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Tool for e-commerce operations (Amazon, price comparison, etc.).
|
||||
pub struct EcommerceTool {
|
||||
// TODO: Add API clients
|
||||
}
|
||||
|
||||
impl EcommerceTool {
|
||||
/// Create a new e-commerce tool.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EcommerceTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EcommerceTool {
|
||||
fn name(&self) -> &str {
|
||||
"ecommerce"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search products, compare prices, and find deals across e-commerce platforms."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["search", "get_product", "compare_prices", "track_price"],
|
||||
"description": "The e-commerce action to perform"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query (for search action)"
|
||||
},
|
||||
"product_id": {
|
||||
"type": "string",
|
||||
"description": "Product ID or ASIN (for get_product, compare_prices)"
|
||||
},
|
||||
"platform": {
|
||||
"type": "string",
|
||||
"enum": ["amazon", "ebay", "walmart", "all"],
|
||||
"description": "E-commerce platform to search"
|
||||
},
|
||||
"max_price": {
|
||||
"type": "number",
|
||||
"description": "Maximum price filter"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Product category filter"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let action = params
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'action' parameter".to_string())
|
||||
})?;
|
||||
|
||||
// TODO: Implement actual e-commerce API integrations
|
||||
let result = match action {
|
||||
"search" => {
|
||||
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
serde_json::json!({
|
||||
"query": query,
|
||||
"results": [],
|
||||
"message": "E-commerce integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"get_product" => {
|
||||
let product_id = params
|
||||
.get("product_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'product_id' parameter".to_string())
|
||||
})?;
|
||||
|
||||
serde_json::json!({
|
||||
"product_id": product_id,
|
||||
"found": false,
|
||||
"message": "E-commerce integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"compare_prices" => {
|
||||
serde_json::json!({
|
||||
"prices": [],
|
||||
"message": "E-commerce integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"track_price" => {
|
||||
serde_json::json!({
|
||||
"tracking": false,
|
||||
"message": "E-commerce integration not yet implemented"
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"unknown action: {}",
|
||||
action
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ToolOutput::success(result, start.elapsed()))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
true // External e-commerce data
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::extensions::{ExtensionKind, ExtensionManager};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
// ── tool_search ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -133,10 +133,7 @@ impl Tool for ToolInstallTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let url = params.get("url").and_then(|v| v.as_str());
|
||||
|
||||
@@ -210,10 +207,7 @@ impl Tool for ToolAuthTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let result = self
|
||||
.manager
|
||||
@@ -306,10 +300,7 @@ impl Tool for ToolActivateTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
match self.manager.activate(name).await {
|
||||
Ok(result) => {
|
||||
@@ -471,10 +462,7 @@ impl Tool for ToolRemoveTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let message = self
|
||||
.manager
|
||||
|
||||
@@ -11,7 +11,7 @@ use async_trait::async_trait;
|
||||
use tokio::fs;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str};
|
||||
use crate::workspace::paths as ws_paths;
|
||||
|
||||
/// Well-known workspace filenames that must go through memory_write, not write_file.
|
||||
@@ -203,10 +203,7 @@ impl Tool for ReadFileTool {
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let path_str = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
||||
let path_str = require_str(¶ms, "path")?;
|
||||
|
||||
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
let limit = params.get("limit").and_then(|v| v.as_u64());
|
||||
@@ -328,10 +325,7 @@ impl Tool for WriteFileTool {
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let path_str = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
||||
let path_str = require_str(¶ms, "path")?;
|
||||
|
||||
// Reject workspace paths: these live in the database, not on disk.
|
||||
if is_workspace_path(path_str) {
|
||||
@@ -342,10 +336,7 @@ impl Tool for WriteFileTool {
|
||||
)));
|
||||
}
|
||||
|
||||
let content = params
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'content' parameter".into()))?;
|
||||
let content = require_str(¶ms, "content")?;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
@@ -650,20 +641,11 @@ impl Tool for ApplyPatchTool {
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let path_str = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
|
||||
let path_str = require_str(¶ms, "path")?;
|
||||
|
||||
let old_string = params
|
||||
.get("old_string")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'old_string' parameter".into()))?;
|
||||
let old_string = require_str(¶ms, "old_string")?;
|
||||
|
||||
let new_string = params
|
||||
.get("new_string")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'new_string' parameter".into()))?;
|
||||
let new_string = require_str(¶ms, "new_string")?;
|
||||
|
||||
let replace_all = params
|
||||
.get("replace_all")
|
||||
|
||||
@@ -9,7 +9,7 @@ use reqwest::Client;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::safety::LeakDetector;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
/// Maximum response body size (5 MB). Prevents OOM from unbounded responses.
|
||||
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
|
||||
@@ -154,17 +154,9 @@ impl Tool for HttpTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let method = params
|
||||
.get("method")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'method' parameter".to_string())
|
||||
})?;
|
||||
let method = require_str(¶ms, "method")?;
|
||||
|
||||
let url = params
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'url' parameter".to_string()))?;
|
||||
let url = require_str(¶ms, "url")?;
|
||||
let parsed_url = validate_url(url)?;
|
||||
|
||||
// Parse headers
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::context::{ContextManager, JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::history::SandboxJobRecord;
|
||||
use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
/// Tool for creating a new job.
|
||||
///
|
||||
@@ -467,17 +467,9 @@ impl Tool for CreateJobTool {
|
||||
params: serde_json::Value,
|
||||
ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let title = params
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?;
|
||||
let title = require_str(¶ms, "title")?;
|
||||
|
||||
let description = params
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'description' parameter".into())
|
||||
})?;
|
||||
let description = require_str(¶ms, "description")?;
|
||||
|
||||
if self.sandbox_enabled() {
|
||||
let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
@@ -635,10 +627,7 @@ impl Tool for JobStatusTool {
|
||||
let start = std::time::Instant::now();
|
||||
let requester_id = ctx.user_id.clone();
|
||||
|
||||
let job_id_str = params
|
||||
.get("job_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
|
||||
let job_id_str = require_str(¶ms, "job_id")?;
|
||||
|
||||
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
|
||||
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
|
||||
@@ -720,10 +709,7 @@ impl Tool for CancelJobTool {
|
||||
let start = std::time::Instant::now();
|
||||
let requester_id = ctx.user_id.clone();
|
||||
|
||||
let job_id_str = params
|
||||
.get("job_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
|
||||
let job_id_str = require_str(¶ms, "job_id")?;
|
||||
|
||||
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
|
||||
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_param, require_str};
|
||||
|
||||
/// Tool for JSON manipulation (parse, query, transform).
|
||||
pub struct JsonTool;
|
||||
@@ -46,16 +46,9 @@ impl Tool for JsonTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let operation = params
|
||||
.get("operation")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'operation' parameter".to_string())
|
||||
})?;
|
||||
let operation = require_str(¶ms, "operation")?;
|
||||
|
||||
let data = params
|
||||
.get("data")
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'data' parameter".to_string()))?;
|
||||
let data = require_param(¶ms, "data")?;
|
||||
|
||||
let result = match operation {
|
||||
"parse" => {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
//! NEAR AI Marketplace tool.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Tool for interacting with the NEAR AI marketplace.
|
||||
pub struct MarketplaceTool {
|
||||
// TODO: Add marketplace client
|
||||
}
|
||||
|
||||
impl MarketplaceTool {
|
||||
/// Create a new marketplace tool.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MarketplaceTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MarketplaceTool {
|
||||
fn name(&self) -> &str {
|
||||
"marketplace"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Interact with the NEAR AI marketplace: search jobs, submit bids, deliver work."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["search_jobs", "get_job", "submit_bid", "accept_job", "submit_work", "get_status"],
|
||||
"description": "The marketplace action to perform"
|
||||
},
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Job ID (for get_job, submit_bid, accept_job, submit_work)"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query (for search_jobs)"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"description": "Job category filter (for search_jobs)"
|
||||
},
|
||||
"bid_amount": {
|
||||
"type": "number",
|
||||
"description": "Bid amount in NEAR (for submit_bid)"
|
||||
},
|
||||
"work_url": {
|
||||
"type": "string",
|
||||
"description": "URL to submitted work (for submit_work)"
|
||||
},
|
||||
"work_description": {
|
||||
"type": "string",
|
||||
"description": "Description of completed work (for submit_work)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let action = params
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'action' parameter".to_string())
|
||||
})?;
|
||||
|
||||
// TODO: Implement actual marketplace integration
|
||||
let result = match action {
|
||||
"search_jobs" => {
|
||||
// Placeholder response
|
||||
serde_json::json!({
|
||||
"jobs": [],
|
||||
"total": 0,
|
||||
"message": "Marketplace integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"get_job" => {
|
||||
let job_id = params
|
||||
.get("job_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'job_id' parameter".to_string())
|
||||
})?;
|
||||
|
||||
serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"status": "not_found",
|
||||
"message": "Marketplace integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"submit_bid" => {
|
||||
serde_json::json!({
|
||||
"success": false,
|
||||
"message": "Marketplace integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"accept_job" => {
|
||||
serde_json::json!({
|
||||
"success": false,
|
||||
"message": "Marketplace integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"submit_work" => {
|
||||
serde_json::json!({
|
||||
"success": false,
|
||||
"message": "Marketplace integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"get_status" => {
|
||||
serde_json::json!({
|
||||
"connected": false,
|
||||
"message": "Marketplace integration not yet implemented"
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"unknown action: {}",
|
||||
action
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ToolOutput::success(result, start.elapsed()))
|
||||
}
|
||||
|
||||
fn estimated_cost(&self, params: &serde_json::Value) -> Option<Decimal> {
|
||||
// Bidding has a cost
|
||||
if params.get("action").and_then(|v| v.as_str()) == Some("submit_bid") {
|
||||
Some(Decimal::new(1, 2)) // 0.01 NEAR gas cost
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
true // External marketplace data
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||
use crate::workspace::{Workspace, paths};
|
||||
|
||||
/// Identity files that the LLM must not overwrite via tool calls.
|
||||
@@ -81,10 +81,7 @@ impl Tool for MemorySearchTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let query = params
|
||||
.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'query' parameter".to_string()))?;
|
||||
let query = require_str(¶ms, "query")?;
|
||||
|
||||
let limit = params
|
||||
.get("limit")
|
||||
@@ -176,12 +173,7 @@ impl Tool for MemoryWriteTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let content = params
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'content' parameter".to_string())
|
||||
})?;
|
||||
let content = require_str(¶ms, "content")?;
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return Err(ToolError::InvalidParameters(
|
||||
@@ -337,10 +329,7 @@ impl Tool for MemoryReadTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let path = params
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".to_string()))?;
|
||||
let path = require_str(¶ms, "path")?;
|
||||
|
||||
let doc = self
|
||||
.workspace
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
//! Built-in tools that come with the agent.
|
||||
|
||||
mod echo;
|
||||
mod ecommerce;
|
||||
pub mod extension_tools;
|
||||
mod file;
|
||||
mod http;
|
||||
mod job;
|
||||
mod json;
|
||||
mod marketplace;
|
||||
mod memory;
|
||||
mod restaurant;
|
||||
pub mod routine;
|
||||
pub(crate) mod shell;
|
||||
mod taskrabbit;
|
||||
mod time;
|
||||
|
||||
pub use echo::EchoTool;
|
||||
pub use ecommerce::EcommerceTool;
|
||||
pub use extension_tools::{
|
||||
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
|
||||
};
|
||||
@@ -24,12 +19,9 @@ pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
|
||||
pub use http::HttpTool;
|
||||
pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool};
|
||||
pub use json::JsonTool;
|
||||
pub use marketplace::MarketplaceTool;
|
||||
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
||||
pub use restaurant::RestaurantTool;
|
||||
pub use routine::{
|
||||
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
|
||||
};
|
||||
pub use shell::ShellTool;
|
||||
pub use taskrabbit::TaskRabbitTool;
|
||||
pub use time::TimeTool;
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
//! Restaurant reservation tool.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Tool for restaurant reservations (OpenTable, Resy, etc.).
|
||||
pub struct RestaurantTool {
|
||||
// TODO: Add reservation API clients
|
||||
}
|
||||
|
||||
impl RestaurantTool {
|
||||
/// Create a new restaurant tool.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RestaurantTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for RestaurantTool {
|
||||
fn name(&self) -> &str {
|
||||
"restaurant"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search restaurants, check availability, and make reservations via OpenTable, Resy, etc."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["search", "check_availability", "make_reservation", "cancel_reservation", "get_reservation"],
|
||||
"description": "The restaurant action to perform"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query (cuisine type, restaurant name, etc.)"
|
||||
},
|
||||
"location": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": { "type": "string" },
|
||||
"neighborhood": { "type": "string" },
|
||||
"latitude": { "type": "number" },
|
||||
"longitude": { "type": "number" }
|
||||
},
|
||||
"description": "Location to search near"
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"description": "Reservation date (YYYY-MM-DD)"
|
||||
},
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "Preferred time (HH:MM)"
|
||||
},
|
||||
"party_size": {
|
||||
"type": "integer",
|
||||
"description": "Number of guests"
|
||||
},
|
||||
"restaurant_id": {
|
||||
"type": "string",
|
||||
"description": "Restaurant ID (for check_availability, make_reservation)"
|
||||
},
|
||||
"reservation_id": {
|
||||
"type": "string",
|
||||
"description": "Reservation ID (for cancel_reservation, get_reservation)"
|
||||
},
|
||||
"guest_name": {
|
||||
"type": "string",
|
||||
"description": "Name for the reservation"
|
||||
},
|
||||
"guest_phone": {
|
||||
"type": "string",
|
||||
"description": "Phone number for the reservation"
|
||||
},
|
||||
"guest_email": {
|
||||
"type": "string",
|
||||
"description": "Email for the reservation"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let action = params
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'action' parameter".to_string())
|
||||
})?;
|
||||
|
||||
// TODO: Implement actual restaurant reservation API integrations
|
||||
let result = match action {
|
||||
"search" => {
|
||||
let query = params.get("query").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
serde_json::json!({
|
||||
"query": query,
|
||||
"restaurants": [],
|
||||
"message": "Restaurant integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"check_availability" => {
|
||||
let restaurant_id = params
|
||||
.get("restaurant_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters(
|
||||
"missing 'restaurant_id' parameter".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
serde_json::json!({
|
||||
"restaurant_id": restaurant_id,
|
||||
"available_times": [],
|
||||
"message": "Restaurant integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"make_reservation" => {
|
||||
serde_json::json!({
|
||||
"success": false,
|
||||
"message": "Restaurant integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"cancel_reservation" => {
|
||||
serde_json::json!({
|
||||
"cancelled": false,
|
||||
"message": "Restaurant integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"get_reservation" => {
|
||||
let reservation_id = params.get("reservation_id").and_then(|v| v.as_str());
|
||||
|
||||
serde_json::json!({
|
||||
"reservation_id": reservation_id,
|
||||
"found": false,
|
||||
"message": "Restaurant integration not yet implemented"
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"unknown action: {}",
|
||||
action
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ToolOutput::success(result, start.elapsed()))
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
true // External restaurant data
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use crate::agent::routine::{
|
||||
use crate::agent::routine_engine::RoutineEngine;
|
||||
use crate::context::JobContext;
|
||||
use crate::db::Database;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
// ==================== routine_create ====================
|
||||
|
||||
@@ -106,25 +106,16 @@ impl Tool for RoutineCreateTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let description = params
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let trigger_type = params
|
||||
.get("trigger_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'trigger_type'".to_string()))?;
|
||||
let trigger_type = require_str(¶ms, "trigger_type")?;
|
||||
|
||||
let prompt = params
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'prompt'".to_string()))?;
|
||||
let prompt = require_str(¶ms, "prompt")?;
|
||||
|
||||
// Build trigger
|
||||
let trigger = match trigger_type {
|
||||
@@ -408,10 +399,7 @@ impl Tool for RoutineUpdateTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let mut routine = self
|
||||
.store
|
||||
@@ -514,10 +502,7 @@ impl Tool for RoutineDeleteTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let routine = self
|
||||
.store
|
||||
@@ -595,10 +580,7 @@ impl Tool for RoutineHistoryTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let name = params
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
|
||||
let name = require_str(¶ms, "name")?;
|
||||
|
||||
let limit = params
|
||||
.get("limit")
|
||||
|
||||
@@ -30,7 +30,7 @@ use tokio::process::Command;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::sandbox::{SandboxManager, SandboxPolicy};
|
||||
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str};
|
||||
|
||||
/// Maximum output size before truncation (64KB).
|
||||
const MAX_OUTPUT_SIZE: usize = 64 * 1024;
|
||||
@@ -401,10 +401,7 @@ impl Tool for ShellTool {
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let command = params
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters("missing 'command' parameter".into()))?;
|
||||
let command = require_str(¶ms, "command")?;
|
||||
|
||||
let workdir = params.get("workdir").and_then(|v| v.as_str());
|
||||
let timeout = params.get("timeout").and_then(|v| v.as_u64());
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
//! TaskRabbit tool for real-world task delegation.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
|
||||
/// Tool for delegating real-world tasks via TaskRabbit.
|
||||
pub struct TaskRabbitTool {
|
||||
// TODO: Add TaskRabbit API client
|
||||
}
|
||||
|
||||
impl TaskRabbitTool {
|
||||
/// Create a new TaskRabbit tool.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskRabbitTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskRabbitTool {
|
||||
fn name(&self) -> &str {
|
||||
"taskrabbit"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Delegate real-world tasks to TaskRabbit taskers (delivery, assembly, cleaning, etc.)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["search_taskers", "get_quote", "book_task", "get_status", "cancel_task"],
|
||||
"description": "The TaskRabbit action to perform"
|
||||
},
|
||||
"task_type": {
|
||||
"type": "string",
|
||||
"enum": ["delivery", "assembly", "moving", "cleaning", "handyman", "other"],
|
||||
"description": "Type of task"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Detailed description of the task"
|
||||
},
|
||||
"location": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"address": { "type": "string" },
|
||||
"city": { "type": "string" },
|
||||
"state": { "type": "string" },
|
||||
"zip": { "type": "string" }
|
||||
},
|
||||
"description": "Location for the task"
|
||||
},
|
||||
"scheduled_time": {
|
||||
"type": "string",
|
||||
"description": "ISO 8601 datetime for when the task should be performed"
|
||||
},
|
||||
"budget": {
|
||||
"type": "number",
|
||||
"description": "Maximum budget for the task in USD"
|
||||
},
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "Task ID (for get_status, cancel_task)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
&self,
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let action = params
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'action' parameter".to_string())
|
||||
})?;
|
||||
|
||||
// TODO: Implement actual TaskRabbit API integration
|
||||
let result = match action {
|
||||
"search_taskers" => {
|
||||
serde_json::json!({
|
||||
"taskers": [],
|
||||
"message": "TaskRabbit integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"get_quote" => {
|
||||
serde_json::json!({
|
||||
"quotes": [],
|
||||
"message": "TaskRabbit integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"book_task" => {
|
||||
serde_json::json!({
|
||||
"booked": false,
|
||||
"message": "TaskRabbit integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"get_status" => {
|
||||
let task_id = params.get("task_id").and_then(|v| v.as_str());
|
||||
|
||||
serde_json::json!({
|
||||
"task_id": task_id,
|
||||
"status": "unknown",
|
||||
"message": "TaskRabbit integration not yet implemented"
|
||||
})
|
||||
}
|
||||
"cancel_task" => {
|
||||
serde_json::json!({
|
||||
"cancelled": false,
|
||||
"message": "TaskRabbit integration not yet implemented"
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
return Err(ToolError::InvalidParameters(format!(
|
||||
"unknown action: {}",
|
||||
action
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ToolOutput::success(result, start.elapsed()))
|
||||
}
|
||||
|
||||
fn estimated_cost(&self, params: &serde_json::Value) -> Option<Decimal> {
|
||||
// Booking a task has associated costs
|
||||
if params.get("action").and_then(|v| v.as_str()) == Some("book_task") {
|
||||
params
|
||||
.get("budget")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|b| Decimal::try_from(b).unwrap_or_default())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn requires_sanitization(&self) -> bool {
|
||||
true // External TaskRabbit data
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::context::JobContext;
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str};
|
||||
|
||||
/// Tool for getting current time and date operations.
|
||||
pub struct TimeTool;
|
||||
@@ -52,12 +52,7 @@ impl Tool for TimeTool {
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let operation = params
|
||||
.get("operation")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'operation' parameter".to_string())
|
||||
})?;
|
||||
let operation = require_str(¶ms, "operation")?;
|
||||
|
||||
let result = match operation {
|
||||
"now" => {
|
||||
@@ -69,12 +64,7 @@ impl Tool for TimeTool {
|
||||
})
|
||||
}
|
||||
"parse" => {
|
||||
let timestamp = params
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'timestamp' parameter".to_string())
|
||||
})?;
|
||||
let timestamp = require_str(¶ms, "timestamp")?;
|
||||
|
||||
let dt: DateTime<Utc> = timestamp.parse().map_err(|e| {
|
||||
ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
|
||||
@@ -87,19 +77,9 @@ impl Tool for TimeTool {
|
||||
})
|
||||
}
|
||||
"diff" => {
|
||||
let ts1 = params
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'timestamp' parameter".to_string())
|
||||
})?;
|
||||
let ts1 = require_str(¶ms, "timestamp")?;
|
||||
|
||||
let ts2 = params
|
||||
.get("timestamp2")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'timestamp2' parameter".to_string())
|
||||
})?;
|
||||
let ts2 = require_str(¶ms, "timestamp2")?;
|
||||
|
||||
let dt1: DateTime<Utc> = ts1.parse().map_err(|e| {
|
||||
ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
|
||||
|
||||
+59
-6
@@ -199,6 +199,28 @@ pub trait Tool: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a required string parameter from a JSON object.
|
||||
///
|
||||
/// Returns `ToolError::InvalidParameters` if the key is missing or not a string.
|
||||
pub fn require_str<'a>(params: &'a serde_json::Value, name: &str) -> Result<&'a str, ToolError> {
|
||||
params
|
||||
.get(name)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
||||
}
|
||||
|
||||
/// Extract a required parameter of any type from a JSON object.
|
||||
///
|
||||
/// Returns `ToolError::InvalidParameters` if the key is missing.
|
||||
pub fn require_param<'a>(
|
||||
params: &'a serde_json::Value,
|
||||
name: &str,
|
||||
) -> Result<&'a serde_json::Value, ToolError> {
|
||||
params
|
||||
.get(name)
|
||||
.ok_or_else(|| ToolError::InvalidParameters(format!("missing '{}' parameter", name)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -235,12 +257,7 @@ mod tests {
|
||||
params: serde_json::Value,
|
||||
_ctx: &JobContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let message = params
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidParameters("missing 'message' parameter".to_string())
|
||||
})?;
|
||||
let message = require_str(¶ms, "message")?;
|
||||
|
||||
Ok(ToolOutput::text(message, Duration::from_millis(1)))
|
||||
}
|
||||
@@ -277,4 +294,40 @@ mod tests {
|
||||
let tool = EchoTool;
|
||||
assert_eq!(tool.execution_timeout(), Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_require_str_present() {
|
||||
let params = serde_json::json!({"name": "alice"});
|
||||
assert_eq!(require_str(¶ms, "name").unwrap(), "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_require_str_missing() {
|
||||
let params = serde_json::json!({});
|
||||
let err = require_str(¶ms, "name").unwrap_err();
|
||||
assert!(err.to_string().contains("missing 'name'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_require_str_wrong_type() {
|
||||
let params = serde_json::json!({"name": 42});
|
||||
let err = require_str(¶ms, "name").unwrap_err();
|
||||
assert!(err.to_string().contains("missing 'name'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_require_param_present() {
|
||||
let params = serde_json::json!({"data": [1, 2, 3]});
|
||||
assert_eq!(
|
||||
require_param(¶ms, "data").unwrap(),
|
||||
&serde_json::json!([1, 2, 3])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_require_param_missing() {
|
||||
let params = serde_json::json!({});
|
||||
let err = require_param(¶ms, "data").unwrap_err();
|
||||
assert!(err.to_string().contains("missing 'data'"));
|
||||
}
|
||||
}
|
||||
|
||||
+54
-70
@@ -129,11 +129,15 @@ impl WorkerHttpClient {
|
||||
format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path)
|
||||
}
|
||||
|
||||
/// Fetch the job description from the orchestrator.
|
||||
pub async fn get_job(&self) -> Result<JobDescription, WorkerError> {
|
||||
/// Send a GET request, check the status, and deserialize the JSON body.
|
||||
async fn get_json<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
context: &str,
|
||||
) -> Result<T, WorkerError> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(self.url("job"))
|
||||
.get(self.url(path))
|
||||
.bearer_auth(&self.token)
|
||||
.send()
|
||||
.await
|
||||
@@ -145,15 +149,51 @@ impl WorkerHttpClient {
|
||||
if !resp.status().is_success() {
|
||||
return Err(WorkerError::OrchestratorRejected {
|
||||
job_id: self.job_id,
|
||||
reason: format!("GET /job returned {}", resp.status()),
|
||||
reason: format!("{} returned {}", context, resp.status()),
|
||||
});
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
||||
reason: format!("failed to parse job description: {}", e),
|
||||
reason: format!("{}: failed to parse response: {}", context, e),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a POST request with a JSON body, check the status, and deserialize the response.
|
||||
async fn post_json<B: Serialize, T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
context: &str,
|
||||
) -> Result<T, WorkerError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.url(path))
|
||||
.bearer_auth(&self.token)
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| WorkerError::LlmProxyFailed {
|
||||
reason: format!("{}: {}", context, e),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(WorkerError::LlmProxyFailed {
|
||||
reason: format!("{}: orchestrator returned {}: {}", context, status, body),
|
||||
});
|
||||
}
|
||||
|
||||
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
||||
reason: format!("{}: failed to parse response: {}", context, e),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch the job description from the orchestrator.
|
||||
pub async fn get_job(&self) -> Result<JobDescription, WorkerError> {
|
||||
self.get_json("job", "GET /job").await
|
||||
}
|
||||
|
||||
/// Proxy an LLM completion request through the orchestrator.
|
||||
pub async fn llm_complete(
|
||||
&self,
|
||||
@@ -166,29 +206,9 @@ impl WorkerHttpClient {
|
||||
stop_sequences: request.stop_sequences.clone(),
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.url("llm/complete"))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&proxy_req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| WorkerError::LlmProxyFailed {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(WorkerError::LlmProxyFailed {
|
||||
reason: format!("orchestrator returned {}: {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
let proxy_resp: ProxyCompletionResponse =
|
||||
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
||||
reason: format!("failed to parse LLM response: {}", e),
|
||||
})?;
|
||||
let proxy_resp: ProxyCompletionResponse = self
|
||||
.post_json("llm/complete", &proxy_req, "LLM complete")
|
||||
.await?;
|
||||
|
||||
Ok(CompletionResponse {
|
||||
content: proxy_resp.content,
|
||||
@@ -212,29 +232,9 @@ impl WorkerHttpClient {
|
||||
tool_choice: request.tool_choice.clone(),
|
||||
};
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.url("llm/complete_with_tools"))
|
||||
.bearer_auth(&self.token)
|
||||
.json(&proxy_req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| WorkerError::LlmProxyFailed {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(WorkerError::LlmProxyFailed {
|
||||
reason: format!("orchestrator returned {}: {}", status, body),
|
||||
});
|
||||
}
|
||||
|
||||
let proxy_resp: ProxyToolCompletionResponse =
|
||||
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
|
||||
reason: format!("failed to parse tool completion response: {}", e),
|
||||
})?;
|
||||
let proxy_resp: ProxyToolCompletionResponse = self
|
||||
.post_json("llm/complete_with_tools", &proxy_req, "LLM tool complete")
|
||||
.await?;
|
||||
|
||||
Ok(ToolCompletionResponse {
|
||||
content: proxy_resp.content,
|
||||
@@ -337,25 +337,9 @@ impl WorkerHttpClient {
|
||||
|
||||
/// Signal job completion to the orchestrator.
|
||||
pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> {
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.url("complete"))
|
||||
.bearer_auth(&self.token)
|
||||
.json(report)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| WorkerError::ConnectionFailed {
|
||||
url: self.orchestrator_url.clone(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(WorkerError::OrchestratorRejected {
|
||||
job_id: self.job_id,
|
||||
reason: format!("completion report rejected: {}", resp.status()),
|
||||
});
|
||||
}
|
||||
|
||||
let _: serde_json::Value = self
|
||||
.post_json("complete", report, "report complete")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "github-tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "GitHub integration tool for IronClaw (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
wit-bindgen = "0.41.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# GitHub Tool for IronClaw
|
||||
|
||||
WASM tool for GitHub integration - manage repos, issues, PRs, and workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- **Repository Info** - Get repo details, list user repos
|
||||
- **Issues** - List, create, and get issue details
|
||||
- **Pull Requests** - List PRs, get PR details, review files, create reviews
|
||||
- **File Content** - Read files from repos
|
||||
- **Workflows** - Trigger GitHub Actions, check run status
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create a GitHub Personal Access Token at <https://github.com/settings/tokens>
|
||||
2. Required scopes: `repo`, `workflow`, `read:org`
|
||||
3. Store the token:
|
||||
|
||||
```
|
||||
ironclaw secret set github_token YOUR_TOKEN
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Get Repository Info
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "get_repo",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw"
|
||||
}
|
||||
```
|
||||
|
||||
### List Open Issues
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list_issues",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"state": "open",
|
||||
"limit": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Create Issue
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create_issue",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"title": "Bug: Something is broken",
|
||||
"body": "Detailed description...",
|
||||
"labels": ["bug", "help wanted"]
|
||||
}
|
||||
```
|
||||
|
||||
### List Pull Requests
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "list_pull_requests",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"state": "open",
|
||||
"limit": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Review PR
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "create_pr_review",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"pr_number": 42,
|
||||
"body": "LGTM! Great work.",
|
||||
"event": "APPROVE"
|
||||
}
|
||||
```
|
||||
|
||||
### Get File Content
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "get_file_content",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"path": "README.md",
|
||||
"ref": "main"
|
||||
}
|
||||
```
|
||||
|
||||
### Trigger Workflow
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "trigger_workflow",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"workflow_id": "ci.yml",
|
||||
"ref": "main",
|
||||
"inputs": {
|
||||
"environment": "staging"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Check Workflow Runs
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "get_workflow_runs",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"limit": 5
|
||||
}
|
||||
```
|
||||
|
||||
### List Workflow Runs (Pagination)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "get_workflow_runs",
|
||||
"owner": "nearai",
|
||||
"repo": "ironclaw",
|
||||
"limit": 5,
|
||||
"page": 2
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Errors are returned as strings in the `error` field of the response.
|
||||
|
||||
### Rate Limit Exceeded
|
||||
|
||||
When the GitHub API rate limit is exceeded (and retries fail), you might see:
|
||||
|
||||
```text
|
||||
GitHub API error 429: { "message": "API rate limit exceeded for user ID ...", ... }
|
||||
```
|
||||
|
||||
The tool automatically logs warnings when the rate limit is low (<10 remaining) and retries on 429/5xx errors.
|
||||
|
||||
### Invalid Parameters
|
||||
|
||||
```text
|
||||
Invalid event: 'INVALID'. Must be one of: APPROVE, REQUEST_CHANGES, COMMENT
|
||||
```
|
||||
|
||||
### Missing Token
|
||||
|
||||
```text
|
||||
GitHub token not found in secret store. Set it with: ironclaw secret set github_token <token>...
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "GitHub API error 404: Not Found"
|
||||
|
||||
- Check that the `owner` and `repo` are correct.
|
||||
- Ensure the `github_token` has access to the repository (especially for private repos).
|
||||
- Verify the token scopes include `repo` and `read:org`.
|
||||
|
||||
### "GitHub API error 401: Bad credentials"
|
||||
|
||||
- The token might be invalid or expired.
|
||||
- Update the token: `ironclaw secret set github_token NEW_TOKEN`.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- The tool logs a warning when remaining requests drop below 10.
|
||||
- Check logs for "GitHub API rate limit low".
|
||||
- If you hit the limit, wait for the reset time (usually 1 hour).
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd tools-src/github
|
||||
cargo build --target wasm32-wasi --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT/Apache-2.0
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"capabilities": {
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{
|
||||
"host": "api.github.com",
|
||||
"path_prefix": "/",
|
||||
"methods": [
|
||||
"GET",
|
||||
"POST"
|
||||
]
|
||||
}
|
||||
],
|
||||
"credentials": {
|
||||
"github_token": {
|
||||
"secret_name": "github_token",
|
||||
"location": {
|
||||
"type": "bearer"
|
||||
},
|
||||
"host_patterns": [
|
||||
"api.github.com"
|
||||
]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 3600
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": [
|
||||
"github_token",
|
||||
"github_*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"default_limit": 30,
|
||||
"max_limit": 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,845 @@
|
||||
//! GitHub WASM Tool for IronClaw.
|
||||
//!
|
||||
//! Provides GitHub integration for reading repos, managing issues,
|
||||
//! reviewing PRs, and triggering workflows.
|
||||
//!
|
||||
//! # Authentication
|
||||
//!
|
||||
//! Store your GitHub Personal Access Token:
|
||||
//! `ironclaw secret set github_token <token>`
|
||||
//!
|
||||
//! Token needs these permissions:
|
||||
//! - repo (for private repos)
|
||||
//! - workflow (for triggering actions)
|
||||
//! - read:org (for org repos)
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-tool",
|
||||
path: "../../wit/tool.wit",
|
||||
});
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const MAX_TEXT_LENGTH: usize = 65536;
|
||||
|
||||
/// Validate input length to prevent oversized payloads.
|
||||
fn validate_input_length(s: &str, field_name: &str) -> Result<(), String> {
|
||||
if s.len() > MAX_TEXT_LENGTH {
|
||||
return Err(format!(
|
||||
"Input '{}' exceeds maximum length of {} characters",
|
||||
field_name, MAX_TEXT_LENGTH
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Percent-encode a string for safe use in URL path segments.
|
||||
/// Encodes everything except alphanumeric, hyphen, underscore, and dot.
|
||||
fn url_encode_path(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() * 2);
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push('%');
|
||||
out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
|
||||
out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Percent-encode a string for use as a URL query parameter value.
|
||||
/// Currently identical to `url_encode_path`.
|
||||
fn url_encode_query(s: &str) -> String {
|
||||
url_encode_path(s)
|
||||
}
|
||||
|
||||
/// Validate that a path segment doesn't contain dangerous characters.
|
||||
/// Returns true if the segment is safe to use.
|
||||
fn validate_path_segment(s: &str) -> bool {
|
||||
!s.is_empty() && !s.contains('/') && !s.contains("..") && !s.contains('?') && !s.contains('#')
|
||||
}
|
||||
|
||||
struct GitHubTool;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action")]
|
||||
enum GitHubAction {
|
||||
#[serde(rename = "get_repo")]
|
||||
GetRepo { owner: String, repo: String },
|
||||
#[serde(rename = "list_issues")]
|
||||
ListIssues {
|
||||
owner: String,
|
||||
repo: String,
|
||||
state: Option<String>,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "create_issue")]
|
||||
CreateIssue {
|
||||
owner: String,
|
||||
repo: String,
|
||||
title: String,
|
||||
body: Option<String>,
|
||||
labels: Option<Vec<String>>,
|
||||
},
|
||||
#[serde(rename = "get_issue")]
|
||||
GetIssue {
|
||||
owner: String,
|
||||
repo: String,
|
||||
issue_number: u32,
|
||||
},
|
||||
#[serde(rename = "list_pull_requests")]
|
||||
ListPullRequests {
|
||||
owner: String,
|
||||
repo: String,
|
||||
state: Option<String>,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "get_pull_request")]
|
||||
GetPullRequest {
|
||||
owner: String,
|
||||
repo: String,
|
||||
pr_number: u32,
|
||||
},
|
||||
#[serde(rename = "get_pull_request_files")]
|
||||
GetPullRequestFiles {
|
||||
owner: String,
|
||||
repo: String,
|
||||
pr_number: u32,
|
||||
},
|
||||
#[serde(rename = "create_pr_review")]
|
||||
CreatePrReview {
|
||||
owner: String,
|
||||
repo: String,
|
||||
pr_number: u32,
|
||||
body: String,
|
||||
event: String,
|
||||
},
|
||||
#[serde(rename = "list_repos")]
|
||||
ListRepos {
|
||||
username: String,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
#[serde(rename = "get_file_content")]
|
||||
GetFileContent {
|
||||
owner: String,
|
||||
repo: String,
|
||||
path: String,
|
||||
r#ref: Option<String>,
|
||||
},
|
||||
#[serde(rename = "trigger_workflow")]
|
||||
TriggerWorkflow {
|
||||
owner: String,
|
||||
repo: String,
|
||||
workflow_id: String,
|
||||
r#ref: String,
|
||||
inputs: Option<serde_json::Value>,
|
||||
},
|
||||
#[serde(rename = "get_workflow_runs")]
|
||||
GetWorkflowRuns {
|
||||
owner: String,
|
||||
repo: String,
|
||||
workflow_id: Option<String>,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
impl exports::near::agent::tool::Guest for GitHubTool {
|
||||
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||
match execute_inner(&req.params) {
|
||||
Ok(result) => exports::near::agent::tool::Response {
|
||||
output: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => exports::near::agent::tool::Response {
|
||||
output: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
SCHEMA.to_string()
|
||||
}
|
||||
|
||||
fn description() -> String {
|
||||
"GitHub integration for managing repositories, issues, pull requests, \
|
||||
and workflows. Supports reading repo info, listing/creating issues, \
|
||||
reviewing PRs, and triggering GitHub Actions. \
|
||||
Authentication is handled via the 'github_token' secret injected by the host."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_inner(params: &str) -> Result<String, String> {
|
||||
let action: GitHubAction =
|
||||
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {e}"))?;
|
||||
|
||||
// Pre-flight check: ensure token exists in secret store.
|
||||
// We don't use the returned value because the host injects it into the request.
|
||||
let _ = get_github_token()?;
|
||||
|
||||
match action {
|
||||
GitHubAction::GetRepo { owner, repo } => get_repo(&owner, &repo),
|
||||
GitHubAction::ListIssues {
|
||||
owner,
|
||||
repo,
|
||||
state,
|
||||
page,
|
||||
limit,
|
||||
} => list_issues(&owner, &repo, state.as_deref(), page, limit),
|
||||
GitHubAction::CreateIssue {
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
labels,
|
||||
} => create_issue(&owner, &repo, &title, body.as_deref(), labels),
|
||||
GitHubAction::GetIssue {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
} => get_issue(&owner, &repo, issue_number),
|
||||
GitHubAction::ListPullRequests {
|
||||
owner,
|
||||
repo,
|
||||
state,
|
||||
page,
|
||||
limit,
|
||||
} => list_pull_requests(&owner, &repo, state.as_deref(), page, limit),
|
||||
GitHubAction::GetPullRequest {
|
||||
owner,
|
||||
repo,
|
||||
pr_number,
|
||||
} => get_pull_request(&owner, &repo, pr_number),
|
||||
GitHubAction::GetPullRequestFiles {
|
||||
owner,
|
||||
repo,
|
||||
pr_number,
|
||||
} => get_pull_request_files(&owner, &repo, pr_number),
|
||||
GitHubAction::CreatePrReview {
|
||||
owner,
|
||||
repo,
|
||||
pr_number,
|
||||
body,
|
||||
event,
|
||||
} => create_pr_review(&owner, &repo, pr_number, &body, &event),
|
||||
GitHubAction::ListRepos {
|
||||
username,
|
||||
page,
|
||||
limit,
|
||||
} => list_repos(&username, page, limit),
|
||||
GitHubAction::GetFileContent {
|
||||
owner,
|
||||
repo,
|
||||
path,
|
||||
r#ref,
|
||||
} => get_file_content(&owner, &repo, &path, r#ref.as_deref()),
|
||||
GitHubAction::TriggerWorkflow {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
r#ref,
|
||||
inputs,
|
||||
} => trigger_workflow(&owner, &repo, &workflow_id, &r#ref, inputs),
|
||||
GitHubAction::GetWorkflowRuns {
|
||||
owner,
|
||||
repo,
|
||||
workflow_id,
|
||||
page,
|
||||
limit,
|
||||
} => get_workflow_runs(&owner, &repo, workflow_id.as_deref(), page, limit),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_github_token() -> Result<String, String> {
|
||||
if near::agent::host::secret_exists("github_token") {
|
||||
// Return dummy value since we only need to verify existence.
|
||||
// The actual token is injected by the host.
|
||||
return Ok("present".to_string());
|
||||
}
|
||||
|
||||
Err("GitHub token not found in secret store. Set it with: ironclaw secret set github_token <token>. \
|
||||
Token needs 'repo', 'workflow', and 'read:org' scopes.".into())
|
||||
}
|
||||
|
||||
fn github_request(method: &str, path: &str, body: Option<String>) -> Result<String, String> {
|
||||
let url = format!("https://api.github.com{}", path);
|
||||
|
||||
// Authorization header (Bearer <token>) is injected automatically by the host
|
||||
// via the `http-wrapper` proxy based on the `github_token` secret.
|
||||
let headers = serde_json::json!({
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "IronClaw-GitHub-Tool"
|
||||
});
|
||||
|
||||
let body_bytes = body.map(|b| b.into_bytes());
|
||||
|
||||
// Simple retry logic for transient errors (max 3 attempts)
|
||||
let max_retries = 3;
|
||||
let mut attempt = 0;
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
let response = near::agent::host::http_request(
|
||||
method,
|
||||
&url,
|
||||
&headers.to_string(),
|
||||
body_bytes.as_deref(),
|
||||
None,
|
||||
);
|
||||
|
||||
match response {
|
||||
Ok(resp) => {
|
||||
// Log warning if rate limit is low
|
||||
if let Ok(headers_json) =
|
||||
serde_json::from_str::<serde_json::Value>(&resp.headers_json)
|
||||
{
|
||||
// Header keys are often lowercase in http libs, check case-insensitively if needed,
|
||||
// but usually standard is lowercase/case-insensitive. Let's try lowercase.
|
||||
if let Some(remaining) = headers_json
|
||||
.get("x-ratelimit-remaining")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
if let Ok(count) = remaining.parse::<u32>() {
|
||||
if count < 10 {
|
||||
near::agent::host::log(
|
||||
near::agent::host::LogLevel::Warn,
|
||||
&format!("GitHub API rate limit low: {} remaining", count),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.status >= 200 && resp.status < 300 {
|
||||
return String::from_utf8(resp.body)
|
||||
.map_err(|e| format!("Invalid UTF-8: {}", e));
|
||||
} else if attempt < max_retries && (resp.status == 429 || resp.status >= 500) {
|
||||
near::agent::host::log(
|
||||
near::agent::host::LogLevel::Warn,
|
||||
&format!(
|
||||
"GitHub API error {} (attempt {}/{}). Retrying...",
|
||||
resp.status, attempt, max_retries
|
||||
),
|
||||
);
|
||||
// Minimal backoff simulation since we can't block easily in WASM without consuming generic budget?
|
||||
// actually std::thread::sleep works in WASMtime if configured, but here we might just spin.
|
||||
// ideally host exposes sleep. For now just retry immediately or rely on host timeout logic?
|
||||
// Let's assume immediate retry for now as simple strategy.
|
||||
continue;
|
||||
} else {
|
||||
let body_str = String::from_utf8_lossy(&resp.body);
|
||||
return Err(format!("GitHub API error {}: {}", resp.status, body_str));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if attempt < max_retries {
|
||||
near::agent::host::log(
|
||||
near::agent::host::LogLevel::Warn,
|
||||
&format!(
|
||||
"HTTP request failed: {} (attempt {}/{}). Retrying...",
|
||||
e, attempt, max_retries
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Err(format!(
|
||||
"HTTP request failed after {} attempts: {}",
|
||||
max_retries, e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === API Functions ===
|
||||
|
||||
fn get_repo(owner: &str, repo: &str) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
github_request(
|
||||
"GET",
|
||||
&format!("/repos/{}/{}", encoded_owner, encoded_repo),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_issues(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
state: Option<&str>,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let state = state.unwrap_or("open");
|
||||
let limit = limit.unwrap_or(30).min(100); // Cap at 100
|
||||
let encoded_state = url_encode_query(state);
|
||||
|
||||
let mut path = format!(
|
||||
"/repos/{}/{}/issues?state={}&per_page={}",
|
||||
encoded_owner, encoded_repo, encoded_state, limit
|
||||
);
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("&page={}", p));
|
||||
}
|
||||
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn create_issue(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
title: &str,
|
||||
body: Option<&str>,
|
||||
labels: Option<Vec<String>>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
validate_input_length(title, "title")?;
|
||||
if let Some(b) = body {
|
||||
validate_input_length(b, "body")?;
|
||||
}
|
||||
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let path = format!("/repos/{}/{}/issues", encoded_owner, encoded_repo);
|
||||
let mut req_body = serde_json::json!({
|
||||
"title": title,
|
||||
});
|
||||
if let Some(body) = body {
|
||||
req_body["body"] = serde_json::json!(body);
|
||||
}
|
||||
if let Some(labels) = labels {
|
||||
req_body["labels"] = serde_json::json!(labels);
|
||||
}
|
||||
github_request("POST", &path, Some(req_body.to_string()))
|
||||
}
|
||||
|
||||
fn get_issue(owner: &str, repo: &str, issue_number: u32) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
github_request(
|
||||
"GET",
|
||||
&format!(
|
||||
"/repos/{}/{}/issues/{}",
|
||||
encoded_owner, encoded_repo, issue_number
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_pull_requests(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
state: Option<&str>,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let state = state.unwrap_or("open");
|
||||
let limit = limit.unwrap_or(30).min(100); // Cap at 100
|
||||
let encoded_state = url_encode_query(state);
|
||||
|
||||
let mut path = format!(
|
||||
"/repos/{}/{}/pulls?state={}&per_page={}",
|
||||
encoded_owner, encoded_repo, encoded_state, limit
|
||||
);
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("&page={}", p));
|
||||
}
|
||||
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn get_pull_request(owner: &str, repo: &str, pr_number: u32) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
github_request(
|
||||
"GET",
|
||||
&format!(
|
||||
"/repos/{}/{}/pulls/{}",
|
||||
encoded_owner, encoded_repo, pr_number
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn get_pull_request_files(owner: &str, repo: &str, pr_number: u32) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
github_request(
|
||||
"GET",
|
||||
&format!(
|
||||
"/repos/{}/{}/pulls/{}/files",
|
||||
encoded_owner, encoded_repo, pr_number
|
||||
),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn create_pr_review(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pr_number: u32,
|
||||
body: &str,
|
||||
event: &str,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
validate_input_length(body, "body")?;
|
||||
|
||||
let valid_events = ["APPROVE", "REQUEST_CHANGES", "COMMENT"];
|
||||
if !valid_events.contains(&event) {
|
||||
return Err(format!(
|
||||
"Invalid event: '{}'. Must be one of: {}",
|
||||
event,
|
||||
valid_events.join(", ")
|
||||
));
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let path = format!(
|
||||
"/repos/{}/{}/pulls/{}/reviews",
|
||||
encoded_owner, encoded_repo, pr_number
|
||||
);
|
||||
let req_body = serde_json::json!({
|
||||
"body": body,
|
||||
"event": event,
|
||||
});
|
||||
github_request("POST", &path, Some(req_body.to_string()))
|
||||
}
|
||||
|
||||
fn list_repos(username: &str, page: Option<u32>, limit: Option<u32>) -> Result<String, String> {
|
||||
if !validate_path_segment(username) {
|
||||
return Err("Invalid username".into());
|
||||
}
|
||||
let encoded_username = url_encode_path(username);
|
||||
let limit = limit.unwrap_or(30).min(100); // Cap at 100
|
||||
let mut path = format!("/users/{}/repos?per_page={}", encoded_username, limit);
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("&page={}", p));
|
||||
}
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
fn get_file_content(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
path: &str,
|
||||
r#ref: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
// Validate path segments - reject path traversal attempts and empty segments
|
||||
for segment in path.split('/') {
|
||||
if segment == ".." {
|
||||
return Err("Invalid path: path traversal not allowed".into());
|
||||
}
|
||||
if segment.is_empty() {
|
||||
return Err("Invalid path: empty segment not allowed".into());
|
||||
}
|
||||
}
|
||||
// Validate ref if provided
|
||||
if let Some(r#ref) = r#ref {
|
||||
if r#ref.contains("..") || r#ref.contains(':') {
|
||||
return Err("Invalid ref: must be a valid branch, tag, or commit SHA".into());
|
||||
}
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
// Path can contain slashes, so we encode each segment separately
|
||||
let encoded_path = path
|
||||
.split('/')
|
||||
.map(url_encode_path)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
let url_path = if let Some(r#ref) = r#ref {
|
||||
let encoded_ref = url_encode_query(r#ref);
|
||||
format!(
|
||||
"/repos/{}/{}/contents/{}?ref={}",
|
||||
encoded_owner, encoded_repo, encoded_path, encoded_ref
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"/repos/{}/{}/contents/{}",
|
||||
encoded_owner, encoded_repo, encoded_path
|
||||
)
|
||||
};
|
||||
github_request("GET", &url_path, None)
|
||||
}
|
||||
|
||||
fn trigger_workflow(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
workflow_id: &str,
|
||||
r#ref: &str,
|
||||
inputs: Option<serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
// Validate inputs size if present
|
||||
if let Some(valid_inputs) = &inputs {
|
||||
let inputs_str = valid_inputs.to_string();
|
||||
validate_input_length(&inputs_str, "inputs")?;
|
||||
}
|
||||
|
||||
// Validate workflow_id - must be a safe filename
|
||||
if workflow_id.contains('/') || workflow_id.contains("..") || workflow_id.contains(':') {
|
||||
return Err("Invalid workflow_id: must be a filename or numeric ID".into());
|
||||
}
|
||||
// Validate ref - must be a valid git ref
|
||||
if r#ref.contains("..") || r#ref.contains(':') {
|
||||
return Err("Invalid ref: must be a valid branch, tag, or commit SHA".into());
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let encoded_workflow_id = url_encode_path(workflow_id);
|
||||
let path = format!(
|
||||
"/repos/{}/{}/actions/workflows/{}/dispatches",
|
||||
encoded_owner, encoded_repo, encoded_workflow_id
|
||||
);
|
||||
let mut req_body = serde_json::json!({
|
||||
"ref": r#ref,
|
||||
});
|
||||
if let Some(inputs) = inputs {
|
||||
req_body["inputs"] = inputs;
|
||||
}
|
||||
github_request("POST", &path, Some(req_body.to_string()))
|
||||
}
|
||||
|
||||
fn get_workflow_runs(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
workflow_id: Option<&str>,
|
||||
page: Option<u32>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<String, String> {
|
||||
if !validate_path_segment(owner) || !validate_path_segment(repo) {
|
||||
return Err("Invalid owner or repo name".into());
|
||||
}
|
||||
// Validate workflow_id if provided
|
||||
if let Some(wid) = workflow_id {
|
||||
if wid.contains('/') || wid.contains("..") || wid.contains(':') {
|
||||
return Err("Invalid workflow_id: must be a filename or numeric ID".into());
|
||||
}
|
||||
}
|
||||
let encoded_owner = url_encode_path(owner);
|
||||
let encoded_repo = url_encode_path(repo);
|
||||
let limit = limit.unwrap_or(30).min(100); // Cap at 100
|
||||
let mut path = if let Some(workflow_id) = workflow_id {
|
||||
let encoded_workflow_id = url_encode_path(workflow_id);
|
||||
format!(
|
||||
"/repos/{}/{}/actions/workflows/{}/runs?per_page={}",
|
||||
encoded_owner, encoded_repo, encoded_workflow_id, limit
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"/repos/{}/{}/actions/runs?per_page={}",
|
||||
encoded_owner, encoded_repo, limit
|
||||
)
|
||||
};
|
||||
if let Some(p) = page {
|
||||
path.push_str(&format!("&page={}", p));
|
||||
}
|
||||
github_request("GET", &path, None)
|
||||
}
|
||||
|
||||
const SCHEMA: &str = r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_repo" },
|
||||
"owner": { "type": "string", "description": "Repository owner (user or org)" },
|
||||
"repo": { "type": "string", "description": "Repository name" }
|
||||
},
|
||||
"required": ["action", "owner", "repo"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_issues" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"state": { "type": "string", "enum": ["open", "closed", "all"], "default": "open" },
|
||||
"limit": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["action", "owner", "repo"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_issue" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"body": { "type": "string" },
|
||||
"labels": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "title"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_issue" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"issue_number": { "type": "integer" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "issue_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_pull_requests" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"state": { "type": "string", "enum": ["open", "closed", "all"], "default": "open" },
|
||||
"limit": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["action", "owner", "repo"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_pull_request" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"pr_number": { "type": "integer" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "pr_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_pull_request_files" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"pr_number": { "type": "integer" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "pr_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_pr_review" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"pr_number": { "type": "integer" },
|
||||
"body": { "type": "string", "description": "Review comment" },
|
||||
"event": { "type": "string", "enum": ["APPROVE", "REQUEST_CHANGES", "COMMENT"] }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "pr_number", "body", "event"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_repos" },
|
||||
"username": { "type": "string" },
|
||||
"limit": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["action", "username"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_file_content" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"path": { "type": "string", "description": "File path in repo" },
|
||||
"ref": { "type": "string", "description": "Branch/commit (default: default branch)" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "path"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "trigger_workflow" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"workflow_id": { "type": "string", "description": "Workflow filename or ID" },
|
||||
"ref": { "type": "string", "description": "Branch to run on" },
|
||||
"inputs": { "type": "object" }
|
||||
},
|
||||
"required": ["action", "owner", "repo", "workflow_id", "ref"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_workflow_runs" },
|
||||
"owner": { "type": "string" },
|
||||
"repo": { "type": "string" },
|
||||
"workflow_id": { "type": "string" },
|
||||
"limit": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["action", "owner", "repo"]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
export!(GitHubTool);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_url_encode_path() {
|
||||
assert_eq!(url_encode_path("foo-bar_123.baz"), "foo-bar_123.baz");
|
||||
assert_eq!(url_encode_path("foo bar"), "foo%20bar");
|
||||
assert_eq!(url_encode_path("foo/bar"), "foo%2Fbar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_path_segment() {
|
||||
assert!(validate_path_segment("foo"));
|
||||
assert!(!validate_path_segment(""));
|
||||
assert!(!validate_path_segment("foo/bar"));
|
||||
assert!(!validate_path_segment(".."));
|
||||
// Empty segments are handled in get_file_content logic, not here
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_event_in_create_pr_review() {
|
||||
let valid = ["APPROVE", "REQUEST_CHANGES", "COMMENT"];
|
||||
// Ensure valid inputs are accepted
|
||||
for event in valid {
|
||||
assert!(valid.contains(&event));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_length_validation() {
|
||||
assert!(validate_input_length("short", "test").is_ok());
|
||||
|
||||
let long = "a".repeat(MAX_TEXT_LENGTH + 1);
|
||||
assert!(validate_input_length(&long, "test").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user