mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfa105539b | ||
|
|
8929baf76a | ||
|
|
e07dfab449 | ||
|
|
6783cba4e4 | ||
|
|
63302ab406 | ||
|
|
7c553b0973 | ||
|
|
5e44185e48 | ||
|
|
72623c9e5b |
@@ -0,0 +1,97 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
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.
|
||||
@@ -4,6 +4,13 @@
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
# Sidecar tool data
|
||||
.sidecar/
|
||||
.todos/
|
||||
|
||||
target/
|
||||
|
||||
# WASM build artifacts (loaded from disk, not bundled)
|
||||
|
||||
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
Generated
+1
-1
@@ -2490,7 +2490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+9
-1
@@ -1,6 +1,14 @@
|
||||
[workspace]
|
||||
exclude = [
|
||||
"channels-src/telegram",
|
||||
"channels-src/slack",
|
||||
"channels-src/whatsapp",
|
||||
"tools-src/gmail",
|
||||
]
|
||||
|
||||
[package]
|
||||
name = "ironclaw"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.92"
|
||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||
|
||||
+9
-13
@@ -112,7 +112,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| `pairing` | ✅ | ✅ | - | list/approve for channel DM pairing |
|
||||
| `nodes` | ✅ | ❌ | P3 | Device management |
|
||||
| `plugins` | ✅ | ❌ | P3 | Plugin management |
|
||||
| `hooks` | ✅ | ❌ | P2 | Lifecycle hooks |
|
||||
| `hooks` | ✅ | ✅ | P2 | Lifecycle hooks |
|
||||
| `cron` | ✅ | ❌ | P2 | Scheduled jobs |
|
||||
| `webhooks` | ✅ | ❌ | P3 | Webhook config |
|
||||
| `message send` | ✅ | ❌ | P2 | Send to channels |
|
||||
@@ -323,14 +323,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
||||
| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers |
|
||||
| `beforeInbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ❌ | P2 | |
|
||||
| `beforeInbound` hook | ✅ | ✅ | P2 | |
|
||||
| `beforeOutbound` hook | ✅ | ✅ | P2 | |
|
||||
| `beforeToolCall` hook | ✅ | ✅ | P2 | |
|
||||
| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger |
|
||||
| `onSessionStart` hook | ✅ | ❌ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ❌ | P2 | |
|
||||
| `onSessionStart` hook | ✅ | ✅ | P2 | |
|
||||
| `onSessionEnd` hook | ✅ | ✅ | P2 | |
|
||||
| `transcribeAudio` hook | ✅ | ❌ | P3 | |
|
||||
| `transformResponse` hook | ✅ | ❌ | P2 | |
|
||||
| `transformResponse` hook | ✅ | ✅ | P2 | |
|
||||
| Bundled hooks | ✅ | ❌ | P2 | |
|
||||
| Plugin hooks | ✅ | ❌ | P3 | |
|
||||
| Workspace hooks | ✅ | ❌ | P2 | Inline code |
|
||||
@@ -420,14 +420,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
||||
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
|
||||
- ❌ WhatsApp channel
|
||||
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
|
||||
- ❌ Hooks system (beforeInbound, beforeToolCall, etc.)
|
||||
- ✅ Hooks system (beforeInbound, beforeToolCall, beforeOutbound, onSessionStart, onSessionEnd, transformResponse)
|
||||
|
||||
### P2 - Medium Priority
|
||||
- ❌ Cron job scheduling
|
||||
- ❌ Web Control UI
|
||||
- ❌ WebChat channel
|
||||
- 🚧 Media handling (caption support; no image/PDF processing)
|
||||
- ❌ CLI subcommands (config, status, memory, doctor)
|
||||
- ❌ Media handling (images, PDFs)
|
||||
- ❌ Ollama/local model support
|
||||
- ❌ Configuration hot-reload
|
||||
- ❌ Webhook trigger endpoint in web gateway
|
||||
|
||||
+138
-32
@@ -22,6 +22,7 @@ use crate::context::JobContext;
|
||||
use crate::db::Database;
|
||||
use crate::error::Error;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::{ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult};
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
@@ -67,10 +68,14 @@ enum AgenticLoopResult {
|
||||
pub struct AgentDeps {
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub llm: Arc<dyn LlmProvider>,
|
||||
/// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation).
|
||||
/// Falls back to the main `llm` if None.
|
||||
pub cheap_llm: Option<Arc<dyn LlmProvider>>,
|
||||
pub safety: Arc<SafetyLayer>,
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub workspace: Option<Arc<Workspace>>,
|
||||
pub extension_manager: Option<Arc<ExtensionManager>>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
}
|
||||
|
||||
/// The main agent that coordinates all components.
|
||||
@@ -113,6 +118,7 @@ impl Agent {
|
||||
deps.safety.clone(),
|
||||
deps.tools.clone(),
|
||||
deps.store.clone(),
|
||||
deps.hooks.clone(),
|
||||
));
|
||||
|
||||
Self {
|
||||
@@ -138,6 +144,11 @@ impl Agent {
|
||||
&self.deps.llm
|
||||
}
|
||||
|
||||
/// Get the cheap/fast LLM provider, falling back to the main one.
|
||||
fn cheap_llm(&self) -> &Arc<dyn LlmProvider> {
|
||||
self.deps.cheap_llm.as_ref().unwrap_or(&self.deps.llm)
|
||||
}
|
||||
|
||||
fn safety(&self) -> &Arc<SafetyLayer> {
|
||||
&self.deps.safety
|
||||
}
|
||||
@@ -150,6 +161,10 @@ impl Agent {
|
||||
self.deps.workspace.as_ref()
|
||||
}
|
||||
|
||||
fn hooks(&self) -> &Arc<HookRegistry> {
|
||||
&self.deps.hooks
|
||||
}
|
||||
|
||||
/// Run the agent main loop.
|
||||
pub async fn run(self) -> Result<(), Error> {
|
||||
// Start channels
|
||||
@@ -301,7 +316,7 @@ impl Agent {
|
||||
Some(spawn_heartbeat(
|
||||
config,
|
||||
workspace.clone(),
|
||||
self.llm().clone(),
|
||||
self.cheap_llm().clone(),
|
||||
Some(notify_tx),
|
||||
))
|
||||
} else {
|
||||
@@ -417,10 +432,32 @@ impl Agent {
|
||||
|
||||
match self.handle_message(&message).await {
|
||||
Ok(Some(response)) if !response.is_empty() => {
|
||||
let _ = self
|
||||
.channels
|
||||
.respond(&message, OutgoingResponse::text(response))
|
||||
.await;
|
||||
// Hook: BeforeOutbound — allow hooks to modify or suppress outbound
|
||||
let event = crate::hooks::HookEvent::Outbound {
|
||||
user_id: message.user_id.clone(),
|
||||
channel: message.channel.clone(),
|
||||
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(_)) => {
|
||||
// Empty response, nothing to send (e.g. approval handled via send_status)
|
||||
@@ -466,7 +503,33 @@ impl Agent {
|
||||
|
||||
async fn handle_message(&self, message: &IncomingMessage) -> Result<Option<String>, Error> {
|
||||
// Parse submission type first
|
||||
let submission = SubmissionParser::parse(&message.content);
|
||||
let mut 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
|
||||
if let Some(ref external_thread_id) = message.thread_id {
|
||||
@@ -875,6 +938,27 @@ impl Agent {
|
||||
// Complete, fail, or request approval
|
||||
match result {
|
||||
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);
|
||||
self.persist_response_chain(thread);
|
||||
let _ = self
|
||||
@@ -1152,8 +1236,8 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// Execute each tool (with approval checking)
|
||||
for tc in tool_calls {
|
||||
// Execute each tool (with approval checking and hook interception)
|
||||
for mut tc in tool_calls {
|
||||
// Check if tool requires approval
|
||||
if let Some(tool) = self.tools().get(&tc.name).await
|
||||
&& tool.requires_approval()
|
||||
@@ -1164,31 +1248,12 @@ impl Agent {
|
||||
sess.is_tool_auto_approved(&tc.name)
|
||||
};
|
||||
|
||||
// For shell commands, override auto-approval for
|
||||
// destructive patterns that should always require
|
||||
// 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)
|
||||
{
|
||||
// Let the tool inspect the specific parameters and
|
||||
// override auto-approval (e.g. destructive shell commands).
|
||||
if is_auto_approved && tool.requires_approval_for(&tc.arguments) {
|
||||
tracing::info!(
|
||||
"Shell command '{}' requires explicit approval despite auto-approve",
|
||||
cmd.chars().take(80).collect::<String>()
|
||||
tool = %tc.name,
|
||||
"Tool requires explicit approval for these parameters despite auto-approve"
|
||||
);
|
||||
is_auto_approved = false;
|
||||
}
|
||||
@@ -1208,6 +1273,47 @@ 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
|
||||
.channels
|
||||
.send_status(
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::config::AgentConfig;
|
||||
use crate::context::{ContextManager, JobContext, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::{Error, JobError};
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::LlmProvider;
|
||||
use crate::safety::SafetyLayer;
|
||||
use crate::tools::ToolRegistry;
|
||||
@@ -49,6 +50,7 @@ pub struct Scheduler {
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
/// Running jobs (main LLM-driven jobs).
|
||||
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
|
||||
/// Running sub-tasks (tool executions, background tasks).
|
||||
@@ -64,6 +66,7 @@ impl Scheduler {
|
||||
safety: Arc<SafetyLayer>,
|
||||
tools: Arc<ToolRegistry>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
hooks: Arc<HookRegistry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
@@ -72,6 +75,7 @@ impl Scheduler {
|
||||
safety,
|
||||
tools,
|
||||
store,
|
||||
hooks,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
subtasks: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
@@ -118,6 +122,7 @@ impl Scheduler {
|
||||
safety: self.safety.clone(),
|
||||
tools: self.tools.clone(),
|
||||
store: self.store.clone(),
|
||||
hooks: self.hooks.clone(),
|
||||
timeout: self.config.job_timeout,
|
||||
use_planning: self.config.use_planning,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::agent::session::Session;
|
||||
use crate::agent::undo::UndoManager;
|
||||
use crate::hooks::HookRegistry;
|
||||
|
||||
/// Key for mapping external thread IDs to internal ones.
|
||||
#[derive(Clone, Hash, Eq, PartialEq)]
|
||||
@@ -25,6 +26,7 @@ pub struct SessionManager {
|
||||
sessions: RwLock<HashMap<String, Arc<Mutex<Session>>>>,
|
||||
thread_map: RwLock<HashMap<ThreadKey, Uuid>>,
|
||||
undo_managers: RwLock<HashMap<Uuid, Arc<Mutex<UndoManager>>>>,
|
||||
hooks: Option<Arc<HookRegistry>>,
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
@@ -34,9 +36,16 @@ impl SessionManager {
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
thread_map: 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.
|
||||
pub async fn get_or_create_session(&self, user_id: &str) -> Arc<Mutex<Session>> {
|
||||
// Fast path: check if session exists
|
||||
@@ -54,8 +63,28 @@ impl SessionManager {
|
||||
return Arc::clone(session);
|
||||
}
|
||||
|
||||
let session = Arc::new(Mutex::new(Session::new(user_id)));
|
||||
let new_session = 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));
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -173,8 +202,8 @@ impl SessionManager {
|
||||
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);
|
||||
|
||||
// Find stale session user_ids
|
||||
let stale_users: Vec<String> = {
|
||||
// Find stale sessions (user_id + session_id)
|
||||
let stale_sessions: Vec<(String, String)> = {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions
|
||||
.iter()
|
||||
@@ -182,7 +211,7 @@ impl SessionManager {
|
||||
// Try to lock; skip if contended (someone is actively using it)
|
||||
let sess = session.try_lock().ok()?;
|
||||
if sess.last_active_at < cutoff {
|
||||
Some(user_id.clone())
|
||||
Some((user_id.clone(), sess.id.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -190,6 +219,11 @@ impl SessionManager {
|
||||
.collect()
|
||||
};
|
||||
|
||||
let stale_users: Vec<String> = stale_sessions
|
||||
.iter()
|
||||
.map(|(user_id, _)| user_id.clone())
|
||||
.collect();
|
||||
|
||||
if stale_users.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
@@ -207,6 +241,25 @@ 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
|
||||
let count = {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
|
||||
+61
-42
@@ -12,6 +12,7 @@ use crate::agent::task::TaskOutput;
|
||||
use crate::context::{ContextManager, JobState};
|
||||
use crate::db::Database;
|
||||
use crate::error::Error;
|
||||
use crate::hooks::HookRegistry;
|
||||
use crate::llm::{
|
||||
ActionPlan, ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection,
|
||||
};
|
||||
@@ -29,6 +30,7 @@ pub struct WorkerDeps {
|
||||
pub safety: Arc<SafetyLayer>,
|
||||
pub tools: Arc<ToolRegistry>,
|
||||
pub store: Option<Arc<dyn Database>>,
|
||||
pub hooks: Arc<HookRegistry>,
|
||||
pub timeout: Duration,
|
||||
pub use_planning: bool,
|
||||
}
|
||||
@@ -352,23 +354,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.map(|selection| {
|
||||
let tool_name = selection.tool_name.clone();
|
||||
let params = selection.parameters.clone();
|
||||
let tools = self.tools().clone();
|
||||
let context_manager = self.context_manager().clone();
|
||||
let safety = self.safety().clone();
|
||||
let deps = self.deps.clone();
|
||||
let job_id = self.job_id;
|
||||
let store = self.deps.store.clone();
|
||||
|
||||
async move {
|
||||
let result = Self::execute_tool_inner(
|
||||
tools,
|
||||
context_manager,
|
||||
safety,
|
||||
store,
|
||||
job_id,
|
||||
&tool_name,
|
||||
¶ms,
|
||||
)
|
||||
.await;
|
||||
let result = Self::execute_tool_inner(&deps, job_id, &tool_name, ¶ms).await;
|
||||
ToolExecResult { result }
|
||||
}
|
||||
})
|
||||
@@ -379,20 +369,18 @@ 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.
|
||||
async fn execute_tool_inner(
|
||||
tools: Arc<ToolRegistry>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
safety: Arc<SafetyLayer>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
deps: &WorkerDeps,
|
||||
job_id: Uuid,
|
||||
tool_name: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<String, Error> {
|
||||
let tool = tools
|
||||
.get(tool_name)
|
||||
.await
|
||||
.ok_or_else(|| crate::error::ToolError::NotFound {
|
||||
name: tool_name.to_string(),
|
||||
})?;
|
||||
let tool =
|
||||
deps.tools
|
||||
.get(tool_name)
|
||||
.await
|
||||
.ok_or_else(|| crate::error::ToolError::NotFound {
|
||||
name: tool_name.to_string(),
|
||||
})?;
|
||||
|
||||
// Tools requiring approval are blocked in autonomous jobs
|
||||
if tool.requires_approval() {
|
||||
@@ -402,8 +390,46 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.into());
|
||||
}
|
||||
|
||||
// Get job context for the tool
|
||||
let job_ctx = context_manager.get_context(job_id).await?;
|
||||
// Fetch job context early so we have the real user_id for hooks
|
||||
let job_ctx = deps.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 {
|
||||
return Err(crate::error::ToolError::ExecutionFailed {
|
||||
name: tool_name.to_string(),
|
||||
@@ -413,7 +439,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
}
|
||||
|
||||
// Validate tool parameters
|
||||
let validation = safety.validator().validate_tool_params(params);
|
||||
let validation = deps.safety.validator().validate_tool_params(¶ms);
|
||||
if !validation.is_valid {
|
||||
let details = validation
|
||||
.errors
|
||||
@@ -478,8 +504,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
Ok(Ok(output)) => {
|
||||
let output_str = serde_json::to_string_pretty(&output.result)
|
||||
.ok()
|
||||
.map(|s| safety.sanitize_tool_output(tool_name, &s).content);
|
||||
context_manager
|
||||
.map(|s| deps.safety.sanitize_tool_output(tool_name, &s).content);
|
||||
deps.context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem.create_action(tool_name, params.clone()).succeed(
|
||||
output_str.clone(),
|
||||
@@ -492,7 +518,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
Ok(Err(e)) => context_manager
|
||||
Ok(Err(e)) => deps
|
||||
.context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem
|
||||
.create_action(tool_name, params.clone())
|
||||
@@ -502,7 +529,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
})
|
||||
.await
|
||||
.ok(),
|
||||
Err(_) => context_manager
|
||||
Err(_) => deps
|
||||
.context_manager
|
||||
.update_memory(job_id, |mem| {
|
||||
let rec = mem
|
||||
.create_action(tool_name, params.clone())
|
||||
@@ -515,7 +543,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
};
|
||||
|
||||
// Persist action to database (fire-and-forget)
|
||||
if let (Some(action), Some(store)) = (action, store) {
|
||||
if let (Some(action), Some(store)) = (action, deps.store.clone()) {
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store.save_action(job_id, &action).await {
|
||||
tracing::warn!("Failed to persist action for job {}: {}", job_id, e);
|
||||
@@ -701,16 +729,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
||||
tool_name: &str,
|
||||
params: &serde_json::Value,
|
||||
) -> Result<String, Error> {
|
||||
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
|
||||
Self::execute_tool_inner(&self.deps, self.job_id, tool_name, params).await
|
||||
}
|
||||
|
||||
async fn mark_completed(&self) -> Result<(), Error> {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -184,6 +184,8 @@ pub struct ReplChannel {
|
||||
debug_mode: Arc<AtomicBool>,
|
||||
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
|
||||
is_streaming: Arc<AtomicBool>,
|
||||
/// When true, the one-liner startup banner is suppressed (boot screen shown instead).
|
||||
suppress_banner: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ReplChannel {
|
||||
@@ -193,6 +195,7 @@ impl ReplChannel {
|
||||
single_message: None,
|
||||
debug_mode: Arc::new(AtomicBool::new(false)),
|
||||
is_streaming: Arc::new(AtomicBool::new(false)),
|
||||
suppress_banner: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +205,15 @@ impl ReplChannel {
|
||||
single_message: Some(message),
|
||||
debug_mode: 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 {
|
||||
self.debug_mode.load(Ordering::Relaxed)
|
||||
}
|
||||
@@ -264,6 +273,7 @@ impl Channel for ReplChannel {
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
let single_message = self.single_message.clone();
|
||||
let debug_mode = Arc::clone(&self.debug_mode);
|
||||
let suppress_banner = Arc::clone(&self.suppress_banner);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// Single message mode: send it and return
|
||||
@@ -298,8 +308,10 @@ impl Channel for ReplChannel {
|
||||
}
|
||||
let _ = rl.load_history(&hist_path);
|
||||
|
||||
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
||||
println!();
|
||||
if !suppress_banner.load(Ordering::Relaxed) {
|
||||
println!("\x1b[1mIronClaw\x1b[0m /help for commands, /quit to exit");
|
||||
println!();
|
||||
}
|
||||
|
||||
loop {
|
||||
let prompt = if debug_mode.load(Ordering::Relaxed) {
|
||||
|
||||
@@ -153,6 +153,15 @@ pub enum DatabaseBackend {
|
||||
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 {
|
||||
type Err = String;
|
||||
|
||||
@@ -388,6 +397,9 @@ impl std::str::FromStr for NearAiApiMode {
|
||||
pub struct NearAiConfig {
|
||||
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
|
||||
pub model: String,
|
||||
/// Cheap/fast model for lightweight tasks (heartbeat, routing, evaluation).
|
||||
/// Falls back to the main model if not set.
|
||||
pub cheap_model: Option<String>,
|
||||
/// Base URL for the NEAR AI API (default: https://api.near.ai)
|
||||
pub base_url: String,
|
||||
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
|
||||
@@ -454,6 +466,7 @@ impl LlmConfig {
|
||||
"fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic"
|
||||
.to_string()
|
||||
}),
|
||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||
base_url: optional_env("NEARAI_BASE_URL")?
|
||||
.unwrap_or_else(|| "https://cloud-api.near.ai".to_string()),
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
|
||||
@@ -40,6 +40,9 @@ pub enum Error {
|
||||
#[error("Workspace error: {0}")]
|
||||
Workspace(#[from] WorkspaceError),
|
||||
|
||||
#[error("Hook error: {0}")]
|
||||
Hook(#[from] crate::hooks::HookError),
|
||||
|
||||
#[error("Orchestrator error: {0}")]
|
||||
Orchestrator(#[from] OrchestratorError),
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! 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>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,555 @@
|
||||
//! 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(¤t_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(¤t_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());
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
//! - **Continuous learning** - Improve estimates from historical data
|
||||
|
||||
pub mod agent;
|
||||
pub mod boot_screen;
|
||||
pub mod bootstrap;
|
||||
pub mod channels;
|
||||
pub mod cli;
|
||||
@@ -50,6 +51,7 @@ pub mod estimation;
|
||||
pub mod evaluation;
|
||||
pub mod extensions;
|
||||
pub mod history;
|
||||
pub mod hooks;
|
||||
pub mod llm;
|
||||
pub mod orchestrator;
|
||||
pub mod pairing;
|
||||
|
||||
+103
@@ -183,3 +183,106 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
|
||||
);
|
||||
Ok(Arc::new(RigAdapter::new(model, &compat.model)))
|
||||
}
|
||||
|
||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||
///
|
||||
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
|
||||
/// Currently only supports NEAR AI backends (Responses and ChatCompletions modes).
|
||||
pub fn create_cheap_llm_provider(
|
||||
config: &LlmConfig,
|
||||
session: Arc<SessionManager>,
|
||||
) -> Result<Option<Arc<dyn LlmProvider>>, LlmError> {
|
||||
let Some(ref cheap_model) = config.nearai.cheap_model else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if config.backend != LlmBackend::NearAi {
|
||||
tracing::warn!(
|
||||
"NEARAI_CHEAP_MODEL is set but LLM_BACKEND is {:?}, not NearAi. \
|
||||
Cheap model setting will be ignored.",
|
||||
config.backend
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut cheap_config = config.nearai.clone();
|
||||
cheap_config.model = cheap_model.clone();
|
||||
|
||||
tracing::info!("Cheap LLM provider: {}", cheap_model);
|
||||
|
||||
match cheap_config.api_mode {
|
||||
NearAiApiMode::Responses => Ok(Some(Arc::new(NearAiProvider::new(cheap_config, session)))),
|
||||
NearAiApiMode::ChatCompletions => {
|
||||
Ok(Some(Arc::new(NearAiChatProvider::new(cheap_config)?)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{LlmBackend, NearAiApiMode, NearAiConfig};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_nearai_config() -> NearAiConfig {
|
||||
NearAiConfig {
|
||||
model: "test-model".to_string(),
|
||||
cheap_model: None,
|
||||
base_url: "https://api.near.ai".to_string(),
|
||||
auth_base_url: "https://private.near.ai".to_string(),
|
||||
session_path: PathBuf::from("/tmp/test-session.json"),
|
||||
api_mode: NearAiApiMode::Responses,
|
||||
api_key: None,
|
||||
fallback_model: None,
|
||||
max_retries: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_llm_config() -> LlmConfig {
|
||||
LlmConfig {
|
||||
backend: LlmBackend::NearAi,
|
||||
nearai: test_nearai_config(),
|
||||
openai: None,
|
||||
anthropic: None,
|
||||
ollama: None,
|
||||
openai_compatible: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_returns_none_when_not_configured() {
|
||||
let config = test_llm_config();
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
|
||||
let result = create_cheap_llm_provider(&config, session);
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_creates_provider_when_configured() {
|
||||
let mut config = test_llm_config();
|
||||
config.nearai.cheap_model = Some("cheap-test-model".to_string());
|
||||
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
let result = create_cheap_llm_provider(&config, session);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let provider = result.unwrap();
|
||||
assert!(provider.is_some());
|
||||
assert_eq!(provider.unwrap().model_name(), "cheap-test-model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_cheap_llm_provider_ignored_for_non_nearai_backend() {
|
||||
let mut config = test_llm_config();
|
||||
config.backend = LlmBackend::OpenAi;
|
||||
config.nearai.cheap_model = Some("cheap-test-model".to_string());
|
||||
|
||||
let session = Arc::new(SessionManager::new(SessionConfig::default()));
|
||||
let result = create_cheap_llm_provider(&config, session);
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+87
-12
@@ -22,9 +22,10 @@ use ironclaw::{
|
||||
config::Config,
|
||||
context::ContextManager,
|
||||
extensions::ExtensionManager,
|
||||
hooks::HookRegistry,
|
||||
llm::{
|
||||
FailoverProvider, LlmProvider, SessionConfig, create_llm_provider,
|
||||
create_llm_provider_with_config, create_session_manager,
|
||||
FailoverProvider, LlmProvider, SessionConfig, create_cheap_llm_provider,
|
||||
create_llm_provider, create_llm_provider_with_config, create_session_manager,
|
||||
},
|
||||
orchestrator::{
|
||||
ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore,
|
||||
@@ -307,8 +308,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
let session = create_session_manager(session_config).await;
|
||||
|
||||
// Ensure we're authenticated before proceeding (only needed for NEAR AI backend)
|
||||
if config.llm.backend == ironclaw::config::LlmBackend::NearAi {
|
||||
// Session-based auth is only needed for NEAR AI backend without an API key.
|
||||
// ChatCompletions mode with an API key skips session auth entirely.
|
||||
if config.llm.backend == ironclaw::config::LlmBackend::NearAi
|
||||
&& config.llm.nearai.api_key.is_none()
|
||||
{
|
||||
session.ensure_authenticated().await?;
|
||||
}
|
||||
|
||||
@@ -334,7 +338,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
let repl_channel = if let Some(ref msg) = cli.message {
|
||||
Some(ReplChannel::with_message(msg.clone()))
|
||||
} else if config.channels.cli.enabled {
|
||||
Some(ReplChannel::new())
|
||||
let repl = ReplChannel::new();
|
||||
// Suppress the one-liner banner; boot screen will be shown instead.
|
||||
repl.suppress_banner();
|
||||
Some(repl)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -534,6 +541,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
llm
|
||||
};
|
||||
|
||||
// Initialize cheap LLM provider for lightweight tasks (heartbeat, evaluation)
|
||||
let cheap_llm = create_cheap_llm_provider(&config.llm, session.clone())?;
|
||||
if let Some(ref cheap) = cheap_llm {
|
||||
tracing::info!("Cheap LLM provider initialized: {}", cheap.model_name());
|
||||
}
|
||||
|
||||
// Initialize safety layer
|
||||
let safety = Arc::new(SafetyLayer::new(&config.safety));
|
||||
tracing::info!("Safety layer initialized");
|
||||
@@ -876,12 +889,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// Initialize channel manager
|
||||
let mut channels = ChannelManager::new();
|
||||
let mut channel_names: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(repl) = repl_channel {
|
||||
channels.add(Box::new(repl));
|
||||
if cli.message.is_some() {
|
||||
tracing::info!("Single message mode");
|
||||
} else {
|
||||
channel_names.push("repl".to_string());
|
||||
tracing::info!("REPL mode enabled");
|
||||
}
|
||||
}
|
||||
@@ -1017,6 +1032,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
channel_names.push(channel_name.clone());
|
||||
channels.add(Box::new(SharedWasmChannel::new(channel_arc)));
|
||||
}
|
||||
|
||||
@@ -1061,6 +1077,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.parse()
|
||||
.expect("HttpConfig host:port must be a valid SocketAddr"),
|
||||
);
|
||||
channel_names.push("http".to_string());
|
||||
channels.add(Box::new(http_channel));
|
||||
tracing::info!(
|
||||
"HTTP channel enabled on {}:{}",
|
||||
@@ -1123,8 +1140,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Create context manager (shared between job tools and agent)
|
||||
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)
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
let session_manager = Arc::new(SessionManager::new().with_hooks(hooks.clone()));
|
||||
|
||||
// Register job tools (sandbox deps auto-injected when container_job_manager is available)
|
||||
tools.register_job_tools(
|
||||
@@ -1134,6 +1154,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// Add web gateway channel if configured
|
||||
let mut gateway_url: Option<String> = None;
|
||||
if let Some(ref gw_config) = config.channels.gateway {
|
||||
let mut gw = GatewayChannel::new(gw_config.clone());
|
||||
if let Some(ref ws) = workspace {
|
||||
@@ -1166,29 +1187,39 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
gateway_url = Some(format!(
|
||||
"http://{}:{}/?token={}",
|
||||
gw_config.host,
|
||||
gw_config.port,
|
||||
gw.auth_token()
|
||||
));
|
||||
|
||||
tracing::info!(
|
||||
"Web gateway enabled on {}:{}",
|
||||
gw_config.host,
|
||||
gw_config.port
|
||||
);
|
||||
tracing::info!(
|
||||
"Web UI: http://{}:{}/?token={}",
|
||||
gw_config.host,
|
||||
gw_config.port,
|
||||
gw.auth_token()
|
||||
);
|
||||
tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
|
||||
|
||||
channel_names.push("gateway".to_string());
|
||||
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
|
||||
let deps = AgentDeps {
|
||||
store: db,
|
||||
llm,
|
||||
cheap_llm,
|
||||
safety,
|
||||
tools,
|
||||
workspace,
|
||||
extension_manager,
|
||||
hooks,
|
||||
};
|
||||
let agent = Agent::new(
|
||||
config.agent.clone(),
|
||||
@@ -1202,6 +1233,38 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
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)
|
||||
agent.run().await?;
|
||||
|
||||
@@ -1229,6 +1292,18 @@ fn check_onboard_needed() -> Option<&'static str> {
|
||||
return Some("Database not configured");
|
||||
}
|
||||
|
||||
// First run (onboarding never completed and no session).
|
||||
// Reads NEARAI_API_KEY env var directly because this function runs
|
||||
// before Config is loaded -- Config::from_env() may fail without a
|
||||
// database URL, which is what triggers onboarding in the first place.
|
||||
if std::env::var("NEARAI_API_KEY").is_err() {
|
||||
let settings = ironclaw::settings::Settings::load();
|
||||
let session_path = ironclaw::llm::session::default_session_path();
|
||||
if !settings.onboard_completed && !session_path.exists() {
|
||||
return Some("First run");
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -1014,6 +1014,7 @@ impl SetupWizard {
|
||||
backend: crate::config::LlmBackend::NearAi,
|
||||
nearai: crate::config::NearAiConfig {
|
||||
model: "dummy".to_string(),
|
||||
cheap_model: None,
|
||||
base_url,
|
||||
auth_base_url,
|
||||
session_path: crate::llm::session::default_session_path(),
|
||||
@@ -2089,8 +2090,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
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
|
||||
// rather than fail when the telegram channel hasn't been compiled.
|
||||
if !available_channel_names().contains(&"telegram") {
|
||||
|
||||
@@ -426,6 +426,26 @@ impl Tool for ShellTool {
|
||||
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 {
|
||||
true // Shell output could contain anything
|
||||
}
|
||||
@@ -566,6 +586,34 @@ mod tests {
|
||||
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]
|
||||
fn test_sandbox_policy_builder() {
|
||||
let tool = ShellTool::new()
|
||||
|
||||
@@ -172,6 +172,21 @@ pub trait Tool: Send + Sync {
|
||||
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.
|
||||
/// Override for long-running tools like sandbox execution.
|
||||
/// Default: 60 seconds.
|
||||
@@ -330,4 +345,11 @@ mod tests {
|
||||
let err = require_param(¶ms, "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"})));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user