Compare commits

..
Author SHA1 Message Date
Illia PolosukhinandClaude Opus 4.6 1aca76b1a7 style: fix rustfmt formatting in bootstrap test
Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 00:22:18 -08:00
Illia PolosukhinandClaude Opus 4.6 414c1b28b9 fix: status command shows libSQL backend and skips keychain probe
The status command only checked DATABASE_URL (postgres), showing
"not configured" for libSQL users. Now detects the DATABASE_BACKEND
env var and reports libSQL path and Turso sync status.

Also remove the keychain probe from status. get_generic_password()
triggers macOS unlock+authorization dialogs which is terrible UX
for a read-only diagnostic command.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 00:13:33 -08:00
Illia PolosukhinandClaude Opus 4.6 d771f99f9e fix: persist DATABASE_BACKEND to ~/.ironclaw/.env for libSQL startup
The wizard saved database_backend only to the database, but
Config::from_env() needs it BEFORE connecting to any database (to
decide which backend to use). Without it, the backend defaults to
Postgres and then fails with "Missing required setting database_url".

Now save all database bootstrap vars (DATABASE_BACKEND, DATABASE_URL,
LIBSQL_PATH, LIBSQL_URL) to ~/.ironclaw/.env via save_bootstrap_env().

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 00:12:01 -08:00
Illia PolosukhinandClaude Opus 4.6 1facda4a75 fix: cache keychain key eagerly to avoid redundant macOS password dialogs
Replace has_master_key() with get_master_key() in step_security() and
immediately build SecretsCrypto from the result. This eliminates redundant
keychain accesses later in init_secrets_context(), each of which triggers
macOS system dialogs (keychain unlock + app authorization).

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-15 00:07:02 -08:00
Illia PolosukhinandClaude Opus 4.6 e8caab1a12 fix: OAuth callback listener binds IPv4 first to match redirect URLs
The listener was binding to [::1] (IPv6) first, but NEAR AI and other
OAuth flows redirect to http://127.0.0.1:9876/... (IPv4 explicit).
On macOS and most systems, [::1] and 127.0.0.1 are separate addresses,
so the browser's connection to 127.0.0.1 was refused when the listener
was on [::1]. Reversed the bind order: try 127.0.0.1 first, fall back
to [::1] if IPv4 is unavailable.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 22:16:40 -08:00
Illia PolosukhinandClaude Opus 4.6 fac91aec3a fix: address latest PR review comments (SecretString, empty env, docs, embeddings)
- Change wizard llm_api_key from String to SecretString to prevent
  accidental logging of API keys
- Fix inject_llm_keys_from_secrets skipping when env var is set but
  empty, matching optional_env's treatment of empty as unset
- Fix inverted doc comment on INJECTED_VARS (env checked first, overlay
  is the fallback, not the other way around)
- Update stale "env vars" comments in main.rs to reflect overlay pattern
- Fix step_embeddings not seeing cached OpenAI key from wizard session

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 17:58:12 -08:00
Illia PolosukhinandClaude Opus 4.6 0aae66c9dc fix: address remaining PR review comments (clippy, TODO, secrets backend ordering)
- Fix empty line after doc comment (clippy: empty_line_after_doc_comments)
- Collapse nested if in optional_env overlay check (clippy: collapsible_if)
- Remove dangling TODO(#XX) placeholder issue ref in channels.rs
- Fix init_secrets_context to respect selected database_backend when both
  postgres and libsql features are compiled, preventing wrong-backend
  secrets storage when DATABASE_URL is set but libsql was chosen

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 17:19:30 -08:00
Illia PolosukhinandClaude Opus 4.6 aa808ca94e fix: remove unsafe set_var, use thread-safe overlay for injected secrets
Address PR #92 review comments:
- Replace all 5 unsafe `std::env::set_var()` calls with safe alternatives
- Add INJECTED_VARS OnceLock<HashMap> overlay in config.rs, checked by
  optional_env() before falling back to std::env::var()
- Cache wizard API key in SetupWizard.llm_api_key field instead of env
- Pass explicit key param to fetch_anthropic_models/fetch_openai_models
- Persist env-provided API keys to secrets store during onboarding

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 14:14:22 -08:00
Illia PolosukhinandClaude Opus 4.6 c7e6833d14 Merge remote-tracking branch 'origin/main' into fix/setup-audit-fixes
Resolve conflicts between main's simplified config (no bootstrap param,
env-only DatabaseConfig) and our branch's typed ChannelSetupError.

- config.rs: take main's simpler resolve() signatures (no bootstrap)
- main.rs: remove dead check_onboard_needed block and CACHED_KEYCHAIN_KEY ref
- channels.rs: keep ChannelSetupError types, restore settings params from main
- wizard.rs: pass &self.settings to setup_telegram, use ? with From impl
- settings.rs: fix test_llm_backend_round_trip (use std::fs::write, tempfile::tempdir)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 14:05:54 -08:00
Illia PolosukhinandClaude Opus 4.6 1885d61d46 fix: replace unreachable!() with error return in setup wizard
The provider match in step_inference_provider was guarded by
is_known but used unreachable!() as the catch-all. If a new
provider is added to the is_known check without a corresponding
match arm, this would panic at runtime. Return a typed error
instead.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 13:51:37 -08:00
Illia PolosukhinandClaude Opus 4.6 da47903108 fix: harden setup module error handling and secret safety
- Introduce ChannelSetupError typed enum replacing raw String errors
  across all channel setup functions (setup_telegram, setup_http,
  setup_tunnel, setup_wasm_channel, validate_telegram_token)
- Add From<ChannelSetupError> for SetupError to simplify call sites
- Convert setup_telegram retry from recursion to loop (unbounded stack)
- Stop printing HTTP webhook secret plaintext to terminal
- Use secret_input() for Turso auth token (was visible input())
- Replace dirs::home_dir().unwrap_or_default() with proper error
- Fix UTF-8 panic in model name truncation (byte-index to chars-based)
- Log warning in secret_exists() instead of silently swallowing errors
- Deduplicate generate_webhook_secret() to delegate to shared helper

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 13:43:25 -08:00
Illia PolosukhinandClaude Opus 4.6 85196cd527 fix: address second-round PR review feedback
- Validate custom model ID is non-empty (loop until valid input)
- Warn on unknown DATABASE_BACKEND env var before defaulting to Postgres
- Force re-selection when llm_backend contains unknown provider value
- Use ok_or_else for proper String error type in google-sheets

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 13:20:06 -08:00
Illia PolosukhinandClaude Opus 4.6 a0e01f04d3 fix: address critical/high audit findings across WASM sub-crates
- Telegram: remove .unwrap() panic on workspace_read (owner_id check)
- WhatsApp: use configured api_version instead of hardcoded v18.0
- WhatsApp: log config parse errors before falling back to defaults
- Slack: log serialization errors in emit_message and json_response
- Google Docs: safe array access for batch update replies
- Google Sheets: safe array access for add_sheet replies
- Google Calendar: fix doc comment secret name mismatch
- Gmail: avoid unnecessary String allocation in UNREAD check

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 12:54:20 -08:00
Illia PolosukhinandClaude Opus 4.6 e982699e09 fix: address PR review feedback (set_var safety, parse warnings, db_map efficiency)
1. Replace unsafe set_var keychain caching with OnceLock<String> in
   SecretsConfig::resolve(). Eliminates the env var write from main.rs
   entirely, using a process-wide OnceLock cache instead.

2. Log tracing::warn when database_backend or llm_backend settings
   fail to parse, instead of silently falling back to defaults.

3. Remove O(K*S) get() pre-check in from_db_map(). Instead, let set()
   run and match on "Path not found" errors to skip unknown keys,
   avoiding full Settings serialization per key.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-14 01:09:20 -08:00
Illia Polosukhin 5e73dbbdc8 Merge remote-tracking branch 'origin/main' into feat/onboarding-libsql-selection 2026-02-14 00:55:48 -08:00
Illia PolosukhinandClaude Opus 4.6 92863bf860 fix: resolve libSQL onboarding crash, keychain double-prompt, and setup audit findings
Three bugs fixed:

1. libSQL onboarding crash ("Missing required setting 'database_url'"):
   DatabaseConfig::resolve() only checked DATABASE_BACKEND env var, falling
   back to Postgres default. Now reads settings.database_backend, plus
   settings.libsql_path and settings.libsql_url as fallbacks.

2. OS keychain prompts twice during startup: Config::from_env() and
   Config::from_db() both called get_master_key(). Now caches the key in
   SECRETS_MASTER_KEY env var after first read so from_db() skips keychain.

3. "Path not found: nearai.session" warning: from_db_map() tried to apply
   app-specific DB keys (nearai.session_token) to the Settings struct.
   Now skips keys that don't map to known Settings fields. Also fixed
   bootstrap migration key mismatch (nearai.session -> nearai.session_token).

Setup module audit fixes (14 findings):
- Replace unreachable!() with proper error in provider match
- Extract setup_api_key_provider() to deduplicate setup_anthropic/setup_openai
- Add SAFETY comments to all unsafe std::env::set_var blocks
- Fix .unwrap() calls with proper error handling
- Remove incorrect #[allow(dead_code)] on used TelegramUpdate::update_id
- Log warnings instead of silently discarding HTTP errors in Telegram binding
- Guard select_many against empty options, fix mask_api_key for non-ASCII
- Update stale doc comment in mod.rs, rename misleading variable
- Add 7 new tests (model fetcher fallbacks, channel discovery, secret gen)

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-13 21:46:19 -08:00
Illia PolosukhinandClaude Opus 4.6 46c1daca5e feat: add interactive database backend selection during onboarding
Previously the onboarding wizard silently defaulted to PostgreSQL because
libsql wasn't in the default feature set. Now both backends ship by default
and the wizard presents a selection prompt when both are available.

DATABASE_BACKEND env var still bypasses the prompt for headless/CI use.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-13 18:27:53 -08:00
60 changed files with 1051 additions and 5486 deletions
-97
View File
@@ -1,97 +0,0 @@
---
description: Fetch a GitHub issue, create a branch, research the codebase, plan the fix, implement with tests, and commit
disable-model-invocation: true
allowed-tools: Bash(gh issue view:*), Bash(gh repo view:*), Bash(git fetch:*), Bash(git checkout:*), Bash(git status:*), Bash(git branch:*), Bash(git add:*), Bash(git commit:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Read, Edit, Write, Grep, Glob
argument-hint: "<issue-number or github-issue-url>"
---
# Fix GitHub Issue
## Step 1: Resolve the issue
Parse `$ARGUMENTS` to extract the issue number:
- If it's a URL like `https://github.com/owner/repo/issues/42`, extract `42`.
- If it's a bare number, use it directly.
- If empty, stop and ask the user for an issue number.
Fetch the issue:
```
gh issue view {number} --json title,body,labels,assignees,comments,state
```
If the issue is closed, warn the user and ask if they still want to proceed.
## Step 2: Create a branch
Create a fresh branch off the latest main:
1. Fetch latest: `git fetch origin`
2. Detect default branch: `gh repo view --json defaultBranchRef --jq .defaultBranchRef.name`
3. Create and switch to a new branch: `git checkout -b fix/{number}-{short-slug} origin/{default-branch}`
- `{short-slug}` is 3-5 words from the issue title, lowercase, hyphenated (e.g. `fix/42-idor-workspace-check`)
If the working tree has uncommitted changes, warn the user and stop. Do not stash or discard their work.
## Step 3: Understand the issue
Summarize the issue in 2-3 sentences. Identify:
- **What's broken or missing** (the symptom or feature request)
- **Acceptance criteria** (what "done" looks like, from the issue body or comments)
- **Constraints** (mentioned technologies, backward compatibility, performance requirements)
If the issue is unclear or ambiguous, list the open questions. These will be addressed during planning.
## Step 4: Research the codebase
Before planning, gather context:
1. **Find relevant code** - Search for files, functions, types, and patterns mentioned in the issue. Read them in full.
2. **Trace the flow** - If the issue is about a specific behavior, trace the code path from the entry point (route handler, CLI command, etc.) through to the relevant logic.
3. **Check existing tests** - Find tests related to the affected code. Understand what's already covered.
4. **Check for prior art** - Look for similar patterns in the codebase that solve analogous problems. Prefer consistency with existing patterns.
## Step 5: Enter planning mode
Enter planning mode to design the implementation. The plan MUST cover:
1. **Root cause** (for bugs) or **design approach** (for features)
2. **Files to modify** with specific descriptions of what changes in each
3. **New files** (if any) with justification for why they're needed
4. **Tests to add** - every code path introduced or changed needs a test:
- Happy path (expected input produces expected output)
- Error paths (invalid input, missing data, permission denied)
- Edge cases (empty collections, boundary values, concurrent access)
5. **IronClaw-specific concerns**:
- If the change touches persistence, both database backends must be updated (`postgres.rs` and `libsql_backend.rs`)
- New `Database` trait methods need implementations in both backends
- No `.unwrap()` or `.expect()` in production code
- Use `crate::` imports, not `super::`
- Error types via `thiserror` in `error.rs`
6. **Migration or compatibility concerns** (if any)
Follow the project's CLAUDE.md guidance for architecture decisions.
Wait for user approval before implementing.
## Step 6: Implement
After the plan is approved:
1. Implement each change from the plan.
2. Write all planned tests.
3. Run IronClaw's full quality gate:
- `cargo fmt`
- `cargo clippy --all --benches --tests --examples --all-features` (zero warnings)
- `cargo test --lib` (all tests pass)
4. If any check fails, fix it before proceeding.
Note: Integration tests (`--test workspace_integration`) require PostgreSQL and are expected to fail locally. Only `--lib` test failures are blocking.
## Step 7: Commit and summarize
1. Commit with a descriptive message referencing the issue (e.g. `fix: prevent IDOR in function call outputs (#42)`).
2. Summarize what was done:
- Files changed with line references
- Tests added and what they cover
- Any follow-up work or open questions
-81
View File
@@ -1,81 +0,0 @@
---
description: Respond to PR review comments — triage, plan fixes, implement after confirmation, push, and reply to reviewers
disable-model-invocation: true
allowed-tools: Bash(gh pr list:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh repo view:*), Bash(git branch:*), Bash(git status:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Read, Edit, Write, Grep, Glob
argument-hint: "[pr-number (optional, auto-detects from branch)]"
---
# Review and Address PR Comments
## Step 1: Find the PR
If `$ARGUMENTS` is provided, use that as the PR number. Otherwise, detect the PR for the current branch:
```
gh pr list --head $(git branch --show-current) --json number,title,url --jq '.[0]'
```
If no PR is found, tell the user and stop.
## Step 2: Fetch all review comments
Resolve the repo owner and name:
```
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
```
Fetch the full set of review comments (not issue-level comments):
```
gh api --paginate repos/{owner}/{repo}/pulls/{number}/comments
```
Also fetch the review summaries:
```
gh api --paginate repos/{owner}/{repo}/pulls/{number}/reviews
```
Deduplicate comments that appear multiple times (bots sometimes post the same finding under different IDs). Group by the actual issue being raised, not by comment ID.
## Step 3: Triage and plan
For each unique issue raised in the comments:
1. **Check if already addressed** - Read the current code at the referenced location. If a prior commit already fixed it, note it as "already resolved".
2. **Assess validity** - Determine if the comment identifies a real problem or is a false positive. Be honest about false positives but explain why.
3. **Classify severity** - Critical (security/data loss), High (bugs/broken behavior), Medium (correctness/robustness), Low (style/naming/nits).
4. **Plan the fix** - For each valid unresolved issue, describe the specific code change needed.
Present the plan as a table to the user:
| # | Issue | File:Line | Severity | Status | Planned Fix |
|---|-------|-----------|----------|--------|-------------|
Wait for user confirmation before proceeding to implementation.
## Step 4: Implement fixes
After user confirms:
1. Implement each fix in the plan.
2. Run IronClaw's quality gate to verify nothing breaks:
- `cargo fmt`
- `cargo clippy --all --benches --tests --examples --all-features`
- `cargo test --lib`
3. Commit with a descriptive message referencing the PR review.
4. Push to the branch.
## Step 5: Reply to comments
For each comment addressed, reply on the PR with a short message stating what was fixed and the commit SHA. For false positives or already-resolved items, reply explaining why no change was needed.
## Rules
- Never guess at code you haven't read. Always read the referenced file and line before assessing a comment.
- Group duplicate comments (same issue reported by multiple bots) and reply to all of them.
- Do not make changes beyond what the review comments ask for. Stay focused.
- If a comment suggests a change you disagree with, present your reasoning to the user during the planning phase rather than silently ignoring it.
- Follow IronClaw conventions: no `.unwrap()` in production code, use `crate::` imports, `thiserror` errors.
- If changes touch persistence, verify both database backends are updated.
-245
View File
@@ -1,245 +0,0 @@
---
description: Deep audit of the IronClaw crate for vulnerabilities, bugs, unfinished work, inconsistencies, and oversights
disable-model-invocation: true
allowed-tools: Bash(cargo fmt:*), Bash(cargo clippy:*), Bash(cargo test:*), Bash(cargo audit:*), Bash(git diff:*), Bash(git log:*), Bash(git show:*), Bash(wc:*), Read, Grep, Glob, Task
argument-hint: "[path/to/crate]"
---
# Rust Crate Audit
You are performing a thorough audit of a Rust crate. Your goal is to find every vulnerability, bug, unfinished piece of work, inconsistency, and oversight before it ships. Leave no stone unturned.
## Step 1: Locate the crate
Parse `$ARGUMENTS`:
- If a path is provided, use it as the crate root.
- If empty, use the current working directory.
Verify it's a valid Rust crate by checking for `Cargo.toml`. If not found, stop and ask the user.
## Step 2: Understand the crate
Read `Cargo.toml` to understand:
- Crate name, version, edition
- Dependencies (look for outdated, unmaintained, or suspicious crates)
- Feature flags and their implications
- Build scripts (`build.rs`) if any
Read `CLAUDE.md`, `README.md`, or top-level documentation if present to understand intent and architecture.
Read `src/lib.rs` or `src/main.rs` to get the module tree. Then read each module's `mod.rs` or top-level file to build a mental map of the crate's structure before diving into details.
Read all Rust files (`src/*.rs`) to make sure everything is in context when you are reasoning.
## Step 3: Run the compiler's checks
Run these commands and capture output. Do NOT fix anything, just collect findings:
```
cargo fmt --check 2>&1
```
```
cargo clippy --all --benches --tests --examples --all-features -- -W clippy::all -W clippy::pedantic -W clippy::nursery 2>&1
```
```
cargo test --lib 2>&1
```
If any of these fail, record the failures as findings. If `cargo test` has ignored tests, note which ones and why.
Note: Integration tests (`--test workspace_integration`) require a PostgreSQL database and are expected to fail locally. Only report `--lib` test failures as blocking.
## Step 4: Scan for unfinished work
Search the entire `src/` tree for:
```
todo!
unimplemented!
fixme
FIXME
TODO
HACK
XXX
SAFETY:
stub
placeholder
temporary
```
For each match:
- Is it in production code or test code?
- Is it a genuine incomplete feature or a deliberate placeholder?
- Is there a tracking issue referenced?
- Could this panic at runtime?
Any `todo!()` or `unimplemented!()` in non-test code is **High severity** (runtime panic).
## Step 5: Audit for vulnerabilities and unsafe code
### 5a. Unsafe code
Search for all `unsafe` blocks. For each one:
- Is the safety invariant documented with a `// SAFETY:` comment?
- Is the invariant actually upheld by the surrounding code?
- Could the unsafe block be replaced with a safe alternative?
- Are there any pointer dereferences, transmutes, or FFI calls?
### 5b. Unwrap and panic paths
Search for `.unwrap()`, `.expect(`, `panic!`, `unreachable!` in non-test code. For each:
- Can this actually panic in production?
- Is there a code path that reaches this with None/Err?
- Should it be replaced with proper error handling (`?`, `.ok()`, `.unwrap_or_default()`)?
IronClaw convention: `.unwrap()` and `.expect()` are banned in production code. Any occurrence outside `#[cfg(test)]` blocks is a **High severity** finding.
### 5c. SQL and injection vectors
Search for string formatting used in SQL queries, shell commands, or HTML:
- `format!` used near `.execute(`, `.query(`, `Command::new(`
- String interpolation in query construction vs parameterized queries
- User input flowing into file paths (`Path::new`, `std::fs::`)
IronClaw has two database backends (PostgreSQL and libSQL). Check both for injection vectors.
### 5d. Cryptographic issues
If the crate uses crypto:
- Are comparisons constant-time? (look for `==` on secrets/hashes vs `subtle::ConstantTimeEq`)
- Is randomness from `OsRng` / `thread_rng` and not a fixed seed?
- Are keys/secrets zeroized after use? (`secrecy`, `zeroize` crates)
- Are deprecated algorithms used? (MD5, SHA1 for security, RC4, DES)
### 5e. Resource exhaustion
- Are there unbounded allocations? (`Vec` growing from user input without limits)
- Are there unbounded loops? (retry loops without max attempts)
- Are file reads bounded? (`std::fs::read_to_string` on user-provided paths)
- Are timeouts set on all network operations?
- Are there connection/resource leaks? (opened but never closed, missing `Drop`)
### 5f. Error handling
- Are errors swallowed silently? (`let _ = ...`, `.ok()` discarding errors that matter)
- Do error types carry enough context to debug in production?
- Are there error type mismatches? (returning generic `anyhow::Error` where a typed error would prevent confusion)
- Is `thiserror` used consistently for error types (IronClaw convention)?
## Step 6: Check for inconsistencies
### 6a. Naming conventions
- Are types, functions, modules named consistently? (e.g., mixing `get_` and `fetch_`, `create_` and `new_`)
- Do similar operations follow the same patterns?
### 6b. Duplicate or near-duplicate code
Look for:
- Functions that do nearly the same thing with minor variations (candidates for generics or shared helpers)
- Repeated error mapping patterns that should be extracted
- Copy-pasted SQL queries or string templates with slight differences
- Identical struct definitions or conversion logic in different modules
### 6c. API consistency
- Do similar functions take arguments in the same order?
- Are return types consistent? (e.g., some functions return `Option<T>`, similar ones return `Result<T, E>`)
- Are visibility modifiers consistent? (`pub` where it should be `pub(crate)`, or vice versa)
### 6d. Dead code and unused items
- Are there functions, structs, or modules that nothing references?
- Are there `#[allow(dead_code)]` annotations that should be investigated?
- Are there feature-gated items where the feature is never enabled?
### 6e. Import style
IronClaw convention: use `crate::` imports, not `super::`. Flag any `super::` imports in non-test code.
## Step 7: Inspect for change oversights
### 7a. Partial refactors
- Are there old patterns coexisting with new patterns?
- Are there renamed types/functions where some call sites still use the old name via a compatibility alias?
- Are there comments referencing behavior that no longer exists?
### 7b. Trait implementation gaps
- If a trait is defined, do all intended types implement it?
- Are there `impl` blocks that look incomplete?
- Are `Default` implementations sensible?
IronClaw key traits: `Database` (~60 methods), `Channel`, `Tool`, `LlmProvider`, `SuccessEvaluator`, `EmbeddingProvider`. If any new methods were added to `Database`, verify both `postgres.rs` and `libsql_backend.rs` implement them.
### 7c. Test coverage gaps
- Are there public functions without any test?
- Are there error paths without tests?
- Are there recently-changed functions where the tests still assert old behavior?
### 7d. Documentation drift
- Do doc comments match actual function behavior?
- Are examples in doc comments still valid and compilable?
## Step 8: Dependency audit
Review `Cargo.toml` and `Cargo.lock`:
- Are there duplicate versions of the same crate in the lock file? (potential version conflicts)
- Are there dependencies with known security advisories? Run `cargo audit` to check (install with `cargo install cargo-audit` if not present).
- Are there heavy dependencies used for trivial functionality?
- Are dependency features minimal?
## Step 9: Present findings
Compile all findings into a structured report. Group by severity, then by category.
### Format
For each finding:
```
### [Severity] Category: One-line summary
**Location:** `file_path:line_number`
**Category:** Vulnerability | Bug | Unfinished | Inconsistency | Duplicate | Oversight | Style
**Description:**
Detailed explanation of the issue, why it matters, and how it could manifest.
**Suggested fix:**
Concrete suggestion with code if applicable.
```
### Severity levels
- **Critical**: Security vulnerability, data loss, or crash in production
- **High**: Bug that causes incorrect behavior, `todo!()`/`unimplemented!()` in prod code, or missing validation on trust boundaries
- **Medium**: Inconsistency, duplicate code, incomplete error handling, missing tests for important paths
- **Low**: Naming inconsistency, unnecessary complexity, documentation drift, minor dead code
- **Nit**: Style preference, optional improvement
### Summary table
End with a summary table:
| # | Severity | Category | File:Line | Finding |
|---|----------|----------|-----------|---------|
And a final tally: X Critical, Y High, Z Medium, W Low, V Nit.
## Rules
- Read every file before reporting on it. Never guess about code you haven't seen.
- Be specific. "This might have issues" is worthless. "Line 42 calls `.unwrap()` on a `Result` that returns `Err` when the DB connection is dropped" is useful.
- Distinguish certainty levels: "this IS a bug" vs "this COULD be a bug if X".
- Don't invent problems to look thorough. If the code is solid, say so.
- Focus on substance over style. Don't flag formatting unless it causes real confusion.
- Respect existing project conventions (check CLAUDE.md). Don't flag patterns the project explicitly endorses.
- When in doubt about severity, round up.
- For large crates (>50 files), prioritize: core logic > public API > internal utilities > tests > examples.
- Use the Task tool to parallelize file reading across modules when the crate is large.
- Do NOT fix anything. This is a read-only audit. Report findings for the user to action.
-170
View File
@@ -1,170 +0,0 @@
---
description: Paranoid architect review of a PR — fetches diff, reads changed files, deep review across 6 lenses, posts findings as GitHub comments
disable-model-invocation: true
allowed-tools: Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh repo view:*), Bash(git diff:*), Bash(git log:*), Read, Grep, Glob
argument-hint: "<pr-number or github-pr-url>"
---
# Paranoid Architect Code Review
You are reviewing this PR as a paranoid architect. Your job is to find every bug, vulnerability, race condition, edge case, and undocumented assumption before it ships. Assume adversarial users, concurrent access, and Murphy's law.
## Step 1: Resolve the PR
Parse `$ARGUMENTS` to extract the PR number:
- If it's a URL like `https://github.com/owner/repo/pull/123`, extract `123`.
- If it's a bare number, use it directly.
- If empty, stop and ask the user for a PR number.
Fetch PR metadata (including head commit SHA for posting line comments later):
```
gh pr view {number} --json title,body,baseRefName,headRefName,headRefOid,files,additions,deletions
```
Save the `headRefOid` value, you'll need it as `commit_id` in Step 6.
## Step 2: Load the full diff
```
gh pr diff {number}
```
Also get the list of changed files:
```
gh pr diff {number} --name-only
```
## Step 3: Read every changed file in full
For each changed file, read the ENTIRE current file (not just the diff hunks). You need surrounding context to catch:
- Callers of modified functions that now behave differently
- Trait/interface contracts that the change may violate
- Invariants established elsewhere that the diff breaks
If the PR touches more than 20 files, still read all of them, but process in this priority order: service logic > routes/handlers > models/types > tests > docs. Batch reads in groups of ~20 if needed.
## Step 4: Deep review
Go through the changes with each of these lenses. For every finding, note the file, line range, severity, and a concrete description.
### IronClaw-specific checks
In addition to the general lenses below, check IronClaw conventions (see CLAUDE.md):
- No `.unwrap()` or `.expect()` in production code (tests are fine)
- Use `crate::` imports, not `super::`
- Error types use `thiserror` in `error.rs`
- If the change touches persistence, verify both database backends are updated (PostgreSQL in `postgres.rs` AND libSQL in `libsql_backend.rs`)
- New tools must implement the `Tool` trait correctly and be registered in `registry.rs`
- External tool output must pass through the safety layer
### 4a. Correctness and bugs
- Off-by-one errors, wrong comparison operators, inverted conditions
- Unreachable code, dead branches, impossible match arms
- Type confusion (mixing up IDs, using wrong enum variant)
- Incorrect error propagation (swallowed errors, wrong error type/status code)
- Broken invariants (e.g. uniqueness assumptions violated, ordering assumptions wrong)
- Concurrency issues (TOCTOU, missing locks, race conditions between check and use)
### 4b. Edge cases and failure handling
- What happens with empty input, None/null, zero-length collections?
- What happens when external services fail (DB down, HTTP timeout, malformed response)?
- What happens at integer boundaries (overflow, underflow, i64::MAX)?
- What happens with malformed or adversarial input (invalid UTF-8, huge payloads, deeply nested JSON)?
- Are all error paths tested? Does every `?` propagation make sense?
- Are partial failures handled (e.g. wrote to DB but failed to emit event)?
### 4c. Security (assume a malicious actor)
- **Authentication/Authorization bypass**: Can an unauthenticated user reach this? Can workspace A's user access workspace B's data? Are there IDOR vulnerabilities?
- **Injection**: SQL injection via string interpolation? Command injection? Log injection? Header injection?
- **Data leakage**: Are secrets, PII, or conversation content logged? Returned in error messages? Exposed in API responses?
- **Resource exhaustion / DoS**: Can an attacker send unbounded input? Trigger expensive operations without rate limits? Cause OOM via large allocations?
- **Financial abuse**: Can tokens/credits be consumed without being tracked? Can usage limits be bypassed?
- **Replay / race conditions**: Can the same request be replayed for double-spend? Can concurrent requests bypass limits?
- **Cryptographic issues**: Timing attacks on comparisons? Weak randomness? Missing HMAC verification?
### 4d. Test coverage
- Is every new public function/method tested?
- Are error paths tested (not just happy paths)?
- Are edge cases covered (empty input, boundary values, concurrent access)?
- Do existing tests still make sense with the new changes, or do they assert stale behavior?
- Are there integration/e2e tests for the full flow?
- If a test is missing, describe exactly what test should be written.
### 4e. Documentation and assumptions
- Are new assumptions documented in comments? (e.g. "this field is always non-empty because X")
- Are non-obvious algorithms or business rules explained?
- Are API contracts (request/response shapes, error codes, status codes) documented?
- Are there TODO/FIXME/HACK comments that should be tracked as issues?
### 4f. Architectural concerns
- Does this change follow existing patterns in the codebase, or does it introduce a new one without justification?
- Are there unnecessary abstractions or premature generalizations?
- Is there duplicated logic that should be extracted?
- Are dependencies between modules clean, or does this create circular/tight coupling?
- Will this change make future work harder?
## Step 5: Present findings
Summarize findings to the user as a table:
| # | Severity | Category | File:Line | Finding | Suggested Fix |
|---|----------|----------|-----------|---------|---------------|
Severity levels:
- **Critical**: Security vulnerability, data loss, or financial exploit
- **High**: Bug that will cause incorrect behavior in production
- **Medium**: Robustness issue, missing validation, or incomplete error handling
- **Low**: Style, naming, documentation, or minor improvement
- **Nit**: Optional suggestion, take-it-or-leave-it
Ask the user which findings to post as PR comments. Default: all Critical, High, and Medium.
## Step 6: Post comments on GitHub
Resolve the repo owner and name if not already known:
```
gh repo view --json owner,name --jq '"\(.owner.login)/\(.name)"'
```
For each approved finding, post a review comment on the PR at the specific file and line. Use the `headRefOid` from Step 1 as the `commit_id`:
```
gh api repos/{owner}/{repo}/pulls/{number}/comments \
-f body="..." \
-f path="..." \
-f commit_id="{headRefOid}" \
-F line=... \
-f side="RIGHT"
```
For findings that span multiple locations or are architectural, post as a regular PR comment:
```
gh pr comment {number} --body "..."
```
Format each comment clearly:
- Severity tag (e.g. `**High Severity**`)
- One-line summary
- Detailed explanation of the issue
- Concrete suggestion for the fix (with code if possible)
## Rules
- Read every changed file in full before writing a single finding. Context matters.
- Never post a comment about code you haven't actually read. Verify line numbers against the actual file.
- Be specific. "This might have issues" is useless. "Line 42 returns 404 but should return 400 because X" is useful.
- Distinguish between "this IS a bug" and "this COULD be a bug if X". Be honest about certainty.
- Don't nitpick formatting or style unless it causes actual confusion. Focus on substance.
- If the code is good and you find nothing, say so. Don't invent problems to look thorough.
- Respect the project's CLAUDE.md privacy rules: never include customer data, secrets, or PII in comments.
- When in doubt about severity, round up. It's cheaper to dismiss a false alarm than to miss a real bug.
+1
View File
@@ -39,6 +39,7 @@ permissions:
# If there's a prerelease-style suffix to the version, then the release(s) # If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease. # will be marked as a prerelease.
on: on:
pull_request:
push: push:
tags: tags:
- '**[0-9]+.[0-9]+.[0-9]+*' - '**[0-9]+.[0-9]+.[0-9]+*'
-8
View File
@@ -2,14 +2,6 @@
.env .env
.env.local .env.local
.env.* .env.*
!.env.example
# Claude Code worktrees
.claude/worktrees/
# Sidecar tool data
.sidecar/
.todos/
target/ target/
-54
View File
@@ -7,60 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.5.0](https://github.com/nearai/ironclaw/compare/v0.4.0...v0.5.0) - 2026-02-17
### Added
- add cooldown management to FailoverProvider ([#114](https://github.com/nearai/ironclaw/pull/114))
## [0.4.0](https://github.com/nearai/ironclaw/compare/v0.3.0...v0.4.0) - 2026-02-17
### Added
- move per-invocation approval check into Tool trait ([#119](https://github.com/nearai/ironclaw/pull/119))
- add polished boot screen on CLI startup ([#118](https://github.com/nearai/ironclaw/pull/118))
- Add lifecycle hooks system with 6 interception points ([#18](https://github.com/nearai/ironclaw/pull/18))
### Other
- remove accidentally committed .sidecar and .todos directories ([#123](https://github.com/nearai/ironclaw/pull/123))
## [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 ## [0.1.3](https://github.com/nearai/ironclaw/compare/v0.1.2...v0.1.3) - 2026-02-12
### Other ### Other
-16
View File
@@ -630,22 +630,6 @@ RUST_LOG=ironclaw::agent=debug cargo run
RUST_LOG=ironclaw=debug,tower_http=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 ## Code Style
- Use `crate::` imports, not `super::` - Use `crate::` imports, not `super::`
Generated
+1 -1
View File
@@ -2490,7 +2490,7 @@ dependencies = [
[[package]] [[package]]
name = "ironclaw" name = "ironclaw"
version = "0.5.0" version = "0.1.3"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"aho-corasick", "aho-corasick",
+2 -12
View File
@@ -1,14 +1,6 @@
[workspace]
exclude = [
"channels-src/telegram",
"channels-src/slack",
"channels-src/whatsapp",
"tools-src/gmail",
]
[package] [package]
name = "ironclaw" name = "ironclaw"
version = "0.5.0" version = "0.1.3"
edition = "2024" edition = "2024"
rust-version = "1.92" rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
@@ -191,13 +183,11 @@ windows-archive = ".tar.gz"
# The archive format to use for non-windows builds (defaults .tar.xz) # The archive format to use for non-windows builds (defaults .tar.xz)
unix-archive = ".tar.gz" unix-archive = ".tar.gz"
# Which actions to run on pull requests # Which actions to run on pull requests
pr-run-mode = "skip" pr-run-mode = "upload"
# Path that installers should place binaries in # Path that installers should place binaries in
install-path = "CARGO_HOME" install-path = "CARGO_HOME"
# Whether to install an updater program # Whether to install an updater program
install-updater = true install-updater = true
# Cache intermediate build artifacts to speed up the release pipelines
cache-builds = true
[workspace.metadata.dist.github-custom-runners] [workspace.metadata.dist.github-custom-runners]
aarch64-unknown-linux-gnu = "ubuntu-24.04-arm" aarch64-unknown-linux-gnu = "ubuntu-24.04-arm"
+15 -11
View File
@@ -112,7 +112,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing | | `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing |
| `nodes` | ✅ | ❌ | P3 | Device management | | `nodes` | ✅ | ❌ | P3 | Device management |
| `plugins` | ✅ | ❌ | P3 | Plugin management | | `plugins` | ✅ | ❌ | P3 | Plugin management |
| `hooks` | ✅ | | P2 | Lifecycle hooks | | `hooks` | ✅ | | P2 | Lifecycle hooks |
| `cron` | ✅ | ❌ | P2 | Scheduled jobs | | `cron` | ✅ | ❌ | P2 | Scheduled jobs |
| `webhooks` | ✅ | ❌ | P3 | Webhook config | | `webhooks` | ✅ | ❌ | P3 | Webhook config |
| `message send` | ✅ | ❌ | P2 | Send to channels | | `message send` | ✅ | ❌ | P2 | Send to channels |
@@ -164,7 +164,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| AWS Bedrock | ✅ | ❌ | P3 | | | AWS Bedrock | ✅ | ❌ | P3 | |
| Google Gemini | ✅ | ❌ | P3 | | | Google Gemini | ✅ | ❌ | P3 | |
| OpenRouter | ✅ | ❌ | P3 | | | OpenRouter | ✅ | ❌ | P3 | |
| Ollama (local) | ✅ | | - | via `rig::providers::ollama` (full support) | | Ollama (local) | ✅ | | P2 | Local models |
| node-llama-cpp | ✅ | | - | N/A for Rust | | node-llama-cpp | ✅ | | - | N/A for Rust |
| llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings | | llama.cpp (native) | ❌ | 🔮 | P3 | Rust bindings |
@@ -174,7 +174,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|---------|----------|----------|-------| |---------|----------|----------|-------|
| Auto-discovery | ✅ | ❌ | | | Auto-discovery | ✅ | ❌ | |
| Failover chains | ✅ | ✅ | `FailoverProvider` with configurable `fallback_model` | | Failover chains | ✅ | ✅ | `FailoverProvider` with configurable `fallback_model` |
| Cooldown management | ✅ | | Lock-free per-provider cooldown in `FailoverProvider` | | Cooldown management | ✅ | | Skip failed providers |
| Per-session model override | ✅ | ✅ | Model selector in TUI | | Per-session model override | ✅ | ✅ | Model selector in TUI |
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut | | Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
@@ -323,14 +323,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger | | Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
| Timezone support | ✅ | ✅ | - | Via cron expressions | | Timezone support | ✅ | ✅ | - | Via cron expressions |
| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers | | One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers |
| `beforeInbound` hook | ✅ | | P2 | | | `beforeInbound` hook | ✅ | | P2 | |
| `beforeOutbound` hook | ✅ | | P2 | | | `beforeOutbound` hook | ✅ | | P2 | |
| `beforeToolCall` hook | ✅ | | P2 | | | `beforeToolCall` hook | ✅ | | P2 | |
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger | | `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
| `onSessionStart` hook | ✅ | | P2 | | | `onSessionStart` hook | ✅ | | P2 | |
| `onSessionEnd` hook | ✅ | | P2 | | | `onSessionEnd` hook | ✅ | | P2 | |
| `transcribeAudio` hook | ✅ | ❌ | P3 | | | `transcribeAudio` hook | ✅ | ❌ | P3 | |
| `transformResponse` hook | ✅ | | P2 | | | `transformResponse` hook | ✅ | | P2 | |
| Bundled hooks | ✅ | ❌ | P2 | | | Bundled hooks | ✅ | ❌ | P2 | |
| Plugin hooks | ✅ | ❌ | P3 | | | Plugin hooks | ✅ | ❌ | P3 | |
| Workspace hooks | ✅ | ❌ | P2 | Inline code | | Workspace hooks | ✅ | ❌ | P2 | Inline code |
@@ -420,10 +420,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
- ✅ Telegram channel (WASM, DM pairing, caption, /start) - ✅ Telegram channel (WASM, DM pairing, caption, /start)
- ❌ WhatsApp channel - ❌ WhatsApp channel
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification) - ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
- Hooks system (beforeInbound, beforeToolCall, beforeOutbound, onSessionStart, onSessionEnd, transformResponse) - Hooks system (beforeInbound, beforeToolCall, etc.)
### P2 - Medium Priority ### P2 - Medium Priority
-Media handling (images, PDFs) -Cron job scheduling
- ❌ Web Control UI
- ❌ WebChat channel
- 🚧 Media handling (caption support; no image/PDF processing)
- ❌ CLI subcommands (config, status, memory, doctor)
- ❌ Ollama/local model support - ❌ Ollama/local model support
- ❌ Configuration hot-reload - ❌ Configuration hot-reload
- ❌ Webhook trigger endpoint in web gateway - ❌ Webhook trigger endpoint in web gateway
-23
View File
@@ -1,23 +0,0 @@
[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
-121
View File
@@ -1,121 +0,0 @@
# 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
@@ -1,39 +0,0 @@
{
"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
}
}
-476
View File
@@ -1,476 +0,0 @@
//! 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");
}
}
+42 -154
View File
@@ -22,7 +22,6 @@ use crate::context::JobContext;
use crate::db::Database; use crate::db::Database;
use crate::error::Error; use crate::error::Error;
use crate::extensions::ExtensionManager; use crate::extensions::ExtensionManager;
use crate::hooks::HookRegistry;
use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult}; use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult};
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
@@ -68,14 +67,10 @@ enum AgenticLoopResult {
pub struct AgentDeps { pub struct AgentDeps {
pub store: Option<Arc<dyn Database>>, pub store: Option<Arc<dyn Database>>,
pub llm: Arc<dyn LlmProvider>, 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 safety: Arc<SafetyLayer>,
pub tools: Arc<ToolRegistry>, pub tools: Arc<ToolRegistry>,
pub workspace: Option<Arc<Workspace>>, pub workspace: Option<Arc<Workspace>>,
pub extension_manager: Option<Arc<ExtensionManager>>, pub extension_manager: Option<Arc<ExtensionManager>>,
pub hooks: Arc<HookRegistry>,
} }
/// The main agent that coordinates all components. /// The main agent that coordinates all components.
@@ -118,7 +113,6 @@ impl Agent {
deps.safety.clone(), deps.safety.clone(),
deps.tools.clone(), deps.tools.clone(),
deps.store.clone(), deps.store.clone(),
deps.hooks.clone(),
)); ));
Self { Self {
@@ -144,11 +138,6 @@ impl Agent {
&self.deps.llm &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> { fn safety(&self) -> &Arc<SafetyLayer> {
&self.deps.safety &self.deps.safety
} }
@@ -161,10 +150,6 @@ impl Agent {
self.deps.workspace.as_ref() self.deps.workspace.as_ref()
} }
fn hooks(&self) -> &Arc<HookRegistry> {
&self.deps.hooks
}
/// Run the agent main loop. /// Run the agent main loop.
pub async fn run(self) -> Result<(), Error> { pub async fn run(self) -> Result<(), Error> {
// Start channels // Start channels
@@ -316,7 +301,7 @@ impl Agent {
Some(spawn_heartbeat( Some(spawn_heartbeat(
config, config,
workspace.clone(), workspace.clone(),
self.cheap_llm().clone(), self.llm().clone(),
Some(notify_tx), Some(notify_tx),
)) ))
} else { } else {
@@ -432,32 +417,10 @@ impl Agent {
match self.handle_message(&message).await { match self.handle_message(&message).await {
Ok(Some(response)) if !response.is_empty() => { Ok(Some(response)) if !response.is_empty() => {
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound let _ = self
let event = crate::hooks::HookEvent::Outbound { .channels
user_id: message.user_id.clone(), .respond(&message, OutgoingResponse::text(response))
channel: message.channel.clone(), .await;
content: response.clone(),
thread_id: message.thread_id.clone(),
};
match self.hooks().run(&event).await {
Err(err) => {
tracing::warn!("BeforeOutbound hook blocked response: {}", err);
}
Ok(crate::hooks::HookOutcome::Continue {
modified: Some(new_content),
}) => {
let _ = self
.channels
.respond(&message, OutgoingResponse::text(new_content))
.await;
}
_ => {
let _ = self
.channels
.respond(&message, OutgoingResponse::text(response))
.await;
}
}
} }
Ok(Some(_)) => { Ok(Some(_)) => {
// Empty response, nothing to send (e.g. approval handled via send_status) // Empty response, nothing to send (e.g. approval handled via send_status)
@@ -503,33 +466,7 @@ impl Agent {
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> { async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
// Parse submission type first // Parse submission type first
let mut submission = SubmissionParser::parse(&message.content); let submission = SubmissionParser::parse(&message.content);
// Hook: BeforeInbound — allow hooks to modify or reject user input
if let Submission::UserInput { ref content } = submission {
let event = crate::hooks::HookEvent::Inbound {
user_id: message.user_id.clone(),
channel: message.channel.clone(),
content: content.clone(),
thread_id: message.thread_id.clone(),
};
match self.hooks().run(&event).await {
Err(crate::hooks::HookError::Rejected { reason }) => {
return Ok(Some(format!("[Message rejected: {}]", reason)));
}
Err(err) => {
return Ok(Some(format!("[Message blocked by hook policy: {}]", err)));
}
Ok(crate::hooks::HookOutcome::Continue {
modified: Some(new_content),
}) => {
submission = Submission::UserInput {
content: new_content,
};
}
_ => {} // Continue, fail-open errors already logged in registry
}
}
// Hydrate thread from DB if it's a historical thread not in memory // Hydrate thread from DB if it's a historical thread not in memory
if let Some(ref external_thread_id) = message.thread_id { if let Some(ref external_thread_id) = message.thread_id {
@@ -938,27 +875,6 @@ impl Agent {
// Complete, fail, or request approval // Complete, fail, or request approval
match result { match result {
Ok(AgenticLoopResult::Response(response)) => { Ok(AgenticLoopResult::Response(response)) => {
// Hook: TransformResponse — allow hooks to modify or reject the final response
let response = {
let event = crate::hooks::HookEvent::ResponseTransform {
user_id: message.user_id.clone(),
thread_id: thread_id.to_string(),
response: response.clone(),
};
match self.hooks().run(&event).await {
Err(crate::hooks::HookError::Rejected { reason }) => {
format!("[Response filtered: {}]", reason)
}
Err(err) => {
format!("[Response blocked by hook policy: {}]", err)
}
Ok(crate::hooks::HookOutcome::Continue {
modified: Some(new_response),
}) => new_response,
_ => response, // fail-open: use original
}
};
thread.complete_turn(&response); thread.complete_turn(&response);
self.persist_response_chain(thread); self.persist_response_chain(thread);
let _ = self let _ = self
@@ -1236,8 +1152,8 @@ impl Agent {
} }
} }
// Execute each tool (with approval checking and hook interception) // Execute each tool (with approval checking)
for mut tc in tool_calls { for tc in tool_calls {
// Check if tool requires approval // Check if tool requires approval
if let Some(tool) = self.tools().get(&tc.name).await if let Some(tool) = self.tools().get(&tc.name).await
&& tool.requires_approval() && tool.requires_approval()
@@ -1248,12 +1164,31 @@ impl Agent {
sess.is_tool_auto_approved(&tc.name) sess.is_tool_auto_approved(&tc.name)
}; };
// Let the tool inspect the specific parameters and // For shell commands, override auto-approval for
// override auto-approval (e.g. destructive shell commands). // destructive patterns that should always require
if is_auto_approved && tool.requires_approval_for(&tc.arguments) { // explicit per-invocation approval.
if is_auto_approved
&& tc.name == "shell"
&& let Some(cmd) = tc
.arguments
.get("command")
.and_then(|c| c.as_str().map(String::from))
.or_else(|| {
tc.arguments
.as_str()
.and_then(|s| {
serde_json::from_str::<serde_json::Value>(s).ok()
})
.and_then(|v| {
v.get("command")
.and_then(|c| c.as_str().map(String::from))
})
})
&& crate::tools::builtin::shell::requires_explicit_approval(&cmd)
{
tracing::info!( tracing::info!(
tool = %tc.name, "Shell command '{}' requires explicit approval despite auto-approve",
"Tool requires explicit approval for these parameters despite auto-approve" cmd.chars().take(80).collect::<String>()
); );
is_auto_approved = false; is_auto_approved = false;
} }
@@ -1273,47 +1208,6 @@ impl Agent {
} }
} }
// Hook: BeforeToolCall — allow hooks to modify or reject tool calls
{
let event = crate::hooks::HookEvent::ToolCall {
tool_name: tc.name.clone(),
parameters: tc.arguments.clone(),
user_id: message.user_id.clone(),
context: "chat".to_string(),
};
match self.hooks().run(&event).await {
Err(crate::hooks::HookError::Rejected { reason }) => {
context_messages.push(ChatMessage::tool_result(
&tc.id,
&tc.name,
format!("Tool call rejected by hook: {}", reason),
));
continue;
}
Err(err) => {
context_messages.push(ChatMessage::tool_result(
&tc.id,
&tc.name,
format!("Tool call blocked by hook policy: {}", err),
));
continue;
}
Ok(crate::hooks::HookOutcome::Continue {
modified: Some(new_params),
}) => match serde_json::from_str(&new_params) {
Ok(parsed) => tc.arguments = parsed,
Err(e) => {
tracing::warn!(
tool = %tc.name,
"Hook returned non-JSON modification for ToolCall, ignoring: {}",
e
);
}
},
_ => {} // Continue, fail-open errors already logged
}
}
let _ = self let _ = self
.channels .channels
.send_status( .send_status(
@@ -1579,9 +1473,6 @@ impl Agent {
session: Arc<Mutex<Session>>, session: Arc<Mutex<Session>>,
thread_id: Uuid, thread_id: Uuid,
) -> Result<SubmissionResult, Error> { ) -> Result<SubmissionResult, Error> {
// Lock session first, then undo manager -- consistent with process_user_input
// to avoid potential deadlocks.
let mut sess = session.lock().await;
let undo_mgr = self.session_manager.get_undo_manager(thread_id).await; let undo_mgr = self.session_manager.get_undo_manager(thread_id).await;
let mut mgr = undo_mgr.lock().await; let mut mgr = undo_mgr.lock().await;
@@ -1589,6 +1480,7 @@ impl Agent {
return Ok(SubmissionResult::ok_with_message("Nothing to undo.")); return Ok(SubmissionResult::ok_with_message("Nothing to undo."));
} }
let mut sess = session.lock().await;
let thread = sess let thread = sess
.threads .threads
.get_mut(&thread_id) .get_mut(&thread_id)
@@ -1599,10 +1491,12 @@ impl Agent {
let current_turn = thread.turn_number(); let current_turn = thread.turn_number();
if let Some(checkpoint) = mgr.undo(current_turn, current_messages) { if let Some(checkpoint) = mgr.undo(current_turn, current_messages) {
// Extract values before consuming the reference
let turn_number = checkpoint.turn_number; let turn_number = checkpoint.turn_number;
let messages = checkpoint.messages.clone();
let undo_count = mgr.undo_count(); let undo_count = mgr.undo_count();
// Restore thread from checkpoint // Restore thread from checkpoint
thread.restore_from_messages(checkpoint.messages); thread.restore_from_messages(messages);
Ok(SubmissionResult::ok_with_message(format!( Ok(SubmissionResult::ok_with_message(format!(
"Undone to turn {}. {} undo(s) remaining.", "Undone to turn {}. {} undo(s) remaining.",
turn_number, undo_count turn_number, undo_count
@@ -1617,9 +1511,6 @@ impl Agent {
session: Arc<Mutex<Session>>, session: Arc<Mutex<Session>>,
thread_id: Uuid, thread_id: Uuid,
) -> Result<SubmissionResult, Error> { ) -> Result<SubmissionResult, Error> {
// Lock session first, then undo manager -- consistent with process_user_input
// to avoid potential deadlocks.
let mut sess = session.lock().await;
let undo_mgr = self.session_manager.get_undo_manager(thread_id).await; let undo_mgr = self.session_manager.get_undo_manager(thread_id).await;
let mut mgr = undo_mgr.lock().await; let mut mgr = undo_mgr.lock().await;
@@ -1627,15 +1518,12 @@ impl Agent {
return Ok(SubmissionResult::ok_with_message("Nothing to redo.")); return Ok(SubmissionResult::ok_with_message("Nothing to redo."));
} }
let thread = sess if let Some(checkpoint) = mgr.redo() {
.threads let mut sess = session.lock().await;
.get_mut(&thread_id) let thread = sess
.ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?; .threads
.get_mut(&thread_id)
let current_messages = thread.messages(); .ok_or_else(|| Error::from(crate::error::JobError::NotFound { id: thread_id }))?;
let current_turn = thread.turn_number();
if let Some(checkpoint) = mgr.redo(current_turn, current_messages) {
thread.restore_from_messages(checkpoint.messages); thread.restore_from_messages(checkpoint.messages);
Ok(SubmissionResult::ok_with_message(format!( Ok(SubmissionResult::ok_with_message(format!(
"Redone to turn {}.", "Redone to turn {}.",
-5
View File
@@ -14,7 +14,6 @@ use crate::config::AgentConfig;
use crate::context::{ContextManager, JobContext, JobState}; use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database; use crate::db::Database;
use crate::error::{Error, JobError}; use crate::error::{Error, JobError};
use crate::hooks::HookRegistry;
use crate::llm::LlmProvider; use crate::llm::LlmProvider;
use crate::safety::SafetyLayer; use crate::safety::SafetyLayer;
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
@@ -50,7 +49,6 @@ pub struct Scheduler {
safety: Arc<SafetyLayer>, safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>, store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>,
/// Running jobs (main LLM-driven jobs). /// Running jobs (main LLM-driven jobs).
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>, jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
/// Running sub-tasks (tool executions, background tasks). /// Running sub-tasks (tool executions, background tasks).
@@ -66,7 +64,6 @@ impl Scheduler {
safety: Arc<SafetyLayer>, safety: Arc<SafetyLayer>,
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>, store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>,
) -> Self { ) -> Self {
Self { Self {
config, config,
@@ -75,7 +72,6 @@ impl Scheduler {
safety, safety,
tools, tools,
store, store,
hooks,
jobs: Arc::new(RwLock::new(HashMap::new())), jobs: Arc::new(RwLock::new(HashMap::new())),
subtasks: Arc::new(RwLock::new(HashMap::new())), subtasks: Arc::new(RwLock::new(HashMap::new())),
} }
@@ -122,7 +118,6 @@ impl Scheduler {
safety: self.safety.clone(), safety: self.safety.clone(),
tools: self.tools.clone(), tools: self.tools.clone(),
store: self.store.clone(), store: self.store.clone(),
hooks: self.hooks.clone(),
timeout: self.config.job_timeout, timeout: self.config.job_timeout,
use_planning: self.config.use_planning, use_planning: self.config.use_planning,
}; };
+4 -57
View File
@@ -11,7 +11,6 @@ use uuid::Uuid;
use crate::agent::session::Session; use crate::agent::session::Session;
use crate::agent::undo::UndoManager; use crate::agent::undo::UndoManager;
use crate::hooks::HookRegistry;
/// Key for mapping external thread IDs to internal ones. /// Key for mapping external thread IDs to internal ones.
#[derive(Clone, Hash, Eq, PartialEq)] #[derive(Clone, Hash, Eq, PartialEq)]
@@ -26,7 +25,6 @@ pub struct SessionManager {
sessions: RwLock<HashMap<String, Arc<Mutex<Session>>>>, sessions: RwLock<HashMap<String, Arc<Mutex<Session>>>>,
thread_map: RwLock<HashMap<ThreadKey, Uuid>>, thread_map: RwLock<HashMap<ThreadKey, Uuid>>,
undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>, undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>,
hooks: Option<Arc<HookRegistry>>,
} }
impl SessionManager { impl SessionManager {
@@ -36,16 +34,9 @@ impl SessionManager {
sessions: RwLock::new(HashMap::new()), sessions: RwLock::new(HashMap::new()),
thread_map: RwLock::new(HashMap::new()), thread_map: RwLock::new(HashMap::new()),
undo_managers: RwLock::new(HashMap::new()), undo_managers: RwLock::new(HashMap::new()),
hooks: None,
} }
} }
/// Attach a hook registry for session lifecycle events.
pub fn with_hooks(mut self, hooks: Arc<HookRegistry>) -> Self {
self.hooks = Some(hooks);
self
}
/// Get or create a session for a user. /// Get or create a session for a user.
pub async fn get_or_create_session(&self, user_id: &str) -> Arc<Mutex<Session>> { pub async fn get_or_create_session(&self, user_id: &str) -> Arc<Mutex<Session>> {
// Fast path: check if session exists // Fast path: check if session exists
@@ -63,28 +54,8 @@ impl SessionManager {
return Arc::clone(session); return Arc::clone(session);
} }
let new_session = Session::new(user_id); let session = Arc::new(Mutex::new(Session::new(user_id)));
let session_id = new_session.id.to_string();
let session = Arc::new(Mutex::new(new_session));
sessions.insert(user_id.to_string(), Arc::clone(&session)); sessions.insert(user_id.to_string(), Arc::clone(&session));
// Fire OnSessionStart hook (fire-and-forget)
if let Some(ref hooks) = self.hooks {
let hooks = hooks.clone();
let uid = user_id.to_string();
let sid = session_id;
tokio::spawn(async move {
use crate::hooks::HookEvent;
let event = HookEvent::SessionStart {
user_id: uid,
session_id: sid,
};
if let Err(e) = hooks.run(&event).await {
tracing::warn!("OnSessionStart hook error: {}", e);
}
});
}
session session
} }
@@ -202,8 +173,8 @@ impl SessionManager {
pub async fn prune_stale_sessions(&self, max_idle: std::time::Duration) -> usize { pub async fn prune_stale_sessions(&self, max_idle: std::time::Duration) -> usize {
let cutoff = chrono::Utc::now() - chrono::TimeDelta::seconds(max_idle.as_secs() as i64); let cutoff = chrono::Utc::now() - chrono::TimeDelta::seconds(max_idle.as_secs() as i64);
// Find stale sessions (user_id + session_id) // Find stale session user_ids
let stale_sessions: Vec<(String, String)> = { let stale_users: Vec<String> = {
let sessions = self.sessions.read().await; let sessions = self.sessions.read().await;
sessions sessions
.iter() .iter()
@@ -211,7 +182,7 @@ impl SessionManager {
// Try to lock; skip if contended (someone is actively using it) // Try to lock; skip if contended (someone is actively using it)
let sess = session.try_lock().ok()?; let sess = session.try_lock().ok()?;
if sess.last_active_at < cutoff { if sess.last_active_at < cutoff {
Some((user_id.clone(), sess.id.to_string())) Some(user_id.clone())
} else { } else {
None None
} }
@@ -219,11 +190,6 @@ impl SessionManager {
.collect() .collect()
}; };
let stale_users: Vec<String> = stale_sessions
.iter()
.map(|(user_id, _)| user_id.clone())
.collect();
if stale_users.is_empty() { if stale_users.is_empty() {
return 0; return 0;
} }
@@ -241,25 +207,6 @@ impl SessionManager {
} }
} }
// Fire OnSessionEnd hooks for stale sessions (fire-and-forget)
if let Some(ref hooks) = self.hooks {
for (user_id, session_id) in &stale_sessions {
let hooks = hooks.clone();
let uid = user_id.clone();
let sid = session_id.clone();
tokio::spawn(async move {
use crate::hooks::HookEvent;
let event = HookEvent::SessionEnd {
user_id: uid,
session_id: sid,
};
if let Err(e) = hooks.run(&event).await {
tracing::warn!("OnSessionEnd hook error: {}", e);
}
});
}
}
// Remove sessions // Remove sessions
let count = { let count = {
let mut sessions = self.sessions.write().await; let mut sessions = self.sessions.write().await;
+16 -136
View File
@@ -43,10 +43,6 @@ impl Checkpoint {
} }
/// Manager for undo/redo functionality. /// Manager for undo/redo functionality.
///
/// Each undo/redo operation pops from one stack and pushes the current state
/// onto the other, so `undo_count() + redo_count()` stays constant across
/// undo/redo cycles (only `checkpoint()` and `clear()` change the total).
pub struct UndoManager { pub struct UndoManager {
/// Stack of past checkpoints (for undo). /// Stack of past checkpoints (for undo).
undo_stack: VecDeque<Checkpoint>, undo_stack: VecDeque<Checkpoint>,
@@ -72,14 +68,6 @@ impl UndoManager {
self self
} }
/// Push a checkpoint onto the undo stack, trimming oldest entries if over limit.
fn push_undo(&mut self, checkpoint: Checkpoint) {
self.undo_stack.push_back(checkpoint);
while self.undo_stack.len() > self.max_checkpoints {
self.undo_stack.pop_front();
}
}
/// Create a checkpoint at the current state. /// Create a checkpoint at the current state.
/// ///
/// This clears the redo stack since we're creating a new history branch. /// This clears the redo stack since we're creating a new history branch.
@@ -92,23 +80,24 @@ impl UndoManager {
// Clear redo stack (new branch of history) // Clear redo stack (new branch of history)
self.redo_stack.clear(); self.redo_stack.clear();
// Create and push checkpoint
let checkpoint = Checkpoint::new(turn_number, messages, description); let checkpoint = Checkpoint::new(turn_number, messages, description);
self.push_undo(checkpoint); self.undo_stack.push_back(checkpoint);
// Trim if over limit
while self.undo_stack.len() > self.max_checkpoints {
self.undo_stack.pop_front();
}
} }
/// Undo: pop the last checkpoint and return it. /// Undo: pop the last checkpoint and return it.
/// ///
/// Saves the current state to the redo stack and pops the most recent /// The current state should be saved to redo stack before calling this.
/// checkpoint from the undo stack so that repeated undos walk backwards
/// through history.
///
/// Takes ownership of `current_messages`; callers must clone first if
/// they need to retain a copy.
pub fn undo( pub fn undo(
&mut self, &mut self,
current_turn: usize, current_turn: usize,
current_messages: Vec<ChatMessage>, current_messages: Vec<ChatMessage>,
) -> Option<Checkpoint> { ) -> Option<&Checkpoint> {
if self.undo_stack.is_empty() { if self.undo_stack.is_empty() {
return None; return None;
} }
@@ -121,8 +110,9 @@ impl UndoManager {
); );
self.redo_stack.push(current); self.redo_stack.push(current);
// Pop and return the most recent checkpoint // Return the most recent checkpoint without removing it
self.undo_stack.pop_back() // (we keep it so multiple undos can work)
self.undo_stack.back()
} }
/// Pop the last checkpoint from the undo stack. /// Pop the last checkpoint from the undo stack.
@@ -131,29 +121,7 @@ impl UndoManager {
} }
/// Redo: restore a previously undone state. /// Redo: restore a previously undone state.
/// pub fn redo(&mut self) -> Option<Checkpoint> {
/// Saves the current state to the undo stack and pops the most recent
/// checkpoint from the redo stack.
///
/// Takes ownership of `current_messages`; callers must clone first if
/// they need to retain a copy.
pub fn redo(
&mut self,
current_turn: usize,
current_messages: Vec<ChatMessage>,
) -> Option<Checkpoint> {
if self.redo_stack.is_empty() {
return None;
}
// Save current state to undo stack
let current = Checkpoint::new(
current_turn,
current_messages,
format!("Turn {}", current_turn),
);
self.push_undo(current);
self.redo_stack.pop() self.redo_stack.pop()
} }
@@ -246,16 +214,14 @@ mod tests {
assert!(manager.can_undo()); assert!(manager.can_undo());
assert!(!manager.can_redo()); assert!(!manager.can_redo());
// Undo - returns owned Checkpoint now // Undo
let current = vec![ChatMessage::user("Hello"), ChatMessage::assistant("Hi")]; let current = vec![ChatMessage::user("Hello"), ChatMessage::assistant("Hi")];
let checkpoint = manager.undo(2, current); let checkpoint = manager.undo(2, current);
assert!(checkpoint.is_some()); assert!(checkpoint.is_some());
let checkpoint = checkpoint.unwrap();
assert_eq!(checkpoint.turn_number, 1);
assert!(manager.can_redo()); assert!(manager.can_redo());
// Redo - now requires current state parameters // Redo
let restored = manager.redo(checkpoint.turn_number, checkpoint.messages); let restored = manager.redo();
assert!(restored.is_some()); assert!(restored.is_some());
} }
@@ -283,90 +249,4 @@ mod tests {
assert!(restored.is_some()); assert!(restored.is_some());
assert_eq!(manager.undo_count(), 0); assert_eq!(manager.undo_count(), 0);
} }
#[test]
fn test_repeated_undo_advances_through_stack() {
let mut manager = UndoManager::new();
// Create 3 checkpoints at turns 0, 1, 2
manager.checkpoint(0, vec![], "Turn 0");
manager.checkpoint(1, vec![ChatMessage::user("msg1")], "Turn 1");
manager.checkpoint(2, vec![ChatMessage::user("msg2")], "Turn 2");
assert_eq!(manager.undo_count(), 3);
// First undo: should return turn 2 checkpoint, stack shrinks to 2
let cp1 = manager
.undo(3, vec![ChatMessage::user("msg3")])
.expect("first undo should succeed");
assert_eq!(cp1.turn_number, 2);
assert_eq!(manager.undo_count(), 2);
// Second undo: should return turn 1 checkpoint (different!), stack shrinks to 1
let cp2 = manager
.undo(cp1.turn_number, cp1.messages)
.expect("second undo should succeed");
assert_eq!(cp2.turn_number, 1);
assert_eq!(manager.undo_count(), 1);
// Verify we walked backwards through distinct checkpoints
assert_ne!(cp1.turn_number, cp2.turn_number);
}
#[test]
fn test_undo_redo_cycle_preserves_state() {
let mut manager = UndoManager::new();
let msgs_t0: Vec<ChatMessage> = vec![];
let msgs_t1 = vec![ChatMessage::user("hello")];
let msgs_t2 = vec![ChatMessage::user("hello"), ChatMessage::assistant("hi")];
manager.checkpoint(0, msgs_t0, "Turn 0");
manager.checkpoint(1, msgs_t1, "Turn 1");
// Undo from turn 2 -> get turn 1 checkpoint
let cp_undo1 = manager
.undo(2, msgs_t2.clone())
.expect("undo should succeed");
assert_eq!(cp_undo1.turn_number, 1);
// Redo from turn 1 -> get turn 2 state back
let cp_redo = manager
.redo(cp_undo1.turn_number, cp_undo1.messages)
.expect("redo should succeed");
assert_eq!(cp_redo.turn_number, 2);
assert_eq!(cp_redo.messages.len(), 2);
// Undo again from turn 2 -> should go back to turn 1 again
let cp_undo2 = manager
.undo(cp_redo.turn_number, cp_redo.messages)
.expect("second undo should succeed");
assert_eq!(cp_undo2.turn_number, 1);
}
#[test]
fn test_undo_redo_stack_sizes_consistent() {
let mut manager = UndoManager::new();
manager.checkpoint(0, vec![], "Turn 0");
manager.checkpoint(1, vec![ChatMessage::user("a")], "Turn 1");
manager.checkpoint(2, vec![ChatMessage::user("b")], "Turn 2");
// Start: undo=3, redo=0, total=3
let total = manager.undo_count() + manager.redo_count();
assert_eq!(total, 3);
// After undo: total should still be 3 (one moved from undo to redo,
// plus the current state pushed to redo)
// Actually: undo pops one (3->2), pushes current to redo (0->1), total=3
let cp = manager.undo(3, vec![]).unwrap();
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
// After redo: redo pops one (1->0), pushes current to undo (2->3), total=3
let cp2 = manager.redo(cp.turn_number, cp.messages).unwrap();
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
// After another undo: same invariant
let _cp3 = manager.undo(cp2.turn_number, cp2.messages).unwrap();
assert_eq!(manager.undo_count() + manager.redo_count(), 3);
}
} }
+42 -61
View File
@@ -12,7 +12,6 @@ use crate::agent::task::TaskOutput;
use crate::context::{ContextManager, JobState}; use crate::context::{ContextManager, JobState};
use crate::db::Database; use crate::db::Database;
use crate::error::Error; use crate::error::Error;
use crate::hooks::HookRegistry;
use crate::llm::{ use crate::llm::{
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection, ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
}; };
@@ -30,7 +29,6 @@ pub struct WorkerDeps {
pub safety: Arc<SafetyLayer>, pub safety: Arc<SafetyLayer>,
pub tools: Arc<ToolRegistry>, pub tools: Arc<ToolRegistry>,
pub store: Option<Arc<dyn Database>>, pub store: Option<Arc<dyn Database>>,
pub hooks: Arc<HookRegistry>,
pub timeout: Duration, pub timeout: Duration,
pub use_planning: bool, pub use_planning: bool,
} }
@@ -354,11 +352,23 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.map(|selection| { .map(|selection| {
let tool_name = selection.tool_name.clone(); let tool_name = selection.tool_name.clone();
let params = selection.parameters.clone(); let params = selection.parameters.clone();
let deps = self.deps.clone(); let tools = self.tools().clone();
let context_manager = self.context_manager().clone();
let safety = self.safety().clone();
let job_id = self.job_id; let job_id = self.job_id;
let store = self.deps.store.clone();
async move { async move {
let result = Self::execute_tool_inner(&deps, job_id, &tool_name, &params).await; let result = Self::execute_tool_inner(
tools,
context_manager,
safety,
store,
job_id,
&tool_name,
&params,
)
.await;
ToolExecResult { result } ToolExecResult { result }
} }
}) })
@@ -369,18 +379,20 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
/// Inner tool execution logic that can be called from both single and parallel paths. /// Inner tool execution logic that can be called from both single and parallel paths.
async fn execute_tool_inner( async fn execute_tool_inner(
deps: &WorkerDeps, tools: Arc<ToolRegistry>,
context_manager: Arc<ContextManager>,
safety: Arc<SafetyLayer>,
store: Option<Arc<dyn Database>>,
job_id: Uuid, job_id: Uuid,
tool_name: &str, tool_name: &str,
params: &serde_json::Value, params: &serde_json::Value,
) -> Result<String, Error> { ) -> Result<String, Error> {
let tool = let tool = tools
deps.tools .get(tool_name)
.get(tool_name) .await
.await .ok_or_else(|| crate::error::ToolError::NotFound {
.ok_or_else(|| crate::error::ToolError::NotFound { name: tool_name.to_string(),
name: tool_name.to_string(), })?;
})?;
// Tools requiring approval are blocked in autonomous jobs // Tools requiring approval are blocked in autonomous jobs
if tool.requires_approval() { if tool.requires_approval() {
@@ -390,46 +402,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.into()); .into());
} }
// Fetch job context early so we have the real user_id for hooks // Get job context for the tool
let job_ctx = deps.context_manager.get_context(job_id).await?; let job_ctx = context_manager.get_context(job_id).await?;
// Run BeforeToolCall hook
let params = {
use crate::hooks::{HookError, HookEvent, HookOutcome};
let event = HookEvent::ToolCall {
tool_name: tool_name.to_string(),
parameters: params.clone(),
user_id: job_ctx.user_id.clone(),
context: format!("job:{}", job_id),
};
match deps.hooks.run(&event).await {
Err(HookError::Rejected { reason }) => {
return Err(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Blocked by hook: {}", reason),
}
.into());
}
Err(err) => {
return Err(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Blocked by hook failure mode: {}", err),
}
.into());
}
Ok(HookOutcome::Continue {
modified: Some(new_params),
}) => serde_json::from_str(&new_params).unwrap_or_else(|e| {
tracing::warn!(
tool = %tool_name,
"Hook returned non-JSON modification for ToolCall, ignoring: {}",
e
);
params.clone()
}),
_ => params.clone(),
}
};
if job_ctx.state == JobState::Cancelled { if job_ctx.state == JobState::Cancelled {
return Err(crate::error::ToolError::ExecutionFailed { return Err(crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(), name: tool_name.to_string(),
@@ -439,7 +413,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
} }
// Validate tool parameters // Validate tool parameters
let validation = deps.safety.validator().validate_tool_params(&params); let validation = safety.validator().validate_tool_params(params);
if !validation.is_valid { if !validation.is_valid {
let details = validation let details = validation
.errors .errors
@@ -504,8 +478,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
Ok(Ok(output)) => { Ok(Ok(output)) => {
let output_str = serde_json::to_string_pretty(&output.result) let output_str = serde_json::to_string_pretty(&output.result)
.ok() .ok()
.map(|s| deps.safety.sanitize_tool_output(tool_name, &s).content); .map(|s| safety.sanitize_tool_output(tool_name, &s).content);
deps.context_manager context_manager
.update_memory(job_id, |mem| { .update_memory(job_id, |mem| {
let rec = mem.create_action(tool_name, params.clone()).succeed( let rec = mem.create_action(tool_name, params.clone()).succeed(
output_str.clone(), output_str.clone(),
@@ -518,8 +492,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.await .await
.ok() .ok()
} }
Ok(Err(e)) => deps Ok(Err(e)) => context_manager
.context_manager
.update_memory(job_id, |mem| { .update_memory(job_id, |mem| {
let rec = mem let rec = mem
.create_action(tool_name, params.clone()) .create_action(tool_name, params.clone())
@@ -529,8 +502,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}) })
.await .await
.ok(), .ok(),
Err(_) => deps Err(_) => context_manager
.context_manager
.update_memory(job_id, |mem| { .update_memory(job_id, |mem| {
let rec = mem let rec = mem
.create_action(tool_name, params.clone()) .create_action(tool_name, params.clone())
@@ -543,7 +515,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}; };
// Persist action to database (fire-and-forget) // Persist action to database (fire-and-forget)
if let (Some(action), Some(store)) = (action, deps.store.clone()) { if let (Some(action), Some(store)) = (action, store) {
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = store.save_action(job_id, &action).await { if let Err(e) = store.save_action(job_id, &action).await {
tracing::warn!("Failed to persist action for job {}: {}", job_id, e); tracing::warn!("Failed to persist action for job {}: {}", job_id, e);
@@ -729,7 +701,16 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
tool_name: &str, tool_name: &str,
params: &serde_json::Value, params: &serde_json::Value,
) -> Result<String, Error> { ) -> Result<String, Error> {
Self::execute_tool_inner(&self.deps, self.job_id, tool_name, params).await Self::execute_tool_inner(
self.tools().clone(),
self.context_manager().clone(),
self.safety().clone(),
self.deps.store.clone(),
self.job_id,
tool_name,
params,
)
.await
} }
async fn mark_completed(&self) -> Result<(), Error> { async fn mark_completed(&self) -> Result<(), Error> {
-208
View File
@@ -1,208 +0,0 @@
//! Boot screen displayed after all initialization completes.
//!
//! Shows a polished ANSI-styled status panel summarizing the agent's runtime
//! state: model, database, tool count, enabled features, active channels,
//! and the gateway URL.
/// All displayable fields for the boot screen.
pub struct BootInfo {
pub version: String,
pub agent_name: String,
pub llm_backend: String,
pub llm_model: String,
pub cheap_model: Option<String>,
pub db_backend: String,
pub db_connected: bool,
pub tool_count: usize,
pub gateway_url: Option<String>,
pub embeddings_enabled: bool,
pub embeddings_provider: Option<String>,
pub heartbeat_enabled: bool,
pub heartbeat_interval_secs: u64,
pub sandbox_enabled: bool,
pub claude_code_enabled: bool,
pub routines_enabled: bool,
pub channels: Vec<String>,
}
/// Print the boot screen to stdout.
pub fn print_boot_screen(info: &BootInfo) {
// ANSI codes matching existing REPL palette
let bold = "\x1b[1m";
let cyan = "\x1b[36m";
let dim = "\x1b[90m";
let yellow_underline = "\x1b[33;4m";
let reset = "\x1b[0m";
let border = format!(" {dim}{}{reset}", "\u{2576}".repeat(58));
println!();
println!("{border}");
println!();
println!(" {bold}{}{reset} v{}", info.agent_name, info.version);
println!();
// Model line
let model_display = if let Some(ref cheap) = info.cheap_model {
format!(
"{cyan}{}{reset} {dim}cheap{reset} {cyan}{}{reset}",
info.llm_model, cheap
)
} else {
format!("{cyan}{}{reset}", info.llm_model)
};
println!(
" {dim}model{reset} {model_display} {dim}via {}{reset}",
info.llm_backend
);
// Database line
let db_status = if info.db_connected {
"connected"
} else {
"none"
};
println!(
" {dim}database{reset} {cyan}{}{reset} {dim}({db_status}){reset}",
info.db_backend
);
// Tools line
println!(
" {dim}tools{reset} {cyan}{}{reset} {dim}registered{reset}",
info.tool_count
);
// Features line
let mut features = Vec::new();
if info.embeddings_enabled {
if let Some(ref provider) = info.embeddings_provider {
features.push(format!("embeddings ({provider})"));
} else {
features.push("embeddings".to_string());
}
}
if info.heartbeat_enabled {
let mins = info.heartbeat_interval_secs / 60;
features.push(format!("heartbeat ({mins}m)"));
}
if info.sandbox_enabled {
features.push("sandbox".to_string());
}
if info.claude_code_enabled {
features.push("claude-code".to_string());
}
if info.routines_enabled {
features.push("routines".to_string());
}
if !features.is_empty() {
println!(
" {dim}features{reset} {cyan}{}{reset}",
features.join(" ")
);
}
// Channels line
if !info.channels.is_empty() {
println!(
" {dim}channels{reset} {cyan}{}{reset}",
info.channels.join(" ")
);
}
// Gateway URL (highlighted)
if let Some(ref url) = info.gateway_url {
println!();
println!(" {dim}gateway{reset} {yellow_underline}{url}{reset}");
}
println!();
println!("{border}");
println!();
println!(" /help for commands, /quit to exit");
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_print_boot_screen_full() {
let info = BootInfo {
version: "0.2.0".to_string(),
agent_name: "ironclaw".to_string(),
llm_backend: "nearai".to_string(),
llm_model: "claude-3-5-sonnet-20241022".to_string(),
cheap_model: Some("gpt-4o-mini".to_string()),
db_backend: "libsql".to_string(),
db_connected: true,
tool_count: 24,
gateway_url: Some("http://127.0.0.1:3001/?token=abc123".to_string()),
embeddings_enabled: true,
embeddings_provider: Some("openai".to_string()),
heartbeat_enabled: true,
heartbeat_interval_secs: 1800,
sandbox_enabled: true,
claude_code_enabled: false,
routines_enabled: true,
channels: vec![
"repl".to_string(),
"gateway".to_string(),
"telegram".to_string(),
],
};
// Should not panic
print_boot_screen(&info);
}
#[test]
fn test_print_boot_screen_minimal() {
let info = BootInfo {
version: "0.2.0".to_string(),
agent_name: "ironclaw".to_string(),
llm_backend: "nearai".to_string(),
llm_model: "gpt-4o".to_string(),
cheap_model: None,
db_backend: "none".to_string(),
db_connected: false,
tool_count: 5,
gateway_url: None,
embeddings_enabled: false,
embeddings_provider: None,
heartbeat_enabled: false,
heartbeat_interval_secs: 0,
sandbox_enabled: false,
claude_code_enabled: false,
routines_enabled: false,
channels: vec![],
};
// Should not panic
print_boot_screen(&info);
}
#[test]
fn test_print_boot_screen_no_features() {
let info = BootInfo {
version: "0.1.0".to_string(),
agent_name: "test".to_string(),
llm_backend: "openai".to_string(),
llm_model: "gpt-4o".to_string(),
cheap_model: None,
db_backend: "postgres".to_string(),
db_connected: true,
tool_count: 10,
gateway_url: None,
embeddings_enabled: false,
embeddings_provider: None,
heartbeat_enabled: false,
heartbeat_interval_secs: 0,
sandbox_enabled: false,
claude_code_enabled: false,
routines_enabled: false,
channels: vec!["repl".to_string()],
};
// Should not panic
print_boot_screen(&info);
}
}
+2 -14
View File
@@ -184,8 +184,6 @@ pub struct ReplChannel {
debug_mode: Arc<AtomicBool>, debug_mode: Arc<AtomicBool>,
/// Whether we're currently streaming (chunks have been printed without a trailing newline). /// Whether we're currently streaming (chunks have been printed without a trailing newline).
is_streaming: Arc<AtomicBool>, is_streaming: Arc<AtomicBool>,
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
suppress_banner: Arc<AtomicBool>,
} }
impl ReplChannel { impl ReplChannel {
@@ -195,7 +193,6 @@ impl ReplChannel {
single_message: None, single_message: None,
debug_mode: Arc::new(AtomicBool::new(false)), debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)), is_streaming: Arc::new(AtomicBool::new(false)),
suppress_banner: Arc::new(AtomicBool::new(false)),
} }
} }
@@ -205,15 +202,9 @@ impl ReplChannel {
single_message: Some(message), single_message: Some(message),
debug_mode: Arc::new(AtomicBool::new(false)), debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)), is_streaming: Arc::new(AtomicBool::new(false)),
suppress_banner: Arc::new(AtomicBool::new(false)),
} }
} }
/// Suppress the one-liner startup banner (boot screen will be shown instead).
pub fn suppress_banner(&self) {
self.suppress_banner.store(true, Ordering::Relaxed);
}
fn is_debug(&self) -> bool { fn is_debug(&self) -> bool {
self.debug_mode.load(Ordering::Relaxed) self.debug_mode.load(Ordering::Relaxed)
} }
@@ -273,7 +264,6 @@ impl Channel for ReplChannel {
let (tx, rx) = mpsc::channel(32); let (tx, rx) = mpsc::channel(32);
let single_message = self.single_message.clone(); let single_message = self.single_message.clone();
let debug_mode = Arc::clone(&self.debug_mode); let debug_mode = Arc::clone(&self.debug_mode);
let suppress_banner = Arc::clone(&self.suppress_banner);
std::thread::spawn(move || { std::thread::spawn(move || {
// Single message mode: send it and return // Single message mode: send it and return
@@ -308,10 +298,8 @@ impl Channel for ReplChannel {
} }
let _ = rl.load_history(&hist_path); let _ = rl.load_history(&hist_path);
if !suppress_banner.load(Ordering::Relaxed) { println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit"); println!();
println!();
}
loop { loop {
let prompt = if debug_mode.load(Ordering::Relaxed) { let prompt = if debug_mode.load(Ordering::Relaxed) {
+6 -15
View File
@@ -12,7 +12,6 @@ let loadingOlder = false;
let jobEvents = new Map(); // job_id -> Array of events let jobEvents = new Map(); // job_id -> Array of events
let jobListRefreshTimer = null; let jobListRefreshTimer = null;
const JOB_EVENTS_CAP = 500; const JOB_EVENTS_CAP = 500;
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
// --- Auth --- // --- Auth ---
@@ -1002,12 +1001,9 @@ function buildBreadcrumb(path) {
} }
function searchMemory(query) { function searchMemory(query) {
const normalizedQuery = normalizeSearchQuery(query);
if (!normalizedQuery) return;
apiFetch('/api/memory/search', { apiFetch('/api/memory/search', {
method: 'POST', method: 'POST',
body: { query: normalizedQuery, limit: 20 }, body: { query, limit: 20 },
}).then((data) => { }).then((data) => {
const tree = document.getElementById('memory-tree'); const tree = document.getElementById('memory-tree');
tree.innerHTML = ''; tree.innerHTML = '';
@@ -1018,23 +1014,18 @@ function searchMemory(query) {
for (const result of data.results) { for (const result of data.results) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'search-result'; item.className = 'search-result';
const snippet = snippetAround(result.content, normalizedQuery, 120); const snippet = snippetAround(result.content, query, 120);
item.innerHTML = '<div class="path">' + escapeHtml(result.path) + '</div>' item.innerHTML = '<div class="path">' + escapeHtml(result.path) + '</div>'
+ '<div class="snippet">' + highlightQuery(snippet, normalizedQuery) + '</div>'; + '<div class="snippet">' + highlightQuery(snippet, query) + '</div>';
item.addEventListener('click', () => readMemoryFile(result.path)); item.addEventListener('click', () => readMemoryFile(result.path));
tree.appendChild(item); tree.appendChild(item);
} }
}).catch(() => {}); }).catch(() => {});
} }
function normalizeSearchQuery(query) {
return (typeof query === 'string' ? query : '').slice(0, MEMORY_SEARCH_QUERY_MAX_LENGTH);
}
function snippetAround(text, query, len) { function snippetAround(text, query, len) {
const normalizedQuery = normalizeSearchQuery(query);
const lower = text.toLowerCase(); const lower = text.toLowerCase();
const idx = lower.indexOf(normalizedQuery.toLowerCase()); const idx = lower.indexOf(query.toLowerCase());
if (idx < 0) return text.substring(0, len); if (idx < 0) return text.substring(0, len);
const start = Math.max(0, idx - Math.floor(len / 2)); const start = Math.max(0, idx - Math.floor(len / 2));
const end = Math.min(text.length, start + len); const end = Math.min(text.length, start + len);
@@ -1047,11 +1038,11 @@ function snippetAround(text, query, len) {
function highlightQuery(text, query) { function highlightQuery(text, query) {
if (!query) return escapeHtml(text); if (!query) return escapeHtml(text);
const escaped = escapeHtml(text); const escaped = escapeHtml(text);
const normalizedQuery = normalizeSearchQuery(query); const queryEscaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const queryEscaped = normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp('(' + queryEscaped + ')', 'gi'); const re = new RegExp('(' + queryEscaped + ')', 'gi');
return escaped.replace(re, '<mark>$1</mark>'); return escaped.replace(re, '<mark>$1</mark>');
} }
// --- Logs --- // --- Logs ---
const LOG_MAX_ENTRIES = 2000; const LOG_MAX_ENTRIES = 2000;
+1 -5
View File
@@ -5,11 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IronClaw</title> <title>IronClaw</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<script <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
src="https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js"
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
crossorigin="anonymous"
></script>
</head> </head>
<body> <body>
<!-- Auth Screen --> <!-- Auth Screen -->
-22
View File
@@ -153,15 +153,6 @@ pub enum DatabaseBackend {
LibSql, LibSql,
} }
impl std::fmt::Display for DatabaseBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Postgres => write!(f, "postgres"),
Self::LibSql => write!(f, "libsql"),
}
}
}
impl std::str::FromStr for DatabaseBackend { impl std::str::FromStr for DatabaseBackend {
type Err = String; type Err = String;
@@ -397,9 +388,6 @@ impl std::str::FromStr for NearAiApiMode {
pub struct NearAiConfig { pub struct NearAiConfig {
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o") /// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
pub model: String, 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) /// Base URL for the NEAR AI API (default: https://api.near.ai)
pub base_url: String, pub base_url: String,
/// Base URL for auth/refresh endpoints (default: https://private.near.ai) /// Base URL for auth/refresh endpoints (default: https://private.near.ai)
@@ -419,13 +407,6 @@ pub struct NearAiConfig {
/// With the default of 3, the provider makes up to 4 total attempts /// With the default of 3, the provider makes up to 4 total attempts
/// (1 initial + 3 retries) before giving up. /// (1 initial + 3 retries) before giving up.
pub max_retries: u32, pub max_retries: u32,
/// Cooldown duration in seconds for the failover provider (default: 300).
/// When a provider accumulates enough consecutive failures it is skipped
/// for this many seconds.
pub failover_cooldown_secs: u64,
/// Number of consecutive retryable failures before a provider enters
/// cooldown (default: 3).
pub failover_cooldown_threshold: u32,
} }
impl LlmConfig { impl LlmConfig {
@@ -473,7 +454,6 @@ impl LlmConfig {
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
.to_string() .to_string()
}), }),
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
base_url: optional_env("NEARAI_BASE_URL")? base_url: optional_env("NEARAI_BASE_URL")?
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()), .unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
auth_base_url: optional_env("NEARAI_AUTH_URL")? auth_base_url: optional_env("NEARAI_AUTH_URL")?
@@ -485,8 +465,6 @@ impl LlmConfig {
api_key: nearai_api_key, api_key: nearai_api_key,
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?, fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?, max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
failover_cooldown_secs: parse_optional_env("LLM_FAILOVER_COOLDOWN_SECS", 300)?,
failover_cooldown_threshold: parse_optional_env("LLM_FAILOVER_THRESHOLD", 3)?,
}; };
// Resolve provider-specific configs based on backend // Resolve provider-specific configs based on backend
-3
View File
@@ -40,9 +40,6 @@ pub enum Error {
#[error("Workspace error: {0}")] #[error("Workspace error: {0}")]
Workspace(#[from] WorkspaceError), Workspace(#[from] WorkspaceError),
#[error("Hook error: {0}")]
Hook(#[from] crate::hooks::HookError),
#[error("Orchestrator error: {0}")] #[error("Orchestrator error: {0}")]
Orchestrator(#[from] OrchestratorError), Orchestrator(#[from] OrchestratorError),
+5 -1
View File
@@ -20,6 +20,10 @@ impl CostEstimator {
// Default tool costs (in USD or equivalent) // Default tool costs (in USD or equivalent)
tool_costs.insert("http".to_string(), dec!(0.0001)); // API call 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("echo".to_string(), dec!(0.0)); // Free
tool_costs.insert("time".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 tool_costs.insert("json".to_string(), dec!(0.0)); // Free
@@ -70,7 +74,7 @@ mod tests {
let estimator = CostEstimator::new(); let estimator = CostEstimator::new();
assert_eq!(estimator.estimate_tool("echo"), dec!(0.0)); assert_eq!(estimator.estimate_tool("echo"), dec!(0.0));
assert_eq!(estimator.estimate_tool("http"), dec!(0.0001)); assert_eq!(estimator.estimate_tool("marketplace"), dec!(0.01));
assert!(estimator.estimate_tool("unknown") > dec!(0.0)); assert!(estimator.estimate_tool("unknown") > dec!(0.0));
} }
+4
View File
@@ -16,6 +16,10 @@ impl TimeEstimator {
// Default tool durations // Default tool durations
tool_durations.insert("http".to_string(), Duration::from_secs(5)); 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("echo".to_string(), Duration::from_millis(10));
tool_durations.insert("time".to_string(), Duration::from_millis(1)); tool_durations.insert("time".to_string(), Duration::from_millis(1));
tool_durations.insert("json".to_string(), Duration::from_millis(5)); tool_durations.insert("json".to_string(), Duration::from_millis(5));
-199
View File
@@ -1,199 +0,0 @@
//! Core hook types and traits.
use std::time::Duration;
use async_trait::async_trait;
/// Points in the agent lifecycle where hooks can be attached.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookPoint {
/// Before processing an inbound user message.
BeforeInbound,
/// Before executing a tool call.
BeforeToolCall,
/// Before sending an outbound response.
BeforeOutbound,
/// When a new session starts.
OnSessionStart,
/// When a session ends (pruned or expired).
OnSessionEnd,
/// Transform the final response before completing a turn.
TransformResponse,
}
/// Contextual data carried with each hook invocation.
#[derive(Debug, Clone)]
pub enum HookEvent {
/// An inbound user message about to be processed.
Inbound {
user_id: String,
channel: String,
content: String,
thread_id: Option<String>,
},
/// A tool call about to be executed.
ToolCall {
tool_name: String,
parameters: serde_json::Value,
user_id: String,
/// "chat" for interactive, or a job ID string for autonomous jobs.
context: String,
},
/// An outbound response about to be sent.
Outbound {
user_id: String,
channel: String,
content: String,
thread_id: Option<String>,
},
/// A new session was created.
SessionStart { user_id: String, session_id: String },
/// A session was ended (pruned).
SessionEnd { user_id: String, session_id: String },
/// The final response is being transformed before completing a turn.
ResponseTransform {
user_id: String,
thread_id: String,
response: String,
},
}
impl HookEvent {
/// Returns the [`HookPoint`] this event corresponds to.
pub fn hook_point(&self) -> HookPoint {
match self {
HookEvent::Inbound { .. } => HookPoint::BeforeInbound,
HookEvent::ToolCall { .. } => HookPoint::BeforeToolCall,
HookEvent::Outbound { .. } => HookPoint::BeforeOutbound,
HookEvent::SessionStart { .. } => HookPoint::OnSessionStart,
HookEvent::SessionEnd { .. } => HookPoint::OnSessionEnd,
HookEvent::ResponseTransform { .. } => HookPoint::TransformResponse,
}
}
/// Apply a modification string to the event's primary content field.
pub fn apply_modification(&mut self, modified: &str) {
match self {
HookEvent::Inbound { content, .. } | HookEvent::Outbound { content, .. } => {
*content = modified.to_string();
}
HookEvent::ToolCall { parameters, .. } => match serde_json::from_str(modified) {
Ok(parsed) => *parameters = parsed,
Err(e) => {
tracing::warn!(
"Hook returned non-JSON modification for ToolCall, ignoring: {}",
e
);
}
},
HookEvent::ResponseTransform { response, .. } => {
*response = modified.to_string();
}
HookEvent::SessionStart { .. } | HookEvent::SessionEnd { .. } => {
// Session events don't have modifiable content
}
}
}
}
/// The result of executing a hook.
#[derive(Debug, Clone)]
pub enum HookOutcome {
/// Continue processing, optionally with modified content.
Continue {
/// If `Some`, replace the event's primary content with this value.
modified: Option<String>,
},
/// Reject the event entirely.
Reject {
/// Human-readable reason for the rejection.
reason: String,
},
}
impl HookOutcome {
/// Shorthand for `Continue { modified: None }`.
pub fn ok() -> Self {
HookOutcome::Continue { modified: None }
}
/// Shorthand for `Continue { modified: Some(value) }`.
pub fn modify(value: String) -> Self {
HookOutcome::Continue {
modified: Some(value),
}
}
/// Shorthand for `Reject { reason }`.
pub fn reject(reason: impl Into<String>) -> Self {
HookOutcome::Reject {
reason: reason.into(),
}
}
}
/// How to handle hook execution failures.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookFailureMode {
/// On error/timeout, continue processing as if the hook returned `ok()`.
FailOpen,
/// On error/timeout, reject the event.
FailClosed,
}
/// Hook execution errors.
#[derive(Debug, thiserror::Error)]
pub enum HookError {
#[error("Hook execution failed: {reason}")]
ExecutionFailed { reason: String },
#[error("Hook timed out after {timeout:?}")]
Timeout { timeout: Duration },
#[error("Hook rejected: {reason}")]
Rejected { reason: String },
}
/// Context passed to hooks alongside the event.
pub struct HookContext {
/// Arbitrary metadata hooks can use.
pub metadata: serde_json::Value,
}
impl Default for HookContext {
fn default() -> Self {
Self {
metadata: serde_json::Value::Null,
}
}
}
/// Trait for implementing lifecycle hooks.
///
/// Hooks intercept and can modify agent operations at well-defined points.
#[async_trait]
pub trait Hook: Send + Sync {
/// A unique name for this hook.
fn name(&self) -> &str;
/// The lifecycle points this hook should be called at.
fn hook_points(&self) -> &[HookPoint];
/// How to handle failures in this hook.
///
/// Default: `FailOpen` (continue on error).
fn failure_mode(&self) -> HookFailureMode {
HookFailureMode::FailOpen
}
/// Maximum time this hook is allowed to run.
///
/// Default: 5 seconds.
fn timeout(&self) -> Duration {
Duration::from_secs(5)
}
/// Execute the hook.
async fn execute(&self, event: &HookEvent, ctx: &HookContext)
-> Result<HookOutcome, HookError>;
}
-19
View File
@@ -1,19 +0,0 @@
//! Lifecycle hooks for intercepting and transforming agent operations.
//!
//! The hook system provides 6 well-defined interception points:
//!
//! - **BeforeInbound** — Before processing an inbound user message
//! - **BeforeToolCall** — Before executing a tool call
//! - **BeforeOutbound** — Before sending an outbound response
//! - **OnSessionStart** — When a new session starts
//! - **OnSessionEnd** — When a session ends
//! - **TransformResponse** — Transform the final response before completing a turn
//!
//! Hooks are executed in priority order (lower number = higher priority).
//! Each hook can pass through, modify content, or reject the event.
pub mod hook;
pub mod registry;
pub use hook::{Hook, HookContext, HookError, HookEvent, HookFailureMode, HookOutcome, HookPoint};
pub use registry::HookRegistry;
-555
View File
@@ -1,555 +0,0 @@
//! Hook registry for managing and executing lifecycle hooks.
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::hooks::hook::{Hook, HookContext, HookError, HookEvent, HookFailureMode, HookOutcome};
/// A registered hook with its priority.
struct HookEntry {
hook: Arc<dyn Hook>,
priority: u32,
}
/// Registry that manages hooks and executes them at lifecycle points.
///
/// Hooks are executed in priority order (lower number = higher priority).
/// A `Reject` outcome stops the chain immediately.
/// A `Modify` outcome chains through subsequent hooks.
pub struct HookRegistry {
hooks: RwLock<Vec<HookEntry>>,
}
impl HookRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
hooks: RwLock::new(Vec::new()),
}
}
/// Register a hook with default priority (100).
pub async fn register(&self, hook: Arc<dyn Hook>) {
self.register_with_priority(hook, 100).await;
}
/// Register a hook with a specific priority.
///
/// Lower priority number = runs first.
pub async fn register_with_priority(&self, hook: Arc<dyn Hook>, priority: u32) {
let mut hooks = self.hooks.write().await;
hooks.push(HookEntry { hook, priority });
hooks.sort_by_key(|e| e.priority);
}
/// Unregister a hook by name. Returns `true` if it was found and removed.
pub async fn unregister(&self, name: &str) -> bool {
let mut hooks = self.hooks.write().await;
let before = hooks.len();
hooks.retain(|e| e.hook.name() != name);
hooks.len() < before
}
/// List all registered hook names (in priority order).
pub async fn list(&self) -> Vec<String> {
let hooks = self.hooks.read().await;
hooks.iter().map(|e| e.hook.name().to_string()).collect()
}
/// Run all hooks matching the event's hook point.
///
/// - Hooks run in priority order (lowest first).
/// - `Reject` stops the chain immediately.
/// - `Modify` chains the modification through subsequent hooks.
/// - Timeout/error handling respects each hook's `failure_mode`.
pub async fn run(&self, event: &HookEvent) -> Result<HookOutcome, HookError> {
let point = event.hook_point();
let ctx = HookContext::default();
// Clone matching hooks and drop the read guard before executing.
// Each hook can run up to its timeout, so holding the guard would
// block concurrent register/unregister/run calls.
let matching: Vec<Arc<dyn Hook>> = {
let hooks = self.hooks.read().await;
hooks
.iter()
.filter(|e| e.hook.hook_points().contains(&point))
.map(|e| e.hook.clone())
.collect()
};
if matching.is_empty() {
return Ok(HookOutcome::ok());
}
let mut current_event = event.clone();
for hook in &matching {
let timeout = hook.timeout();
let result = tokio::time::timeout(timeout, hook.execute(&current_event, &ctx)).await;
match result {
Ok(Ok(HookOutcome::Reject { reason })) => {
tracing::debug!(hook = hook.name(), "Hook rejected: {}", reason);
return Err(HookError::Rejected { reason });
}
Ok(Ok(HookOutcome::Continue {
modified: Some(value),
})) => {
tracing::debug!(hook = hook.name(), "Hook modified content");
current_event.apply_modification(&value);
}
Ok(Ok(HookOutcome::Continue { modified: None })) => {
// No-op, continue chain
}
Ok(Err(err)) => match hook.failure_mode() {
HookFailureMode::FailOpen => {
tracing::warn!(hook = hook.name(), "Hook failed (fail-open): {}", err);
}
HookFailureMode::FailClosed => {
tracing::warn!(hook = hook.name(), "Hook failed (fail-closed): {}", err);
return Err(HookError::ExecutionFailed {
reason: format!("Hook '{}' failed: {}", hook.name(), err),
});
}
},
Err(_elapsed) => match hook.failure_mode() {
HookFailureMode::FailOpen => {
tracing::warn!(
hook = hook.name(),
"Hook timed out (fail-open) after {:?}",
timeout
);
}
HookFailureMode::FailClosed => {
tracing::warn!(
hook = hook.name(),
"Hook timed out (fail-closed) after {:?}",
timeout
);
return Err(HookError::Timeout { timeout });
}
},
}
}
// Determine final outcome by comparing with original event
let modified = extract_content(&current_event);
let original = extract_content(event);
if modified != original {
Ok(HookOutcome::modify(modified))
} else {
Ok(HookOutcome::ok())
}
}
}
impl Default for HookRegistry {
fn default() -> Self {
Self::new()
}
}
/// Extract the primary content string from a hook event.
fn extract_content(event: &HookEvent) -> String {
match event {
HookEvent::Inbound { content, .. } | HookEvent::Outbound { content, .. } => content.clone(),
HookEvent::ToolCall { parameters, .. } => {
serde_json::to_string(parameters).unwrap_or_default()
}
HookEvent::ResponseTransform { response, .. } => response.clone(),
HookEvent::SessionStart { session_id, .. } | HookEvent::SessionEnd { session_id, .. } => {
session_id.clone()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::hook::{HookFailureMode, HookPoint};
use async_trait::async_trait;
use std::time::Duration;
/// A test hook that always returns ok.
struct PassthroughHook {
name: String,
points: Vec<HookPoint>,
}
#[async_trait]
impl Hook for PassthroughHook {
fn name(&self) -> &str {
&self.name
}
fn hook_points(&self) -> &[HookPoint] {
&self.points
}
async fn execute(
&self,
_event: &HookEvent,
_ctx: &HookContext,
) -> Result<HookOutcome, HookError> {
Ok(HookOutcome::ok())
}
}
/// A hook that modifies content by appending a suffix.
struct ModifyHook {
name: String,
suffix: String,
points: Vec<HookPoint>,
}
#[async_trait]
impl Hook for ModifyHook {
fn name(&self) -> &str {
&self.name
}
fn hook_points(&self) -> &[HookPoint] {
&self.points
}
async fn execute(
&self,
event: &HookEvent,
_ctx: &HookContext,
) -> Result<HookOutcome, HookError> {
let content = extract_content(event);
Ok(HookOutcome::modify(format!("{}{}", content, self.suffix)))
}
}
/// A hook that always rejects.
struct RejectHook {
name: String,
reason: String,
points: Vec<HookPoint>,
}
#[async_trait]
impl Hook for RejectHook {
fn name(&self) -> &str {
&self.name
}
fn hook_points(&self) -> &[HookPoint] {
&self.points
}
async fn execute(
&self,
_event: &HookEvent,
_ctx: &HookContext,
) -> Result<HookOutcome, HookError> {
Ok(HookOutcome::reject(&self.reason))
}
}
/// A hook that always errors.
struct ErrorHook {
name: String,
points: Vec<HookPoint>,
failure_mode: HookFailureMode,
}
#[async_trait]
impl Hook for ErrorHook {
fn name(&self) -> &str {
&self.name
}
fn hook_points(&self) -> &[HookPoint] {
&self.points
}
fn failure_mode(&self) -> HookFailureMode {
self.failure_mode
}
async fn execute(
&self,
_event: &HookEvent,
_ctx: &HookContext,
) -> Result<HookOutcome, HookError> {
Err(HookError::ExecutionFailed {
reason: "test error".into(),
})
}
}
/// A hook that sleeps longer than its timeout.
struct SlowHook {
name: String,
points: Vec<HookPoint>,
failure_mode: HookFailureMode,
}
#[async_trait]
impl Hook for SlowHook {
fn name(&self) -> &str {
&self.name
}
fn hook_points(&self) -> &[HookPoint] {
&self.points
}
fn failure_mode(&self) -> HookFailureMode {
self.failure_mode
}
fn timeout(&self) -> Duration {
Duration::from_millis(50)
}
async fn execute(
&self,
_event: &HookEvent,
_ctx: &HookContext,
) -> Result<HookOutcome, HookError> {
tokio::time::sleep(Duration::from_millis(200)).await;
Ok(HookOutcome::ok())
}
}
fn test_event() -> HookEvent {
HookEvent::Inbound {
user_id: "user-1".into(),
channel: "test".into(),
content: "hello".into(),
thread_id: None,
}
}
#[tokio::test]
async fn test_empty_registry_returns_ok() {
let registry = HookRegistry::new();
let result = registry.run(&test_event()).await;
assert!(result.is_ok());
assert!(matches!(
result.unwrap(),
HookOutcome::Continue { modified: None }
));
}
#[tokio::test]
async fn test_register_and_list() {
let registry = HookRegistry::new();
registry
.register(Arc::new(PassthroughHook {
name: "hook-a".into(),
points: vec![HookPoint::BeforeInbound],
}))
.await;
registry
.register(Arc::new(PassthroughHook {
name: "hook-b".into(),
points: vec![HookPoint::BeforeInbound],
}))
.await;
let names = registry.list().await;
assert_eq!(names, vec!["hook-a", "hook-b"]);
}
#[tokio::test]
async fn test_priority_ordering() {
let registry = HookRegistry::new();
// Register in reverse priority order
registry
.register_with_priority(
Arc::new(ModifyHook {
name: "low-prio".into(),
suffix: "-LOW".into(),
points: vec![HookPoint::BeforeInbound],
}),
200,
)
.await;
registry
.register_with_priority(
Arc::new(ModifyHook {
name: "high-prio".into(),
suffix: "-HIGH".into(),
points: vec![HookPoint::BeforeInbound],
}),
10,
)
.await;
// Should run in priority order: high-prio first, then low-prio
let names = registry.list().await;
assert_eq!(names[0], "high-prio");
assert_eq!(names[1], "low-prio");
let result = registry.run(&test_event()).await.unwrap();
match result {
HookOutcome::Continue { modified: Some(m) } => {
// "hello" -> "hello-HIGH" -> "hello-HIGH-LOW"
assert_eq!(m, "hello-HIGH-LOW");
}
other => panic!("Expected modification chain, got: {:?}", other),
}
}
#[tokio::test]
async fn test_reject_stops_chain() {
let registry = HookRegistry::new();
registry
.register_with_priority(
Arc::new(RejectHook {
name: "blocker".into(),
reason: "blocked".into(),
points: vec![HookPoint::BeforeInbound],
}),
10,
)
.await;
registry
.register_with_priority(
Arc::new(ModifyHook {
name: "modifier".into(),
suffix: "-MODIFIED".into(),
points: vec![HookPoint::BeforeInbound],
}),
20,
)
.await;
let result = registry.run(&test_event()).await;
assert!(result.is_err());
match result.unwrap_err() {
HookError::Rejected { reason } => assert_eq!(reason, "blocked"),
other => panic!("Expected Rejected, got: {:?}", other),
}
}
#[tokio::test]
async fn test_modification_chaining() {
let registry = HookRegistry::new();
registry
.register_with_priority(
Arc::new(ModifyHook {
name: "first".into(),
suffix: "-A".into(),
points: vec![HookPoint::BeforeInbound],
}),
10,
)
.await;
registry
.register_with_priority(
Arc::new(ModifyHook {
name: "second".into(),
suffix: "-B".into(),
points: vec![HookPoint::BeforeInbound],
}),
20,
)
.await;
let result = registry.run(&test_event()).await.unwrap();
match result {
HookOutcome::Continue { modified: Some(m) } => {
assert_eq!(m, "hello-A-B");
}
other => panic!("Expected chained modification, got: {:?}", other),
}
}
#[tokio::test]
async fn test_fail_open_on_error() {
let registry = HookRegistry::new();
registry
.register(Arc::new(ErrorHook {
name: "err-open".into(),
points: vec![HookPoint::BeforeInbound],
failure_mode: HookFailureMode::FailOpen,
}))
.await;
let result = registry.run(&test_event()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_fail_closed_on_error() {
let registry = HookRegistry::new();
registry
.register(Arc::new(ErrorHook {
name: "err-closed".into(),
points: vec![HookPoint::BeforeInbound],
failure_mode: HookFailureMode::FailClosed,
}))
.await;
let result = registry.run(&test_event()).await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
HookError::ExecutionFailed { .. }
));
}
#[tokio::test]
async fn test_fail_open_on_timeout() {
let registry = HookRegistry::new();
registry
.register(Arc::new(SlowHook {
name: "slow-open".into(),
points: vec![HookPoint::BeforeInbound],
failure_mode: HookFailureMode::FailOpen,
}))
.await;
let result = registry.run(&test_event()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_fail_closed_on_timeout() {
let registry = HookRegistry::new();
registry
.register(Arc::new(SlowHook {
name: "slow-closed".into(),
points: vec![HookPoint::BeforeInbound],
failure_mode: HookFailureMode::FailClosed,
}))
.await;
let result = registry.run(&test_event()).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), HookError::Timeout { .. }));
}
#[tokio::test]
async fn test_unregister() {
let registry = HookRegistry::new();
registry
.register(Arc::new(PassthroughHook {
name: "removable".into(),
points: vec![HookPoint::BeforeInbound],
}))
.await;
assert_eq!(registry.list().await.len(), 1);
assert!(registry.unregister("removable").await);
assert_eq!(registry.list().await.len(), 0);
// Unregistering non-existent returns false
assert!(!registry.unregister("nonexistent").await);
}
#[tokio::test]
async fn test_hooks_only_match_their_points() {
let registry = HookRegistry::new();
registry
.register(Arc::new(RejectHook {
name: "outbound-only".into(),
reason: "blocked".into(),
points: vec![HookPoint::BeforeOutbound],
}))
.await;
// Inbound event should not be affected by outbound-only hook
let result = registry.run(&test_event()).await;
assert!(result.is_ok());
}
}
-2
View File
@@ -39,7 +39,6 @@
//! - **Continuous learning** - Improve estimates from historical data //! - **Continuous learning** - Improve estimates from historical data
pub mod agent; pub mod agent;
pub mod boot_screen;
pub mod bootstrap; pub mod bootstrap;
pub mod channels; pub mod channels;
pub mod cli; pub mod cli;
@@ -51,7 +50,6 @@ pub mod estimation;
pub mod evaluation; pub mod evaluation;
pub mod extensions; pub mod extensions;
pub mod history; pub mod history;
pub mod hooks;
pub mod llm; pub mod llm;
pub mod orchestrator; pub mod orchestrator;
pub mod pairing; pub mod pairing;
+8 -542
View File
@@ -2,15 +2,10 @@
//! //!
//! Wraps multiple LlmProvider instances and tries each in sequence //! Wraps multiple LlmProvider instances and tries each in sequence
//! until one succeeds. Transparent to callers --- same LlmProvider trait. //! until one succeeds. Transparent to callers --- same LlmProvider trait.
//!
//! Providers that fail repeatedly are temporarily placed in cooldown
//! so subsequent requests skip them, reducing latency when a provider
//! is known to be down. Cooldown state is lock-free (atomics only).
use std::future::Future; use std::future::Future;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use async_trait::async_trait; use async_trait::async_trait;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -46,217 +41,61 @@ fn is_retryable(err: &LlmError) -> bool {
) )
} }
/// Configuration for per-provider cooldown behavior.
///
/// When a provider accumulates `failure_threshold` consecutive retryable
/// failures, it enters cooldown for `cooldown_duration`. During cooldown
/// the provider is skipped (unless *all* providers are in cooldown, in
/// which case the oldest-cooled one is tried).
#[derive(Debug, Clone)]
pub struct CooldownConfig {
/// How long a provider stays in cooldown after exceeding the threshold.
pub cooldown_duration: Duration,
/// Number of consecutive retryable failures before cooldown activates.
pub failure_threshold: u32,
}
impl Default for CooldownConfig {
fn default() -> Self {
Self {
cooldown_duration: Duration::from_secs(300),
failure_threshold: 3,
}
}
}
/// Per-provider cooldown state, entirely lock-free.
///
/// All atomic operations use `Relaxed` ordering — consistent with the
/// existing `last_used` field. Stale reads are harmless: the worst case
/// is one extra attempt against a provider that just entered cooldown.
struct ProviderCooldown {
/// Consecutive retryable failures. Reset to 0 on success.
failure_count: AtomicU32,
/// Nanoseconds since `epoch` when cooldown was activated.
/// 0 means the provider is NOT in cooldown.
cooldown_activated_nanos: AtomicU64,
}
impl ProviderCooldown {
fn new() -> Self {
Self {
failure_count: AtomicU32::new(0),
cooldown_activated_nanos: AtomicU64::new(0),
}
}
/// Check whether the provider is currently in cooldown.
fn is_in_cooldown(&self, now_nanos: u64, cooldown_nanos: u64) -> bool {
let activated = self.cooldown_activated_nanos.load(Ordering::Relaxed);
activated != 0 && now_nanos.saturating_sub(activated) < cooldown_nanos
}
/// Record a retryable failure. Returns `true` if the threshold was
/// just reached (caller should activate cooldown).
fn record_failure(&self, threshold: u32) -> bool {
let prev = self.failure_count.fetch_add(1, Ordering::Relaxed);
prev + 1 >= threshold
}
/// Activate cooldown at the given timestamp.
fn activate_cooldown(&self, now_nanos: u64) {
self.cooldown_activated_nanos
.store(now_nanos, Ordering::Relaxed);
}
/// Reset failure count and clear cooldown (called on success).
fn reset(&self) {
self.failure_count.store(0, Ordering::Relaxed);
self.cooldown_activated_nanos.store(0, Ordering::Relaxed);
}
}
/// An LLM provider that wraps multiple providers and tries each in sequence /// An LLM provider that wraps multiple providers and tries each in sequence
/// on transient failures. /// on transient failures.
/// ///
/// The first provider in the list is the primary. If it fails with a retryable /// The first provider in the list is the primary. If it fails with a retryable
/// error, the next provider is tried, and so on. Non-retryable errors /// error, the next provider is tried, and so on. Non-retryable errors
/// (e.g. `AuthFailed`, `ContextLengthExceeded`) propagate immediately. /// (e.g. `AuthFailed`, `ContextLengthExceeded`) propagate immediately.
///
/// Providers that repeatedly fail with retryable errors are temporarily
/// placed in cooldown and skipped, reducing latency.
pub struct FailoverProvider { pub struct FailoverProvider {
providers: Vec<Arc<dyn LlmProvider>>, providers: Vec<Arc<dyn LlmProvider>>,
/// Index of the provider that last handled a request successfully. /// Index of the provider that last handled a request successfully.
/// Used by `model_name()` and `cost_per_token()` so downstream cost /// Used by `model_name()` and `cost_per_token()` so downstream cost
/// tracking reflects the provider that actually served the request. /// tracking reflects the provider that actually served the request.
last_used: AtomicUsize, last_used: AtomicUsize,
/// Per-provider cooldown tracking (same length as `providers`).
cooldowns: Vec<ProviderCooldown>,
/// Reference instant for computing elapsed nanos. Shared across all
/// cooldown timestamps so they are comparable.
epoch: Instant,
/// Cooldown configuration.
cooldown_config: CooldownConfig,
} }
impl FailoverProvider { impl FailoverProvider {
/// Create a new failover provider with default cooldown settings. /// Create a new failover provider.
/// ///
/// Returns an error if `providers` is empty. /// Returns an error if `providers` is empty.
pub fn new(providers: Vec<Arc<dyn LlmProvider>>) -> Result<Self, LlmError> { pub fn new(providers: Vec<Arc<dyn LlmProvider>>) -> Result<Self, LlmError> {
Self::with_cooldown(providers, CooldownConfig::default())
}
/// Create a new failover provider with explicit cooldown configuration.
///
/// Returns an error if `providers` is empty.
pub fn with_cooldown(
providers: Vec<Arc<dyn LlmProvider>>,
cooldown_config: CooldownConfig,
) -> Result<Self, LlmError> {
if providers.is_empty() { if providers.is_empty() {
return Err(LlmError::RequestFailed { return Err(LlmError::RequestFailed {
provider: "failover".to_string(), provider: "failover".to_string(),
reason: "FailoverProvider requires at least one provider".to_string(), reason: "FailoverProvider requires at least one provider".to_string(),
}); });
} }
let cooldowns = (0..providers.len())
.map(|_| ProviderCooldown::new())
.collect();
Ok(Self { Ok(Self {
providers, providers,
last_used: AtomicUsize::new(0), last_used: AtomicUsize::new(0),
cooldowns,
epoch: Instant::now(),
cooldown_config,
}) })
} }
/// Nanoseconds elapsed since `self.epoch`.
///
/// Truncates `u128` → `u64` (wraps after ~584 years of continuous
/// uptime). Acceptable because `epoch` is set at construction time.
fn now_nanos(&self) -> u64 {
self.epoch.elapsed().as_nanos() as u64
}
/// Try each provider in sequence until one succeeds or all fail. /// Try each provider in sequence until one succeeds or all fail.
///
/// Providers in cooldown are skipped unless *all* providers are in
/// cooldown, in which case the one with the oldest cooldown timestamp
/// (most likely to have recovered) is tried.
async fn try_providers<T, F, Fut>(&self, mut call: F) -> Result<T, LlmError> async fn try_providers<T, F, Fut>(&self, mut call: F) -> Result<T, LlmError>
where where
F: FnMut(Arc<dyn LlmProvider>) -> Fut, F: FnMut(Arc<dyn LlmProvider>) -> Fut,
Fut: Future<Output = Result<T, LlmError>>, Fut: Future<Output = Result<T, LlmError>>,
{ {
let now_nanos = self.now_nanos();
let cooldown_nanos = self.cooldown_config.cooldown_duration.as_nanos() as u64;
// Partition providers into available and cooled-down.
let (mut available, cooled_down): (Vec<usize>, Vec<usize>) = (0..self.providers.len())
.partition(|&i| !self.cooldowns[i].is_in_cooldown(now_nanos, cooldown_nanos));
// Log skipped providers.
for &i in &cooled_down {
tracing::info!(
provider = %self.providers[i].model_name(),
"Skipping provider (in cooldown)"
);
}
// Never skip ALL providers: if every provider is in cooldown, pick
// the one with the oldest cooldown activation (most likely recovered).
if available.is_empty() {
let oldest = (0..self.providers.len())
.min_by_key(|&i| {
self.cooldowns[i]
.cooldown_activated_nanos
.load(Ordering::Relaxed)
})
.expect("providers list is non-empty");
tracing::info!(
provider = %self.providers[oldest].model_name(),
"All providers in cooldown, trying oldest-cooled provider"
);
available.push(oldest);
}
let mut last_error: Option<LlmError> = None; let mut last_error: Option<LlmError> = None;
for (pos, &i) in available.iter().enumerate() { for (i, provider) in self.providers.iter().enumerate() {
let provider = &self.providers[i];
let result = call(Arc::clone(provider)).await; let result = call(Arc::clone(provider)).await;
match result { match result {
Ok(response) => { Ok(response) => {
self.last_used.store(i, Ordering::Relaxed); self.last_used.store(i, Ordering::Relaxed);
self.cooldowns[i].reset();
return Ok(response); return Ok(response);
} }
Err(err) => { Err(err) => {
if !is_retryable(&err) { if !is_retryable(&err) {
return Err(err); return Err(err);
} }
if i + 1 < self.providers.len() {
// Increment failure count; activate cooldown if threshold reached.
if self.cooldowns[i].record_failure(self.cooldown_config.failure_threshold) {
let nanos = self.now_nanos();
self.cooldowns[i].activate_cooldown(nanos);
tracing::warn!(
provider = %provider.model_name(),
threshold = self.cooldown_config.failure_threshold,
cooldown_secs = self.cooldown_config.cooldown_duration.as_secs(),
"Provider entered cooldown after repeated failures"
);
}
if pos + 1 < available.len() {
let next_i = available[pos + 1];
tracing::warn!( tracing::warn!(
provider = %provider.model_name(), provider = %provider.model_name(),
error = %err, error = %err,
next_provider = %self.providers[next_i].model_name(), next_provider = %self.providers[i + 1].model_name(),
"Provider failed with retryable error, trying next provider" "Provider failed with retryable error, trying next provider"
); );
} }
@@ -265,9 +104,9 @@ impl FailoverProvider {
} }
} }
// SAFETY: `available` is non-empty (guaranteed above), so at least one // SAFETY: providers is non-empty (checked in `new`), so at least one
// iteration ran and `last_error` is `Some`. // iteration ran and `last_error` is `Some`.
Err(last_error.expect("available providers list is non-empty")) Err(last_error.expect("providers list is non-empty"))
} }
} }
@@ -327,6 +166,7 @@ mod tests {
use super::*; use super::*;
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration;
use crate::llm::provider::{CompletionResponse, FinishReason, ToolCompletionResponse}; use crate::llm::provider::{CompletionResponse, FinishReason, ToolCompletionResponse};
@@ -592,380 +432,6 @@ mod tests {
assert!(models.contains(&"model-b".to_string())); assert!(models.contains(&"model-b".to_string()));
} }
// --- MultiCallMockProvider for cooldown tests ---
//
// Unlike `MockProvider` which uses `.take()` (single-use), this mock
// tracks a call counter and returns errors for the first N calls,
// then succeeds.
struct MultiCallMockProvider {
name: String,
/// How many calls should fail before succeeding. 0 = always succeed.
fail_count: u32,
/// Atomically tracks how many times `complete` has been called.
calls: AtomicU32,
/// If true, failures are non-retryable (AuthFailed).
non_retryable: bool,
}
impl MultiCallMockProvider {
/// Always succeeds.
fn always_ok(name: &str) -> Self {
Self {
name: name.to_string(),
fail_count: 0,
calls: AtomicU32::new(0),
non_retryable: false,
}
}
/// Fails with retryable error for the first `n` calls, then succeeds.
fn fail_then_ok(name: &str, n: u32) -> Self {
Self {
name: name.to_string(),
fail_count: n,
calls: AtomicU32::new(0),
non_retryable: false,
}
}
/// Always fails with retryable error.
fn always_fail(name: &str) -> Self {
Self {
name: name.to_string(),
fail_count: u32::MAX,
calls: AtomicU32::new(0),
non_retryable: false,
}
}
/// Always fails with non-retryable error.
fn always_fail_non_retryable(name: &str) -> Self {
Self {
name: name.to_string(),
fail_count: u32::MAX,
calls: AtomicU32::new(0),
non_retryable: true,
}
}
fn call_count(&self) -> u32 {
self.calls.load(Ordering::Relaxed)
}
}
#[async_trait]
impl LlmProvider for MultiCallMockProvider {
fn model_name(&self) -> &str {
&self.name
}
fn cost_per_token(&self) -> (Decimal, Decimal) {
(Decimal::ZERO, Decimal::ZERO)
}
async fn complete(
&self,
_request: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
let n = self.calls.fetch_add(1, Ordering::Relaxed);
if n < self.fail_count {
if self.non_retryable {
return Err(LlmError::AuthFailed {
provider: self.name.clone(),
});
}
return Err(LlmError::RequestFailed {
provider: self.name.clone(),
reason: format!("call {} failed", n),
});
}
Ok(CompletionResponse {
content: format!("{} ok", self.name),
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
async fn complete_with_tools(
&self,
_request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let n = self.calls.fetch_add(1, Ordering::Relaxed);
if n < self.fail_count {
if self.non_retryable {
return Err(LlmError::AuthFailed {
provider: self.name.clone(),
});
}
return Err(LlmError::RequestFailed {
provider: self.name.clone(),
reason: format!("call {} failed", n),
});
}
Ok(ToolCompletionResponse {
content: Some(format!("{} ok", self.name)),
tool_calls: vec![],
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
Ok(vec![self.name.clone()])
}
}
// --- Cooldown tests ---
// Cooldown test 1: Provider enters cooldown after `threshold` consecutive failures.
#[tokio::test]
async fn cooldown_activates_after_threshold() {
let config = CooldownConfig {
cooldown_duration: Duration::from_secs(300),
failure_threshold: 2,
};
let p1 = Arc::new(MultiCallMockProvider::always_fail("p1"));
let p2 = Arc::new(MultiCallMockProvider::always_ok("p2"));
let failover =
FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap();
// Request 1: p1 fails (count=1, below threshold), p2 succeeds.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
assert_eq!(p1.call_count(), 1);
// Request 2: p1 fails again (count=2, reaches threshold → cooldown), p2 succeeds.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
assert_eq!(p1.call_count(), 2);
// Request 3: p1 should be skipped (in cooldown), only p2 called.
let prev_p1_calls = p1.call_count();
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
// p1 was NOT called again.
assert_eq!(p1.call_count(), prev_p1_calls);
}
// Cooldown test 2: Cooldown expires after duration, provider is retried.
#[tokio::test]
async fn cooldown_expires_after_duration() {
let config = CooldownConfig {
cooldown_duration: Duration::from_millis(1),
failure_threshold: 1,
};
// p1 fails once then succeeds (fail_then_ok with n=1 would work,
// but we use always_fail to prove it's skipped, then swap).
let p1 = Arc::new(MultiCallMockProvider::fail_then_ok("p1", 2));
let p2 = Arc::new(MultiCallMockProvider::always_ok("p2"));
let failover =
FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap();
// Request 1: p1 fails (threshold=1, enters cooldown immediately), p2 succeeds.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
assert_eq!(p1.call_count(), 1);
// Request 2: p1 in cooldown, skipped. Only p2 called.
// (But cooldown is 1ms, so wait a bit to let it expire.)
tokio::time::sleep(Duration::from_millis(5)).await;
// After sleep, cooldown should have expired. p1 gets tried again.
// p1 is set to fail 2 times total, so call #2 (index 1) still fails.
// But it proves p1 was attempted again after cooldown expired.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(p1.call_count(), 2); // p1 was retried
assert_eq!(r.content, "p2 ok"); // p2 handled it
// Wait again for cooldown to expire, p1 call #3 (index 2) succeeds.
tokio::time::sleep(Duration::from_millis(5)).await;
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p1 ok");
assert_eq!(p1.call_count(), 3);
}
// Cooldown test 3: Never skip all providers — oldest-cooled one is tried.
#[tokio::test]
async fn never_skip_all_providers() {
let config = CooldownConfig {
cooldown_duration: Duration::from_secs(300),
failure_threshold: 1,
};
// Both providers always fail.
let p1 = Arc::new(MultiCallMockProvider::always_fail("p1"));
let p2 = Arc::new(MultiCallMockProvider::always_fail("p2"));
let failover =
FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap();
// Request 1: both tried, both fail, both enter cooldown.
let _ = failover.complete(make_request()).await;
assert_eq!(p1.call_count(), 1);
assert_eq!(p2.call_count(), 1);
// Request 2: all in cooldown, but the oldest-cooled one (p1, activated
// first) should be tried.
let prev_total = p1.call_count() + p2.call_count();
let _ = failover.complete(make_request()).await;
let new_total = p1.call_count() + p2.call_count();
// Exactly one more call was made (to the oldest-cooled provider).
assert_eq!(new_total, prev_total + 1);
}
// Cooldown test 4: Success resets failure count so it never reaches threshold.
//
// With threshold=3, accumulate 2 failures then succeed. Verify the
// atomic counter is back to 0 and no cooldown was activated. Then
// use a second provider pair to show that without the reset, 3
// consecutive failures DO trigger cooldown (control case).
#[tokio::test]
async fn reset_on_success() {
let config = CooldownConfig {
cooldown_duration: Duration::from_secs(300),
failure_threshold: 3,
};
// p1 fails for calls 0,1 then succeeds on call 2+.
let p1 = Arc::new(MultiCallMockProvider::fail_then_ok("p1", 2));
let p2 = Arc::new(MultiCallMockProvider::always_ok("p2"));
let failover =
FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config.clone()).unwrap();
// Request 1: p1 fails (failure_count=1), p2 succeeds.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
// Request 2: p1 fails (failure_count=2, still below threshold=3), p2 succeeds.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
assert_eq!(p1.call_count(), 2);
// Request 3: p1 succeeds (call index 2) → counter resets to 0.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p1 ok");
assert_eq!(p1.call_count(), 3);
// Verify counter was reset to 0 and no cooldown activated.
let nanos = failover.now_nanos();
let cooldown_nanos = failover.cooldown_config.cooldown_duration.as_nanos() as u64;
assert!(!failover.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos));
assert_eq!(
failover.cooldowns[0].failure_count.load(Ordering::Relaxed),
0
);
// Control: without a success in the middle, 3 failures DO trigger cooldown.
let p3 = Arc::new(MultiCallMockProvider::always_fail("p3"));
let p4 = Arc::new(MultiCallMockProvider::always_ok("p4"));
let control =
FailoverProvider::with_cooldown(vec![p3.clone(), p4.clone()], config).unwrap();
for _ in 0..3 {
let _ = control.complete(make_request()).await.unwrap();
}
let nanos = control.now_nanos();
assert!(control.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos));
}
// Cooldown test 5: threshold-1 failures don't trigger cooldown, threshold does.
#[tokio::test]
async fn threshold_boundary() {
let config = CooldownConfig {
cooldown_duration: Duration::from_secs(300),
failure_threshold: 3,
};
let p1 = Arc::new(MultiCallMockProvider::always_fail("p1"));
let p2 = Arc::new(MultiCallMockProvider::always_ok("p2"));
let failover =
FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap();
// 2 requests: p1 fails twice (below threshold of 3), not in cooldown.
for _ in 0..2 {
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
}
assert_eq!(p1.call_count(), 2);
// p1 should still be available (not in cooldown).
let nanos = failover.now_nanos();
let cooldown_nanos = failover.cooldown_config.cooldown_duration.as_nanos() as u64;
assert!(!failover.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos));
// 3rd request: p1 fails → reaches threshold → enters cooldown.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
assert_eq!(p1.call_count(), 3);
let nanos = failover.now_nanos();
assert!(failover.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos));
// 4th request: p1 should be skipped.
let prev = p1.call_count();
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
assert_eq!(p1.call_count(), prev); // not called
}
// Cooldown test 6: Non-retryable error returns immediately, no failure bump.
#[tokio::test]
async fn non_retryable_does_not_increment_cooldown() {
let config = CooldownConfig {
cooldown_duration: Duration::from_secs(300),
failure_threshold: 1,
};
let p1 = Arc::new(MultiCallMockProvider::always_fail_non_retryable("p1"));
let p2 = Arc::new(MultiCallMockProvider::always_ok("p2"));
let failover =
FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone()], config).unwrap();
// Non-retryable error should return immediately.
let err = failover.complete(make_request()).await.unwrap_err();
assert!(matches!(err, LlmError::AuthFailed { .. }));
assert_eq!(p1.call_count(), 1);
// p2 should NOT have been called (non-retryable = no failover).
assert_eq!(p2.call_count(), 0);
// p1 should NOT be in cooldown (non-retryable doesn't bump count).
let nanos = failover.now_nanos();
let cooldown_nanos = failover.cooldown_config.cooldown_duration.as_nanos() as u64;
assert!(!failover.cooldowns[0].is_in_cooldown(nanos, cooldown_nanos));
}
// Cooldown test 7: Three providers, first in cooldown, second/third available.
#[tokio::test]
async fn three_providers_mixed_cooldown() {
let config = CooldownConfig {
cooldown_duration: Duration::from_secs(300),
failure_threshold: 1,
};
let p1 = Arc::new(MultiCallMockProvider::always_fail("p1"));
let p2 = Arc::new(MultiCallMockProvider::always_ok("p2"));
let p3 = Arc::new(MultiCallMockProvider::always_ok("p3"));
let failover =
FailoverProvider::with_cooldown(vec![p1.clone(), p2.clone(), p3.clone()], config)
.unwrap();
// Request 1: p1 fails → enters cooldown (threshold=1), p2 succeeds.
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
assert_eq!(p1.call_count(), 1);
// Request 2: p1 skipped (cooldown), p2 and p3 available.
let prev = p1.call_count();
let r = failover.complete(make_request()).await.unwrap();
assert_eq!(r.content, "p2 ok");
assert_eq!(p1.call_count(), prev); // p1 skipped
}
// Test: is_retryable correctly classifies errors. // Test: is_retryable correctly classifies errors.
#[test] #[test]
fn retryable_classification() { fn retryable_classification() {
+1 -106
View File
@@ -17,7 +17,7 @@ mod retry;
mod rig_adapter; mod rig_adapter;
pub mod session; pub mod session;
pub use failover::{CooldownConfig, FailoverProvider}; pub use failover::FailoverProvider;
pub use nearai::{ModelInfo, NearAiProvider}; pub use nearai::{ModelInfo, NearAiProvider};
pub use nearai_chat::NearAiChatProvider; pub use nearai_chat::NearAiChatProvider;
pub use provider::{ pub use provider::{
@@ -183,108 +183,3 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
); );
Ok(Arc::new(RigAdapter::new(model, &compat.model))) 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,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 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());
}
}
+6 -19
View File
@@ -428,30 +428,17 @@ impl SessionManager {
})?; })?;
let user_id = self.user_id.read().await.clone(); let user_id = self.user_id.read().await.clone();
let value = if let Some(value) = store let value = store
.get_setting(&user_id, "nearai.session_token") .get_setting(&user_id, "nearai.session_token")
.await .await
.map_err(|e| LlmError::SessionRenewalFailed { .map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(), provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e), reason: format!("DB query failed: {}", e),
})? { })?
value .ok_or_else(|| LlmError::SessionRenewalFailed {
} else { provider: "nearai".to_string(),
tracing::warn!( reason: "No session in DB".to_string(),
"nearai.session_token missing; falling back to legacy nearai.session for backwards compatibility" })?;
);
store
.get_setting(&user_id, "nearai.session")
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e),
})?
.ok_or(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "No session in DB".to_string(),
})?
};
let session: SessionData = let session: SessionData =
serde_json::from_value(value).map_err(|e| LlmError::SessionRenewalFailed { serde_json::from_value(value).map_err(|e| LlmError::SessionRenewalFailed {
+13 -97
View File
@@ -22,10 +22,9 @@ use ironclaw::{
config::Config, config::Config,
context::ContextManager, context::ContextManager,
extensions::ExtensionManager, extensions::ExtensionManager,
hooks::HookRegistry,
llm::{ llm::{
CooldownConfig, FailoverProvider, LlmProvider, SessionConfig, create_cheap_llm_provider, FailoverProvider, LlmProvider, SessionConfig, create_llm_provider,
create_llm_provider, create_llm_provider_with_config, create_session_manager, create_llm_provider_with_config, create_session_manager,
}, },
orchestrator::{ orchestrator::{
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore, ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
@@ -308,11 +307,8 @@ async fn main() -> anyhow::Result<()> {
}; };
let session = create_session_manager(session_config).await; let session = create_session_manager(session_config).await;
// Session-based auth is only needed for NEAR AI backend without an API key. // Ensure we're authenticated before proceeding (only needed for NEAR AI backend)
// ChatCompletions mode with an API key skips session auth entirely. if config.llm.backend == ironclaw::config::LlmBackend::NearAi {
if config.llm.backend == ironclaw::config::LlmBackend::NearAi
&& config.llm.nearai.api_key.is_none()
{
session.ensure_authenticated().await?; session.ensure_authenticated().await?;
} }
@@ -338,10 +334,7 @@ async fn main() -> anyhow::Result<()> {
let repl_channel = if let Some(ref msg) = cli.message { let repl_channel = if let Some(ref msg) = cli.message {
Some(ReplChannel::with_message(msg.clone())) Some(ReplChannel::with_message(msg.clone()))
} else if config.channels.cli.enabled { } else if config.channels.cli.enabled {
let repl = ReplChannel::new(); Some(ReplChannel::new())
// Suppress the one-liner banner; boot screen will be shown instead.
repl.suppress_banner();
Some(repl)
} else { } else {
None None
}; };
@@ -536,26 +529,11 @@ async fn main() -> anyhow::Result<()> {
fallback = %fallback.model_name(), fallback = %fallback.model_name(),
"LLM failover enabled" "LLM failover enabled"
); );
let cooldown_config = CooldownConfig { Arc::new(FailoverProvider::new(vec![llm, fallback])?)
cooldown_duration: std::time::Duration::from_secs(
config.llm.nearai.failover_cooldown_secs,
),
failure_threshold: config.llm.nearai.failover_cooldown_threshold,
};
Arc::new(FailoverProvider::with_cooldown(
vec![llm, fallback],
cooldown_config,
)?)
} else { } else {
llm 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 // Initialize safety layer
let safety = Arc::new(SafetyLayer::new(&config.safety)); let safety = Arc::new(SafetyLayer::new(&config.safety));
tracing::info!("Safety layer initialized"); tracing::info!("Safety layer initialized");
@@ -898,14 +876,12 @@ async fn main() -> anyhow::Result<()> {
// Initialize channel manager // Initialize channel manager
let mut channels = ChannelManager::new(); let mut channels = ChannelManager::new();
let mut channel_names: Vec<String> = Vec::new();
if let Some(repl) = repl_channel { if let Some(repl) = repl_channel {
channels.add(Box::new(repl)); channels.add(Box::new(repl));
if cli.message.is_some() { if cli.message.is_some() {
tracing::info!("Single message mode"); tracing::info!("Single message mode");
} else { } else {
channel_names.push("repl".to_string());
tracing::info!("REPL mode enabled"); tracing::info!("REPL mode enabled");
} }
} }
@@ -1041,7 +1017,6 @@ async fn main() -> anyhow::Result<()> {
} }
} }
channel_names.push(channel_name.clone());
channels.add(Box::new(SharedWasmChannel::new(channel_arc))); channels.add(Box::new(SharedWasmChannel::new(channel_arc)));
} }
@@ -1086,7 +1061,6 @@ async fn main() -> anyhow::Result<()> {
.parse() .parse()
.expect("HttpConfig host:port must be a valid SocketAddr"), .expect("HttpConfig host:port must be a valid SocketAddr"),
); );
channel_names.push("http".to_string());
channels.add(Box::new(http_channel)); channels.add(Box::new(http_channel));
tracing::info!( tracing::info!(
"HTTP channel enabled on {}:{}", "HTTP channel enabled on {}:{}",
@@ -1149,11 +1123,8 @@ async fn main() -> anyhow::Result<()> {
// Create context manager (shared between job tools and agent) // Create context manager (shared between job tools and agent)
let context_manager = Arc::new(ContextManager::new(config.agent.max_parallel_jobs)); let context_manager = Arc::new(ContextManager::new(config.agent.max_parallel_jobs));
// Create hook registry
let hooks = Arc::new(HookRegistry::new());
// Create session manager (shared between agent and web gateway) // Create session manager (shared between agent and web gateway)
let session_manager = Arc::new(SessionManager::new().with_hooks(hooks.clone())); let session_manager = Arc::new(SessionManager::new());
// Register job tools (sandbox deps auto-injected when container_job_manager is available) // Register job tools (sandbox deps auto-injected when container_job_manager is available)
tools.register_job_tools( tools.register_job_tools(
@@ -1163,7 +1134,6 @@ async fn main() -> anyhow::Result<()> {
); );
// Add web gateway channel if configured // Add web gateway channel if configured
let mut gateway_url: Option<String> = None;
if let Some(ref gw_config) = config.channels.gateway { if let Some(ref gw_config) = config.channels.gateway {
let mut gw = GatewayChannel::new(gw_config.clone()); let mut gw = GatewayChannel::new(gw_config.clone());
if let Some(ref ws) = workspace { if let Some(ref ws) = workspace {
@@ -1196,39 +1166,29 @@ async fn main() -> anyhow::Result<()> {
} }
} }
gateway_url = Some(format!(
"http://{}:{}/?token={}",
gw_config.host,
gw_config.port,
gw.auth_token()
));
tracing::info!( tracing::info!(
"Web gateway enabled on {}:{}", "Web gateway enabled on {}:{}",
gw_config.host, gw_config.host,
gw_config.port gw_config.port
); );
tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port); tracing::info!(
"Web UI: http://{}:{}/?token={}",
gw_config.host,
gw_config.port,
gw.auth_token()
);
channel_names.push("gateway".to_string());
channels.add(Box::new(gw)); channels.add(Box::new(gw));
} }
// Capture boot screen info before moving Arcs into AgentDeps.
let boot_tool_count = tools.count();
let boot_llm_model = llm.model_name().to_string();
let boot_cheap_model = cheap_llm.as_ref().map(|c| c.model_name().to_string());
// Create and run the agent // Create and run the agent
let deps = AgentDeps { let deps = AgentDeps {
store: db, store: db,
llm, llm,
cheap_llm,
safety, safety,
tools, tools,
workspace, workspace,
extension_manager, extension_manager,
hooks,
}; };
let agent = Agent::new( let agent = Agent::new(
config.agent.clone(), config.agent.clone(),
@@ -1242,38 +1202,6 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Agent initialized, starting main loop..."); tracing::info!("Agent initialized, starting main loop...");
// Print boot screen for interactive CLI mode (not single-message mode).
if config.channels.cli.enabled && cli.message.is_none() {
let boot_info = ironclaw::boot_screen::BootInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
agent_name: config.agent.name.clone(),
llm_backend: config.llm.backend.to_string(),
llm_model: boot_llm_model,
cheap_model: boot_cheap_model,
db_backend: if cli.no_db {
"none".to_string()
} else {
config.database.backend.to_string()
},
db_connected: !cli.no_db,
tool_count: boot_tool_count,
gateway_url,
embeddings_enabled: config.embeddings.enabled,
embeddings_provider: if config.embeddings.enabled {
Some(config.embeddings.provider.clone())
} else {
None
},
heartbeat_enabled: config.heartbeat.enabled,
heartbeat_interval_secs: config.heartbeat.interval_secs,
sandbox_enabled: config.sandbox.enabled,
claude_code_enabled: config.claude_code.enabled,
routines_enabled: config.routines.enabled,
channels: channel_names,
};
ironclaw::boot_screen::print_boot_screen(&boot_info);
}
// Run the agent (blocks until shutdown) // Run the agent (blocks until shutdown)
agent.run().await?; agent.run().await?;
@@ -1301,18 +1229,6 @@ fn check_onboard_needed() -> Option<&'static str> {
return Some("Database not configured"); 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 None
} }
+2 -2
View File
@@ -202,7 +202,7 @@ async fn report_complete(
State(state): State<OrchestratorState>, State(state): State<OrchestratorState>,
Path(job_id): Path<Uuid>, Path(job_id): Path<Uuid>,
Json(report): Json<CompletionReport>, Json(report): Json<CompletionReport>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<StatusCode, StatusCode> {
if report.success { if report.success {
tracing::info!( tracing::info!(
job_id = %job_id, job_id = %job_id,
@@ -223,7 +223,7 @@ async fn report_complete(
}; };
let _ = state.job_manager.complete_job(job_id, result).await; let _ = state.job_manager.complete_job(job_id, result).await;
Ok(Json(serde_json::json!({"status": "ok"}))) Ok(StatusCode::OK)
} }
// -- Sandbox job event handlers -- // -- Sandbox job event handlers --
-539
View File
@@ -1,539 +0,0 @@
# 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`
+2 -3
View File
@@ -1014,7 +1014,6 @@ impl SetupWizard {
backend: crate::config::LlmBackend::NearAi, backend: crate::config::LlmBackend::NearAi,
nearai: crate::config::NearAiConfig { nearai: crate::config::NearAiConfig {
model: "dummy".to_string(), model: "dummy".to_string(),
cheap_model: None,
base_url, base_url,
auth_base_url, auth_base_url,
session_path: crate::llm::session::default_session_path(), session_path: crate::llm::session::default_session_path(),
@@ -1022,8 +1021,6 @@ impl SetupWizard {
api_key: None, api_key: None,
fallback_model: None, fallback_model: None,
max_retries: 3, max_retries: 3,
failover_cooldown_secs: 300,
failover_cooldown_threshold: 3,
}, },
openai: None, openai: None,
anthropic: None, anthropic: None,
@@ -2092,6 +2089,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_install_missing_bundled_channels_installs_telegram() { async fn test_install_missing_bundled_channels_installs_telegram() {
use crate::channels::wasm::available_channel_names;
// WASM artifacts only exist in dev builds (not CI). Skip gracefully // WASM artifacts only exist in dev builds (not CI). Skip gracefully
// rather than fail when the telegram channel hasn't been compiled. // rather than fail when the telegram channel hasn't been compiled.
if !available_channel_names().contains(&"telegram") { if !available_channel_names().contains(&"telegram") {
+7 -2
View File
@@ -3,7 +3,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Simple echo tool for testing. /// Simple echo tool for testing.
pub struct EchoTool; pub struct EchoTool;
@@ -38,7 +38,12 @@ impl Tool for EchoTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let message = require_str(&params, "message")?; let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'message' parameter".to_string())
})?;
Ok(ToolOutput::text(message, start.elapsed())) Ok(ToolOutput::text(message, start.elapsed()))
} }
+136
View File
@@ -0,0 +1,136 @@
//! 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
}
}
+17 -5
View File
@@ -9,7 +9,7 @@ use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::extensions::{ExtensionKind, ExtensionManager}; use crate::extensions::{ExtensionKind, ExtensionManager};
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
// ── tool_search ────────────────────────────────────────────────────────── // ── tool_search ──────────────────────────────────────────────────────────
@@ -133,7 +133,10 @@ impl Tool for ToolInstallTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let url = params.get("url").and_then(|v| v.as_str()); let url = params.get("url").and_then(|v| v.as_str());
@@ -207,7 +210,10 @@ impl Tool for ToolAuthTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let result = self let result = self
.manager .manager
@@ -300,7 +306,10 @@ impl Tool for ToolActivateTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
match self.manager.activate(name).await { match self.manager.activate(name).await {
Ok(result) => { Ok(result) => {
@@ -462,7 +471,10 @@ impl Tool for ToolRemoveTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?;
let message = self let message = self
.manager .manager
+25 -7
View File
@@ -11,7 +11,7 @@ use async_trait::async_trait;
use tokio::fs; use tokio::fs;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput};
use crate::workspace::paths as ws_paths; use crate::workspace::paths as ws_paths;
/// Well-known workspace filenames that must go through memory_write, not write_file. /// Well-known workspace filenames that must go through memory_write, not write_file.
@@ -203,7 +203,10 @@ impl Tool for ReadFileTool {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = require_str(&params, "path")?; let path_str = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
let offset = params.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize; 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()); let limit = params.get("limit").and_then(|v| v.as_u64());
@@ -325,7 +328,10 @@ impl Tool for WriteFileTool {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = require_str(&params, "path")?; let path_str = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
// Reject workspace paths: these live in the database, not on disk. // Reject workspace paths: these live in the database, not on disk.
if is_workspace_path(path_str) { if is_workspace_path(path_str) {
@@ -336,7 +342,10 @@ impl Tool for WriteFileTool {
))); )));
} }
let content = require_str(&params, "content")?; let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'content' parameter".into()))?;
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@@ -641,11 +650,20 @@ impl Tool for ApplyPatchTool {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let path_str = require_str(&params, "path")?; let path_str = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
let old_string = require_str(&params, "old_string")?; let old_string = params
.get("old_string")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'old_string' parameter".into()))?;
let new_string = require_str(&params, "new_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 replace_all = params let replace_all = params
.get("replace_all") .get("replace_all")
+21 -48
View File
@@ -5,18 +5,13 @@ use std::net::{IpAddr, ToSocketAddrs};
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use futures::StreamExt;
use reqwest::Client; use reqwest::Client;
use crate::context::JobContext; use crate::context::JobContext;
use crate::safety::LeakDetector; use crate::safety::LeakDetector;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Maximum response body size (5 MB). /// Maximum response body size (5 MB). Prevents OOM from unbounded responses.
///
/// 5 MB is large enough for typical JSON API responses and moderate HTML pages,
/// but small enough to prevent OOM from malicious or runaway servers. The WASM
/// HTTP wrapper uses the same limit for consistency.
const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024;
/// Tool for making HTTP requests. /// Tool for making HTTP requests.
@@ -159,9 +154,17 @@ impl Tool for HttpTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let method = require_str(&params, "method")?; let method = params
.get("method")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'method' parameter".to_string())
})?;
let url = require_str(&params, "url")?; let url = params
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'url' parameter".to_string()))?;
let parsed_url = validate_url(url)?; let parsed_url = validate_url(url)?;
// Parse headers // Parse headers
@@ -235,43 +238,19 @@ impl Tool for HttpTool {
.filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string()))) .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
.collect(); .collect();
// Pre-check Content-Length header to reject obviously oversized responses // Get response body with size cap to prevent OOM
// before downloading anything, preventing OOM from malicious servers. let body_bytes = response.bytes().await.map_err(|e| {
if let Some(content_length) = response.headers().get(reqwest::header::CONTENT_LENGTH) ToolError::ExternalService(format!("failed to read response body: {}", e))
&& let Ok(s) = content_length.to_str() })?;
&& let Ok(len) = s.parse::<usize>()
&& len > MAX_RESPONSE_SIZE if body_bytes.len() > MAX_RESPONSE_SIZE {
{
tracing::warn!(
url = %parsed_url,
content_length = len,
max = MAX_RESPONSE_SIZE,
"Rejected HTTP response: Content-Length exceeds limit"
);
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"Response Content-Length ({} bytes) exceeds maximum allowed size ({} bytes)", "Response body too large ({} bytes, max {})",
len, MAX_RESPONSE_SIZE body_bytes.len(),
MAX_RESPONSE_SIZE
))); )));
} }
// Stream the response body with a hard size cap. Even if Content-Length was
// absent or lied about the size, we stop reading once we exceed the limit.
let mut body = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = StreamExt::next(&mut stream).await {
let chunk = chunk.map_err(|e| {
ToolError::ExternalService(format!("failed to read response body: {}", e))
})?;
if body.len() + chunk.len() > MAX_RESPONSE_SIZE {
return Err(ToolError::ExecutionFailed(format!(
"Response body exceeds maximum allowed size ({} bytes)",
MAX_RESPONSE_SIZE
)));
}
body.extend_from_slice(&chunk);
}
let body_bytes = bytes::Bytes::from(body);
let body_text = String::from_utf8_lossy(&body_bytes).into_owned(); let body_text = String::from_utf8_lossy(&body_bytes).into_owned();
// Try to parse as JSON, fall back to string // Try to parse as JSON, fall back to string
@@ -357,10 +336,4 @@ mod tests {
// Public // Public
assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); assert!(!is_disallowed_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
} }
#[test]
fn test_max_response_size_is_reasonable() {
// MAX_RESPONSE_SIZE should be 5 MB to prevent OOM while allowing typical API responses.
assert_eq!(MAX_RESPONSE_SIZE, 5 * 1024 * 1024);
}
} }
+19 -5
View File
@@ -18,7 +18,7 @@ use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database; use crate::db::Database;
use crate::history::SandboxJobRecord; use crate::history::SandboxJobRecord;
use crate::orchestrator::job_manager::{ContainerJobManager, JobMode}; use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for creating a new job. /// Tool for creating a new job.
/// ///
@@ -467,9 +467,17 @@ impl Tool for CreateJobTool {
params: serde_json::Value, params: serde_json::Value,
ctx: &JobContext, ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let title = require_str(&params, "title")?; let title = params
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?;
let description = require_str(&params, "description")?; let description = params
.get("description")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'description' parameter".into())
})?;
if self.sandbox_enabled() { if self.sandbox_enabled() {
let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true); let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -627,7 +635,10 @@ impl Tool for JobStatusTool {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let requester_id = ctx.user_id.clone(); let requester_id = ctx.user_id.clone();
let job_id_str = require_str(&params, "job_id")?; 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 = Uuid::parse_str(job_id_str).map_err(|_| { let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str)) ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
@@ -709,7 +720,10 @@ impl Tool for CancelJobTool {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let requester_id = ctx.user_id.clone(); let requester_id = ctx.user_id.clone();
let job_id_str = require_str(&params, "job_id")?; 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 = Uuid::parse_str(job_id_str).map_err(|_| { let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str)) ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
+10 -3
View File
@@ -3,7 +3,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_param, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for JSON manipulation (parse, query, transform). /// Tool for JSON manipulation (parse, query, transform).
pub struct JsonTool; pub struct JsonTool;
@@ -46,9 +46,16 @@ impl Tool for JsonTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let operation = require_str(&params, "operation")?; let operation = params
.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'operation' parameter".to_string())
})?;
let data = require_param(&params, "data")?; let data = params
.get("data")
.ok_or_else(|| ToolError::InvalidParameters("missing 'data' parameter".to_string()))?;
let result = match operation { let result = match operation {
"parse" => { "parse" => {
+160
View File
@@ -0,0 +1,160 @@
//! 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
}
}
+15 -4
View File
@@ -17,7 +17,7 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
use crate::workspace::{Workspace, paths}; use crate::workspace::{Workspace, paths};
/// Identity files that the LLM must not overwrite via tool calls. /// Identity files that the LLM must not overwrite via tool calls.
@@ -81,7 +81,10 @@ impl Tool for MemorySearchTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let query = require_str(&params, "query")?; let query = params
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'query' parameter".to_string()))?;
let limit = params let limit = params
.get("limit") .get("limit")
@@ -173,7 +176,12 @@ impl Tool for MemoryWriteTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let content = require_str(&params, "content")?; let content = params
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'content' parameter".to_string())
})?;
if content.trim().is_empty() { if content.trim().is_empty() {
return Err(ToolError::InvalidParameters( return Err(ToolError::InvalidParameters(
@@ -329,7 +337,10 @@ impl Tool for MemoryReadTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let path = require_str(&params, "path")?; let path = params
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".to_string()))?;
let doc = self let doc = self
.workspace .workspace
+8
View File
@@ -1,17 +1,22 @@
//! Built-in tools that come with the agent. //! Built-in tools that come with the agent.
mod echo; mod echo;
mod ecommerce;
pub mod extension_tools; pub mod extension_tools;
mod file; mod file;
mod http; mod http;
mod job; mod job;
mod json; mod json;
mod marketplace;
mod memory; mod memory;
mod restaurant;
pub mod routine; pub mod routine;
pub(crate) mod shell; pub(crate) mod shell;
mod taskrabbit;
mod time; mod time;
pub use echo::EchoTool; pub use echo::EchoTool;
pub use ecommerce::EcommerceTool;
pub use extension_tools::{ pub use extension_tools::{
ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool,
}; };
@@ -19,9 +24,12 @@ pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
pub use http::HttpTool; pub use http::HttpTool;
pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool}; pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool};
pub use json::JsonTool; pub use json::JsonTool;
pub use marketplace::MarketplaceTool;
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool}; pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
pub use restaurant::RestaurantTool;
pub use routine::{ pub use routine::{
RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool, RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool,
}; };
pub use shell::ShellTool; pub use shell::ShellTool;
pub use taskrabbit::TaskRabbitTool;
pub use time::TimeTool; pub use time::TimeTool;
+172
View File
@@ -0,0 +1,172 @@
//! 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
}
}
+25 -7
View File
@@ -20,7 +20,7 @@ use crate::agent::routine::{
use crate::agent::routine_engine::RoutineEngine; use crate::agent::routine_engine::RoutineEngine;
use crate::context::JobContext; use crate::context::JobContext;
use crate::db::Database; use crate::db::Database;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
// ==================== routine_create ==================== // ==================== routine_create ====================
@@ -106,16 +106,25 @@ impl Tool for RoutineCreateTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
let description = params let description = params
.get("description") .get("description")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .unwrap_or("");
let trigger_type = require_str(&params, "trigger_type")?; let trigger_type = params
.get("trigger_type")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'trigger_type'".to_string()))?;
let prompt = require_str(&params, "prompt")?; let prompt = params
.get("prompt")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'prompt'".to_string()))?;
// Build trigger // Build trigger
let trigger = match trigger_type { let trigger = match trigger_type {
@@ -399,7 +408,10 @@ impl Tool for RoutineUpdateTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
let mut routine = self let mut routine = self
.store .store
@@ -502,7 +514,10 @@ impl Tool for RoutineDeleteTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
let routine = self let routine = self
.store .store
@@ -580,7 +595,10 @@ impl Tool for RoutineHistoryTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let name = require_str(&params, "name")?; let name = params
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?;
let limit = params let limit = params
.get("limit") .get("limit")
+5 -50
View File
@@ -30,7 +30,7 @@ use tokio::process::Command;
use crate::context::JobContext; use crate::context::JobContext;
use crate::sandbox::{SandboxManager, SandboxPolicy}; use crate::sandbox::{SandboxManager, SandboxPolicy};
use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput};
/// Maximum output size before truncation (64KB). /// Maximum output size before truncation (64KB).
const MAX_OUTPUT_SIZE: usize = 64 * 1024; const MAX_OUTPUT_SIZE: usize = 64 * 1024;
@@ -401,7 +401,10 @@ impl Tool for ShellTool {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let command = require_str(&params, "command")?; let command = params
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'command' parameter".into()))?;
let workdir = params.get("workdir").and_then(|v| v.as_str()); let workdir = params.get("workdir").and_then(|v| v.as_str());
let timeout = params.get("timeout").and_then(|v| v.as_u64()); let timeout = params.get("timeout").and_then(|v| v.as_u64());
@@ -426,26 +429,6 @@ impl Tool for ShellTool {
true // Shell commands should require approval true // Shell commands should require approval
} }
fn requires_approval_for(&self, params: &serde_json::Value) -> bool {
let cmd = params
.get("command")
.and_then(|c| c.as_str().map(String::from))
.or_else(|| {
params
.as_str()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("command").and_then(|c| c.as_str().map(String::from)))
});
if let Some(ref cmd) = cmd
&& requires_explicit_approval(cmd)
{
return true;
}
false
}
fn requires_sanitization(&self) -> bool { fn requires_sanitization(&self) -> bool {
true // Shell output could contain anything true // Shell output could contain anything
} }
@@ -586,34 +569,6 @@ mod tests {
assert!(requires_explicit_approval(cmd.as_deref().unwrap())); assert!(requires_explicit_approval(cmd.as_deref().unwrap()));
} }
#[test]
fn test_requires_approval_for_destructive_command() {
let tool = ShellTool::new();
// Destructive commands must return true even though shell already
// requires base approval -- the distinction matters for auto-approve override.
assert!(tool.requires_approval_for(&serde_json::json!({"command": "rm -rf /tmp"})));
assert!(tool.requires_approval_for(
&serde_json::json!({"command": "git push --force origin main"})
));
assert!(tool.requires_approval_for(&serde_json::json!({"command": "DROP TABLE users;"})));
}
#[test]
fn test_requires_approval_for_safe_command() {
let tool = ShellTool::new();
// Safe commands should not override auto-approval; only destructive ones do.
assert!(!tool.requires_approval_for(&serde_json::json!({"command": "cargo build"})));
assert!(!tool.requires_approval_for(&serde_json::json!({"command": "echo hello"})));
}
#[test]
fn test_requires_approval_for_string_encoded_args() {
let tool = ShellTool::new();
// When arguments are string-encoded JSON (rare LLM behavior).
let args = serde_json::Value::String(r#"{"command": "rm -rf /tmp/stuff"}"#.to_string());
assert!(tool.requires_approval_for(&args));
}
#[test] #[test]
fn test_sandbox_policy_builder() { fn test_sandbox_policy_builder() {
let tool = ShellTool::new() let tool = ShellTool::new()
+157
View File
@@ -0,0 +1,157 @@
//! 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
}
}
+25 -5
View File
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use crate::context::JobContext; use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput, require_str}; use crate::tools::tool::{Tool, ToolError, ToolOutput};
/// Tool for getting current time and date operations. /// Tool for getting current time and date operations.
pub struct TimeTool; pub struct TimeTool;
@@ -52,7 +52,12 @@ impl Tool for TimeTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let operation = require_str(&params, "operation")?; let operation = params
.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'operation' parameter".to_string())
})?;
let result = match operation { let result = match operation {
"now" => { "now" => {
@@ -64,7 +69,12 @@ impl Tool for TimeTool {
}) })
} }
"parse" => { "parse" => {
let timestamp = require_str(&params, "timestamp")?; let timestamp = params
.get("timestamp")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'timestamp' parameter".to_string())
})?;
let dt: DateTime<Utc> = timestamp.parse().map_err(|e| { let dt: DateTime<Utc> = timestamp.parse().map_err(|e| {
ToolError::InvalidParameters(format!("invalid timestamp: {}", e)) ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
@@ -77,9 +87,19 @@ impl Tool for TimeTool {
}) })
} }
"diff" => { "diff" => {
let ts1 = require_str(&params, "timestamp")?; let ts1 = params
.get("timestamp")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'timestamp' parameter".to_string())
})?;
let ts2 = require_str(&params, "timestamp2")?; let ts2 = params
.get("timestamp2")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'timestamp2' parameter".to_string())
})?;
let dt1: DateTime<Utc> = ts1.parse().map_err(|e| { let dt1: DateTime<Utc> = ts1.parse().map_err(|e| {
ToolError::InvalidParameters(format!("invalid timestamp: {}", e)) ToolError::InvalidParameters(format!("invalid timestamp: {}", e))
+6 -81
View File
@@ -172,21 +172,6 @@ pub trait Tool: Send + Sync {
false false
} }
/// Whether this specific invocation should override auto-approval.
///
/// This method is called after checking `requires_approval()` and finding that
/// the tool is auto-approved for this session. Return `true` to force approval
/// for this specific invocation despite auto-approval (for example, for
/// destructive operations like `rm -rf` or `git push --force`).
///
/// Return `false` to allow auto-approval to proceed normally.
///
/// The default returns `false`. Override only if you need parameter-aware
/// approval gating.
fn requires_approval_for(&self, _params: &serde_json::Value) -> bool {
false
}
/// Maximum time this tool is allowed to run before the caller kills it. /// Maximum time this tool is allowed to run before the caller kills it.
/// Override for long-running tools like sandbox execution. /// Override for long-running tools like sandbox execution.
/// Default: 60 seconds. /// Default: 60 seconds.
@@ -214,28 +199,6 @@ 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -272,7 +235,12 @@ mod tests {
params: serde_json::Value, params: serde_json::Value,
_ctx: &JobContext, _ctx: &JobContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let message = require_str(&params, "message")?; let message = params
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ToolError::InvalidParameters("missing 'message' parameter".to_string())
})?;
Ok(ToolOutput::text(message, Duration::from_millis(1))) Ok(ToolOutput::text(message, Duration::from_millis(1)))
} }
@@ -309,47 +277,4 @@ mod tests {
let tool = EchoTool; let tool = EchoTool;
assert_eq!(tool.execution_timeout(), Duration::from_secs(60)); 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(&params, "name").unwrap(), "alice");
}
#[test]
fn test_require_str_missing() {
let params = serde_json::json!({});
let err = require_str(&params, "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(&params, "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(&params, "data").unwrap(),
&serde_json::json!([1, 2, 3])
);
}
#[test]
fn test_require_param_missing() {
let params = serde_json::json!({});
let err = require_param(&params, "data").unwrap_err();
assert!(err.to_string().contains("missing 'data'"));
}
#[test]
fn test_requires_approval_for_default() {
let tool = EchoTool;
// Default requires_approval_for() returns false, allowing auto-approval.
assert!(!tool.requires_approval_for(&serde_json::json!({"message": "hi"})));
}
} }
+70 -54
View File
@@ -129,15 +129,11 @@ impl WorkerHttpClient {
format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path) format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path)
} }
/// Send a GET request, check the status, and deserialize the JSON body. /// Fetch the job description from the orchestrator.
async fn get_json<T: serde::de::DeserializeOwned>( pub async fn get_job(&self) -> Result<JobDescription, WorkerError> {
&self,
path: &str,
context: &str,
) -> Result<T, WorkerError> {
let resp = self let resp = self
.client .client
.get(self.url(path)) .get(self.url("job"))
.bearer_auth(&self.token) .bearer_auth(&self.token)
.send() .send()
.await .await
@@ -149,51 +145,15 @@ impl WorkerHttpClient {
if !resp.status().is_success() { if !resp.status().is_success() {
return Err(WorkerError::OrchestratorRejected { return Err(WorkerError::OrchestratorRejected {
job_id: self.job_id, job_id: self.job_id,
reason: format!("{} returned {}", context, resp.status()), reason: format!("GET /job returned {}", resp.status()),
}); });
} }
resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { resp.json().await.map_err(|e| WorkerError::LlmProxyFailed {
reason: format!("{}: failed to parse response: {}", context, e), reason: format!("failed to parse job description: {}", 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. /// Proxy an LLM completion request through the orchestrator.
pub async fn llm_complete( pub async fn llm_complete(
&self, &self,
@@ -206,9 +166,29 @@ impl WorkerHttpClient {
stop_sequences: request.stop_sequences.clone(), stop_sequences: request.stop_sequences.clone(),
}; };
let proxy_resp: ProxyCompletionResponse = self let resp = self
.post_json("llm/complete", &proxy_req, "LLM complete") .client
.await?; .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),
})?;
Ok(CompletionResponse { Ok(CompletionResponse {
content: proxy_resp.content, content: proxy_resp.content,
@@ -232,9 +212,29 @@ impl WorkerHttpClient {
tool_choice: request.tool_choice.clone(), tool_choice: request.tool_choice.clone(),
}; };
let proxy_resp: ProxyToolCompletionResponse = self let resp = self
.post_json("llm/complete_with_tools", &proxy_req, "LLM tool complete") .client
.await?; .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),
})?;
Ok(ToolCompletionResponse { Ok(ToolCompletionResponse {
content: proxy_resp.content, content: proxy_resp.content,
@@ -337,9 +337,25 @@ impl WorkerHttpClient {
/// Signal job completion to the orchestrator. /// Signal job completion to the orchestrator.
pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> { pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> {
let _: serde_json::Value = self let resp = self
.post_json("complete", report, "report complete") .client
.await?; .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()),
});
}
Ok(()) Ok(())
} }
} }
-22
View File
@@ -1,22 +0,0 @@
[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
-189
View File
@@ -1,189 +0,0 @@
# 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
@@ -1,41 +0,0 @@
{
"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
}
}
-845
View File
@@ -1,845 +0,0 @@
//! 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());
}
}