mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 08:00:17 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01bfecb48 | ||
|
|
940c182fea | ||
|
|
654bda9239 | ||
|
|
13cde37722 | ||
|
|
f5123055b5 |
@@ -115,8 +115,6 @@ AGENT_NAME=ironclaw
|
|||||||
AGENT_MAX_PARALLEL_JOBS=5
|
AGENT_MAX_PARALLEL_JOBS=5
|
||||||
AGENT_JOB_TIMEOUT_SECS=3600
|
AGENT_JOB_TIMEOUT_SECS=3600
|
||||||
AGENT_STUCK_THRESHOLD_SECS=300
|
AGENT_STUCK_THRESHOLD_SECS=300
|
||||||
# Maximum tokens per job (0 = unlimited, also settable via settings.json agent.max_tokens_per_job)
|
|
||||||
# AGENT_MAX_TOKENS_PER_JOB=0
|
|
||||||
# Enable planning phase before tool execution (default: true)
|
# Enable planning phase before tool execution (default: true)
|
||||||
AGENT_USE_PLANNING=true
|
AGENT_USE_PLANNING=true
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Claude Code Review
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [labeled]
|
types: [opened, labeled]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -28,7 +28,6 @@ jobs:
|
|||||||
uses: anthropics/claude-code-action@v1
|
uses: anthropics/claude-code-action@v1
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
allowed_bots: "ironclaw-ci[bot]"
|
|
||||||
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
|
claude_args: "--max-turns 50 --model claude-haiku-4-5-20251001 --allowedTools 'Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(git blame:*),Bash(git log:*),Bash(git diff:*)'"
|
||||||
prompt: |
|
prompt: |
|
||||||
Code review this pull request. Follow these steps precisely:
|
Code review this pull request. Follow these steps precisely:
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ jobs:
|
|||||||
|
|
||||||
clippy-windows:
|
clippy-windows:
|
||||||
name: Clippy Windows (${{ matrix.name }})
|
name: Clippy Windows (${{ matrix.name }})
|
||||||
if: github.base_ref == 'main'
|
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -77,12 +76,7 @@ jobs:
|
|||||||
needs: [format, clippy, clippy-windows]
|
needs: [format, clippy, clippy-windows]
|
||||||
steps:
|
steps:
|
||||||
- run: |
|
- run: |
|
||||||
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" ]]; then
|
if [[ "${{ needs.format.result }}" != "success" || "${{ needs.clippy.result }}" != "success" || "${{ needs.clippy-windows.result }}" != "success" ]]; then
|
||||||
echo "One or more jobs failed"
|
echo "One or more jobs failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# clippy-windows only runs on main PRs, so skip/success are both acceptable
|
|
||||||
if [[ "${{ needs.clippy-windows.result }}" == "failure" ]]; then
|
|
||||||
echo "Windows clippy failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate GitHub App token
|
- name: Generate GitHub App token
|
||||||
id: app-token
|
id: app-token
|
||||||
|
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
|
||||||
uses: actions/create-github-app-token@v2
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||||
@@ -229,6 +230,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Generate GitHub App token
|
- name: Generate GitHub App token
|
||||||
id: app-token
|
id: app-token
|
||||||
|
if: ${{ secrets.GH_RELEASES_MANAGER_APP_ID != '' }}
|
||||||
uses: actions/create-github-app-token@v2
|
uses: actions/create-github-app-token@v2
|
||||||
with:
|
with:
|
||||||
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }}
|
||||||
@@ -406,10 +408,6 @@ jobs:
|
|||||||
echo "passed=true" >> "$GITHUB_OUTPUT"
|
echo "passed=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Only merge PRs targeting main. Chained PRs (targeting another
|
|
||||||
# promotion branch) stay open — when the base PR merges into main,
|
|
||||||
# GitHub auto-retargets the chained PR. Merging chained PRs would
|
|
||||||
# trigger delete_branch_on_merge, auto-closing downstream PRs.
|
|
||||||
- name: Merge promotion PR
|
- name: Merge promotion PR
|
||||||
id: merge
|
id: merge
|
||||||
if: steps.evaluate.outputs.passed == 'true'
|
if: steps.evaluate.outputs.passed == 'true'
|
||||||
@@ -418,15 +416,12 @@ jobs:
|
|||||||
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
PR_NUMBER: ${{ needs.create-promotion-pr.outputs.pr_number }}
|
||||||
run: |
|
run: |
|
||||||
if [ -n "$PR_NUMBER" ]; then
|
if [ -n "$PR_NUMBER" ]; then
|
||||||
BASE=$(gh pr view "$PR_NUMBER" --json baseRefName --jq '.baseRefName')
|
echo "Merging promotion PR #${PR_NUMBER}"
|
||||||
if [ "$BASE" = "main" ]; then
|
# Do NOT use --delete-branch: deleting a promotion branch closes
|
||||||
echo "Merging promotion PR #${PR_NUMBER} (targets main)"
|
# any chained PRs that use it as their base (verified in ironclaw-ci-test).
|
||||||
|
# Stale promotion branches are cleaned up separately.
|
||||||
gh pr merge "$PR_NUMBER" --merge
|
gh pr merge "$PR_NUMBER" --merge
|
||||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
|
||||||
echo "PR #${PR_NUMBER} targets '${BASE}' (not main) — leaving open for chain resolution"
|
|
||||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Update tested tag (always, so next batch covers only new commits) ──
|
# ── Update tested tag (always, so next batch covers only new commits) ──
|
||||||
@@ -444,7 +439,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: staging
|
ref: staging
|
||||||
fetch-depth: 0
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Update staging-tested tag
|
- name: Update staging-tested tag
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ name: Run Tests
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ trace_*.json
|
|||||||
|
|
||||||
# Local Claude Code settings (machine-specific, should not be committed)
|
# Local Claude Code settings (machine-specific, should not be committed)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
rust_out
|
||||||
|
|||||||
@@ -7,81 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.17.0](https://github.com/nearai/ironclaw/compare/v0.16.1...v0.17.0) - 2026-03-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- *(llm)* per-provider unsupported parameter filtering (#749, #728) ([#809](https://github.com/nearai/ironclaw/pull/809))
|
|
||||||
- persist user_id in save_job and expose job_id on routine runs ([#709](https://github.com/nearai/ironclaw/pull/709))
|
|
||||||
- *(ci)* chained promotion PRs with multi-agent Claude review ([#776](https://github.com/nearai/ironclaw/pull/776))
|
|
||||||
- add background sandbox reaper for orphaned Docker containers ([#634](https://github.com/nearai/ironclaw/pull/634))
|
|
||||||
- *(wasm)* lazy schema injection on WASM tool errors ([#638](https://github.com/nearai/ironclaw/pull/638))
|
|
||||||
- add AWS Bedrock LLM provider via native Converse API ([#713](https://github.com/nearai/ironclaw/pull/713))
|
|
||||||
- full image support across all channels ([#725](https://github.com/nearai/ironclaw/pull/725))
|
|
||||||
- *(skills)* exclude_keywords veto in skill activation scoring ([#688](https://github.com/nearai/ironclaw/pull/688))
|
|
||||||
- *(mcp)* transport abstraction, stdio/UDS transports, and OAuth fixes ([#721](https://github.com/nearai/ironclaw/pull/721))
|
|
||||||
- add PID-based gateway lock to prevent multiple instances ([#717](https://github.com/nearai/ironclaw/pull/717))
|
|
||||||
- configurable LLM request timeout via LLM_REQUEST_TIMEOUT_SECS ([#615](https://github.com/nearai/ironclaw/pull/615)) ([#630](https://github.com/nearai/ironclaw/pull/630))
|
|
||||||
- *(timezone)* add timezone-aware session context ([#671](https://github.com/nearai/ironclaw/pull/671))
|
|
||||||
- *(setup)* Anthropic OAuth onboarding with setup-token support ([#384](https://github.com/nearai/ironclaw/pull/384))
|
|
||||||
- *(llm)* add Google Gemini, AWS Bedrock, io.net, Mistral, Yandex, and Cloudflare WS AI providers ([#676](https://github.com/nearai/ironclaw/pull/676))
|
|
||||||
- unified thread model for web gateway ([#607](https://github.com/nearai/ironclaw/pull/607))
|
|
||||||
- WASM channel attachments with LLM pipeline integration ([#596](https://github.com/nearai/ironclaw/pull/596))
|
|
||||||
- enable Anthropic prompt caching via automatic cache_control injection ([#660](https://github.com/nearai/ironclaw/pull/660))
|
|
||||||
- *(routines)* approval context for autonomous job execution ([#577](https://github.com/nearai/ironclaw/pull/577))
|
|
||||||
- *(llm)* declarative provider registry ([#618](https://github.com/nearai/ironclaw/pull/618))
|
|
||||||
- *(gateway)* show IronClaw version in status popover [skip-regression-check] ([#636](https://github.com/nearai/ironclaw/pull/636))
|
|
||||||
- Wire memory hygiene retention policy into heartbeat loop ([#629](https://github.com/nearai/ironclaw/pull/629))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- *(ci)* run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] ([#802](https://github.com/nearai/ironclaw/pull/802))
|
|
||||||
- *(ci)* clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] ([#794](https://github.com/nearai/ironclaw/pull/794))
|
|
||||||
- *(ci)* secrets can't be used in step if conditions [skip-regression-check] ([#787](https://github.com/nearai/ironclaw/pull/787))
|
|
||||||
- prevent irreversible context loss when compaction archive write fails ([#754](https://github.com/nearai/ironclaw/pull/754))
|
|
||||||
- button styles ([#637](https://github.com/nearai/ironclaw/pull/637))
|
|
||||||
- *(mcp)* JSON-RPC spec compliance — flexible id, correct notification format ([#685](https://github.com/nearai/ironclaw/pull/685))
|
|
||||||
- preserve tool-call history across thread hydration ([#568](https://github.com/nearai/ironclaw/pull/568)) ([#670](https://github.com/nearai/ironclaw/pull/670))
|
|
||||||
- CLI commands ignore runtime DATABASE_BACKEND when both features compiled ([#740](https://github.com/nearai/ironclaw/pull/740))
|
|
||||||
- *(web)* prevent fetch error when hostname is an IP address in TEE check ([#672](https://github.com/nearai/ironclaw/pull/672))
|
|
||||||
- add timezone conversion support to time tool ([#687](https://github.com/nearai/ironclaw/pull/687))
|
|
||||||
- standardize libSQL timestamps as RFC 3339 UTC ([#683](https://github.com/nearai/ironclaw/pull/683))
|
|
||||||
- *(docker)* bind postgres to localhost only ([#686](https://github.com/nearai/ironclaw/pull/686))
|
|
||||||
- *(repl)* skip /quit on EOF when stdin is not a TTY ([#724](https://github.com/nearai/ironclaw/pull/724))
|
|
||||||
- *(web)* prevent Enter key from sending message during IME composition ([#715](https://github.com/nearai/ironclaw/pull/715))
|
|
||||||
- *(config)* init_secrets no longer overwrites entire config ([#726](https://github.com/nearai/ironclaw/pull/726))
|
|
||||||
- *(cli)* status command ignores config.toml and settings.json ([#354](https://github.com/nearai/ironclaw/pull/354)) ([#734](https://github.com/nearai/ironclaw/pull/734))
|
|
||||||
- *(setup)* preserve model name when re-running onboarding with same provider ([#600](https://github.com/nearai/ironclaw/pull/600)) ([#694](https://github.com/nearai/ironclaw/pull/694))
|
|
||||||
- *(setup)* initialize secrets crypto for env-var security option ([#666](https://github.com/nearai/ironclaw/pull/666)) ([#706](https://github.com/nearai/ironclaw/pull/706))
|
|
||||||
- persist /model selection across restarts ([#707](https://github.com/nearai/ironclaw/pull/707))
|
|
||||||
- *(routines)* resolve message tool channel/target from per-job metadata ([#708](https://github.com/nearai/ironclaw/pull/708))
|
|
||||||
- sanitize HTML error bodies from MCP servers to prevent web UI white screen ([#263](https://github.com/nearai/ironclaw/pull/263)) ([#656](https://github.com/nearai/ironclaw/pull/656))
|
|
||||||
- prevent Instant duration overflow on Windows ([#657](https://github.com/nearai/ironclaw/pull/657)) ([#664](https://github.com/nearai/ironclaw/pull/664))
|
|
||||||
- enable libsql remote + tls features for Turso cloud sync ([#587](https://github.com/nearai/ironclaw/pull/587))
|
|
||||||
- *(tests)* replace hardcoded /tmp paths with tempdir + add 300 unit tests ([#659](https://github.com/nearai/ironclaw/pull/659))
|
|
||||||
- *(llm)* nudge LLM when it expresses tool intent without calling tools ([#653](https://github.com/nearai/ironclaw/pull/653))
|
|
||||||
- *(llm)* report zero cost for OpenRouter free-tier models ([#463](https://github.com/nearai/ironclaw/pull/463)) ([#613](https://github.com/nearai/ironclaw/pull/613))
|
|
||||||
- reliable network tests and improved tool error messages ([#626](https://github.com/nearai/ironclaw/pull/626))
|
|
||||||
- *(wasm)* use per-engine cache dirs on Windows to avoid file lock error ([#624](https://github.com/nearai/ironclaw/pull/624))
|
|
||||||
- *(libsql)* support flexible embedding dimensions ([#534](https://github.com/nearai/ironclaw/pull/534))
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Restructure CLAUDE.md into modular rules + add pr-shepherd command ([#750](https://github.com/nearai/ironclaw/pull/750))
|
|
||||||
- make src/llm/ self-contained for crate extraction ([#767](https://github.com/nearai/ironclaw/pull/767))
|
|
||||||
- add simplified Chinese (zh-CN) README translation ([#488](https://github.com/nearai/ironclaw/pull/488))
|
|
||||||
- *(job)* cover job tool validation and state transitions ([#681](https://github.com/nearai/ironclaw/pull/681))
|
|
||||||
- *(agent)* wire TestRig job tools through the scheduler ([#716](https://github.com/nearai/ironclaw/pull/716))
|
|
||||||
- Fix single-message mode to exit after one turn when background channels are enabled ([#719](https://github.com/nearai/ironclaw/pull/719))
|
|
||||||
- remove dead code ([#648](https://github.com/nearai/ironclaw/pull/648)) ([#703](https://github.com/nearai/ironclaw/pull/703))
|
|
||||||
- add reviewer-feedback guardrails (CLAUDE.md, pre-commit hook, skill) ([#665](https://github.com/nearai/ironclaw/pull/665))
|
|
||||||
- update WASM artifact SHA256 checksums [skip ci] ([#631](https://github.com/nearai/ironclaw/pull/631))
|
|
||||||
- add explanatory comments to coverage workflow ([#610](https://github.com/nearai/ironclaw/pull/610))
|
|
||||||
- build system prompt once per turn, skip tools on force-text ([#583](https://github.com/nearai/ironclaw/pull/583))
|
|
||||||
- add comprehensive subdirectory CLAUDE.md files and update root ([#589](https://github.com/nearai/ironclaw/pull/589))
|
|
||||||
- Improve test infrastructure: StubChannel, gateway helpers, security tests, search edge cases ([#623](https://github.com/nearai/ironclaw/pull/623))
|
|
||||||
- *(workspace)* regression test for document_path in search results ([#509](https://github.com/nearai/ironclaw/pull/509))
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
|
- AWS Bedrock LLM provider via native Converse API with IAM and SSO auth support (feature-gated: `--features bedrock`)
|
||||||
|
|||||||
Generated
+1
-1
@@ -3350,7 +3350,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.17.0"
|
version = "0.16.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ exclude = [
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ironclaw"
|
name = "ironclaw"
|
||||||
version = "0.17.0"
|
version = "0.16.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
|
||||||
|
|||||||
+44
-51
@@ -10,8 +10,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
- 🚫 Out of scope (intentionally skipped)
|
- 🚫 Out of scope (intentionally skipped)
|
||||||
- ➖ N/A (not applicable to Rust implementation)
|
- ➖ N/A (not applicable to Rust implementation)
|
||||||
|
|
||||||
**Last reviewed against OpenClaw PRs:** 2026-03-10 (merged 2026-02-24 through 2026-03-10)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Architecture
|
## 1. Architecture
|
||||||
@@ -41,11 +39,11 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
| Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only |
|
||||||
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
|
| OpenAI-compatible HTTP API | ✅ | ✅ | /v1/chat/completions, per-request `model` override |
|
||||||
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
|
| Canvas hosting | ✅ | ❌ | Agent-driven UI |
|
||||||
| Gateway lock (PID-based) | ✅ | ❌ | |
|
| Gateway lock (PID-based) | ✅ | ✅ | `fs4` flock-based, acquired in `main.rs` before agent startup |
|
||||||
| launchd/systemd integration | ✅ | ❌ | |
|
| launchd/systemd integration | ✅ | ❌ | |
|
||||||
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
| Bonjour/mDNS discovery | ✅ | ❌ | |
|
||||||
| Tailscale integration | ✅ | ❌ | |
|
| Tailscale integration | ✅ | ❌ | |
|
||||||
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status + /healthz + /readyz, with channel-backed readiness probes |
|
| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status |
|
||||||
| `doctor` diagnostics | ✅ | ❌ | |
|
| `doctor` diagnostics | ✅ | ❌ | |
|
||||||
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
|
| Agent event broadcast | ✅ | 🚧 | SSE broadcast manager exists (SseManager) but tool/job-state events not fully wired |
|
||||||
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
|
| Channel health monitor | ✅ | ❌ | Auto-restart with configurable interval |
|
||||||
@@ -68,17 +66,17 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| REPL (simple) | ✅ | ✅ | - | For testing |
|
| REPL (simple) | ✅ | ✅ | - | For testing |
|
||||||
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
| WASM channels | ❌ | ✅ | - | IronClaw innovation |
|
||||||
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
|
| WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection |
|
||||||
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics |
|
| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
|
||||||
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
|
| Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
|
||||||
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
|
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
|
||||||
| Slack | ✅ | ✅ | - | WASM tool |
|
| Slack | ✅ | ✅ | - | WASM tool |
|
||||||
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
|
| iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended |
|
||||||
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
| Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required |
|
||||||
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools, Docx table/image/file actions, rich-text media extraction |
|
| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools |
|
||||||
| LINE | ✅ | ❌ | P3 | |
|
| LINE | ✅ | ❌ | P3 | |
|
||||||
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
| WebChat | ✅ | ✅ | - | Web gateway chat |
|
||||||
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
| Matrix | ✅ | ❌ | P3 | E2EE support |
|
||||||
| Mattermost | ✅ | ❌ | P3 | Emoji reactions, interactive buttons, model picker |
|
| Mattermost | ✅ | ❌ | P3 | Emoji reactions |
|
||||||
| Google Chat | ✅ | ❌ | P3 | |
|
| Google Chat | ✅ | ❌ | P3 | |
|
||||||
| MS Teams | ✅ | ❌ | P3 | |
|
| MS Teams | ✅ | ❌ | P3 | |
|
||||||
| Twitch | ✅ | ❌ | P3 | |
|
| Twitch | ✅ | ❌ | P3 | |
|
||||||
@@ -94,8 +92,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| User message reactions | ✅ | ❌ | Surface inbound reactions |
|
| User message reactions | ✅ | ❌ | Surface inbound reactions |
|
||||||
| sendPoll | ✅ | ❌ | Poll creation via agent |
|
| sendPoll | ✅ | ❌ | Poll creation via agent |
|
||||||
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
|
| Cron/heartbeat topic targeting | ✅ | ❌ | Messages land in correct topic |
|
||||||
| DM topics support | ✅ | ❌ | Agent/topic bindings in DMs and agent-scoped SessionKeys |
|
|
||||||
| Persistent ACP topic binding | ✅ | ❌ | ACP harness sessions can pin to Telegram forum or DM topics |
|
|
||||||
|
|
||||||
### Discord-Specific Features (since Feb 2025)
|
### Discord-Specific Features (since Feb 2025)
|
||||||
|
|
||||||
@@ -111,36 +107,21 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
|
| Streaming draft replies | ✅ | ❌ | Partial replies via draft message updates |
|
||||||
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
|
| Configurable stream modes | ✅ | ❌ | Per-channel stream behavior |
|
||||||
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking plus reply participation memory |
|
| Thread ownership | ✅ | ❌ | Thread-level ownership tracking |
|
||||||
| Download-file action | ✅ | ❌ | On-demand attachment downloads via message actions |
|
|
||||||
|
|
||||||
### Mattermost-Specific Features (since Mar 2026)
|
|
||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
|
||||||
|---------|----------|----------|-------|
|
|
||||||
| Interactive buttons | ✅ | ❌ | Clickable message buttons with signed callback flow |
|
|
||||||
| Interactive model picker | ✅ | ❌ | In-channel provider/model chooser |
|
|
||||||
|
|
||||||
### Feishu/Lark-Specific Features (since Mar 2026)
|
|
||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
|
||||||
|---------|----------|----------|-------|
|
|
||||||
| Doc/table actions | ✅ | ❌ | `feishu_doc` supports tables, positional insert, color_text, image upload, and file upload |
|
|
||||||
| Rich-text embedded media extraction | ✅ | ❌ | Pull video/media attachments from post messages |
|
|
||||||
|
|
||||||
### Channel Features
|
### Channel Features
|
||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Notes |
|
| Feature | OpenClaw | IronClaw | Notes |
|
||||||
|---------|----------|----------|-------|
|
|---------|----------|----------|-------|
|
||||||
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
| DM pairing codes | ✅ | ✅ | `ironclaw pairing list/approve`, host APIs |
|
||||||
| Allowlist/blocklist | ✅ | 🚧 | `allow_from` + pairing store + hardened command/group allowlists |
|
| Allowlist/blocklist | ✅ | 🚧 | allow_from + pairing store |
|
||||||
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
| Self-message bypass | ✅ | ❌ | Own messages skip pairing |
|
||||||
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
| Mention-based activation | ✅ | ✅ | bot_username + respond_to_all_group_messages |
|
||||||
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
| Per-group tool policies | ✅ | ❌ | Allow/deny specific tools |
|
||||||
| Thread isolation | ✅ | ✅ | Separate sessions per thread/topic |
|
| Thread isolation | ✅ | ✅ | Separate sessions per thread |
|
||||||
| Per-channel media limits | ✅ | 🚧 | Caption support plus `mediaMaxMb` enforcement for WhatsApp, Telegram, and Discord |
|
| Per-channel media limits | ✅ | ✅ | Attachment type in WIT; max 10 per msg, 20MB total, MIME allowlist |
|
||||||
| Typing indicators | ✅ | 🚧 | TUI + channel typing, with configurable silence timeout; richer parity pending |
|
| Typing indicators | ✅ | 🚧 | TUI + Telegram typing/actionable status prompts; richer parity pending |
|
||||||
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions/scopes |
|
| Per-channel ackReaction config | ✅ | ❌ | Customizable acknowledgement reactions |
|
||||||
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
| Group session priming | ✅ | ❌ | Member roster injected for context |
|
||||||
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
|
| Sender_id in trusted metadata | ✅ | ❌ | Exposed in system metadata |
|
||||||
|
|
||||||
@@ -157,8 +138,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
| `gateway start/stop` | ✅ | ❌ | P2 | |
|
||||||
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup |
|
||||||
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
| `tui` | ✅ | ✅ | - | Ratatui TUI |
|
||||||
| `config` | ✅ | ✅ | - | Read/write config plus validate/path helpers |
|
| `config` | ✅ | ✅ | - | Read/write config |
|
||||||
| `backup` | ✅ | ❌ | P3 | Create/verify local backup archives |
|
|
||||||
| `channels` | ✅ | ❌ | P2 | Channel management |
|
| `channels` | ✅ | ❌ | P2 | Channel management |
|
||||||
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
| `models` | ✅ | 🚧 | - | Model selector in TUI |
|
||||||
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
| `status` | ✅ | ✅ | - | System status (enriched session details) |
|
||||||
@@ -197,15 +177,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Global sessions | ✅ | ❌ | Optional shared context |
|
| Global sessions | ✅ | ❌ | Optional shared context |
|
||||||
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
| Session pruning | ✅ | ❌ | Auto cleanup old sessions |
|
||||||
| Context compaction | ✅ | ✅ | Auto summarization |
|
| Context compaction | ✅ | ✅ | Auto summarization |
|
||||||
| Compaction model override | ✅ | ❌ | Use a dedicated provider/model for summarization only |
|
|
||||||
| Post-compaction read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
|
| Post-compaction read audit | ✅ | ❌ | Layer 3: workspace rules appended to summaries |
|
||||||
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
|
| Post-compaction context injection | ✅ | ❌ | Workspace context as system event |
|
||||||
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
|
| Custom system prompts | ✅ | ✅ | Template variables, safety guardrails |
|
||||||
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
| Skills (modular capabilities) | ✅ | ✅ | Prompt-based skills with trust gating, attenuation, activation criteria, catalog, selector |
|
||||||
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
| Skill routing blocks | ✅ | 🚧 | ActivationCriteria (keywords, patterns, tags) but no "Use when / Don't use when" blocks |
|
||||||
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
| Skill path compaction | ✅ | ❌ | ~ prefix to reduce prompt tokens |
|
||||||
| Thinking modes (off/minimal/low/medium/high/xhigh/adaptive) | ✅ | ❌ | Configurable reasoning depth |
|
| Thinking modes (low/med/high) | ✅ | ❌ | Configurable reasoning depth |
|
||||||
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model; Anthropic Claude 4.6 defaults to adaptive |
|
| Per-model thinkingDefault override | ✅ | ❌ | Override thinking level per model |
|
||||||
| Block-level streaming | ✅ | ❌ | |
|
| Block-level streaming | ✅ | ❌ | |
|
||||||
| Tool-level streaming | ✅ | ❌ | |
|
| Tool-level streaming | ✅ | ❌ | |
|
||||||
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
|
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
|
||||||
@@ -234,11 +213,15 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Provider | OpenClaw | IronClaw | Priority | Notes |
|
| Provider | OpenClaw | IronClaw | Priority | Notes |
|
||||||
|----------|----------|----------|----------|-------|
|
|----------|----------|----------|----------|-------|
|
||||||
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
| NEAR AI | ✅ | ✅ | - | Primary provider |
|
||||||
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6, adaptive thinking default |
|
| Anthropic (Claude) | ✅ | 🚧 | - | Via NEAR AI proxy; Opus 4.5, Sonnet 4, Sonnet 4.6 |
|
||||||
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy; GPT-5.4 + Codex OAuth |
|
| OpenAI | ✅ | 🚧 | - | Via NEAR AI proxy |
|
||||||
| AWS Bedrock | ✅ | ❌ | P3 | |
|
| AWS Bedrock | ✅ | ✅ | - | Native Converse API via aws-sdk-bedrockruntime (requires `--features bedrock`) |
|
||||||
| Google Gemini | ✅ | ❌ | P3 | |
|
| Google Gemini | ✅ | ✅ | P3 | Via `gemini` adapter |
|
||||||
| NVIDIA API | ✅ | ❌ | P3 | New provider |
|
| io.net | ✅ | ✅ | P3 | Via `ionet` adapter |
|
||||||
|
| Mistral | ✅ | ✅ | P3 | Via `mistral` adapter |
|
||||||
|
| Yandex AI Studio | ✅ | ✅ | P3 | Via `yandex` adapter |
|
||||||
|
| Cloudflare Workers AI | ✅ | ✅ | P3 | Via `cloudflare` adapter |
|
||||||
|
| NVIDIA API | ✅ | ✅ | P3 | Via `nvidia` adapter and `providers.json` |
|
||||||
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
| OpenRouter | ✅ | ✅ | - | Via OpenAI-compatible provider (RigAdapter) |
|
||||||
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
| Tinfoil | ❌ | ✅ | - | Private inference provider (IronClaw-only) |
|
||||||
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
| OpenAI-compatible | ❌ | ✅ | - | Generic OpenAI-compatible endpoint (RigAdapter) |
|
||||||
@@ -259,7 +242,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
| Per-session model override | ✅ | ✅ | Model selector in TUI |
|
||||||
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
|
| Model selection UI | ✅ | ✅ | TUI keyboard shortcut |
|
||||||
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config |
|
| Per-model thinkingDefault | ✅ | ❌ | Override thinking level per model in config |
|
||||||
| 1M context support | ✅ | ❌ | Anthropic extended context beta + OpenAI Codex GPT-5.4 1M context |
|
| 1M context beta header | ✅ | ❌ | Anthropic extended context support |
|
||||||
|
|
||||||
### Owner: _Unassigned_
|
### Owner: _Unassigned_
|
||||||
|
|
||||||
@@ -269,20 +252,32 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
|
|
||||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||||
|---------|----------|----------|----------|-------|
|
|---------|----------|----------|----------|-------|
|
||||||
|
| WIT inbound-attachment type | N/A | ✅ | P1 | `inbound-attachment` record in channel-host (id, mime_type, filename, size_bytes, source_url, storage_key, extracted_text) |
|
||||||
|
| WIT outbound attachment type | N/A | ✅ | P1 | `attachment` record in channel (filename, mime_type, data) on `agent-response` |
|
||||||
|
| WIT on-broadcast export | N/A | ✅ | P1 | Proactive message sending without prior incoming message |
|
||||||
|
| IncomingMessage attachments | N/A | ✅ | P1 | `IncomingAttachment` struct on `IncomingMessage`, populated from WASM channels |
|
||||||
|
| OutgoingResponse attachments | N/A | ✅ | P1 | File paths on `OutgoingResponse`, read from disk and sent as WIT attachments |
|
||||||
|
| Attachment security (size/MIME) | N/A | ✅ | P1 | Inbound: max 10, 20MB total, MIME allowlist. Outbound: 50MB total |
|
||||||
|
| Telegram media parsing | ✅ | ✅ | P1 | Photo, document, audio, video, voice, sticker parsed and emitted as attachments |
|
||||||
|
| Telegram media sending | ✅ | ✅ | P1 | sendPhoto/sendDocument multipart upload, auto photo→document fallback >10MB |
|
||||||
|
| Slack file parsing | ✅ | ✅ | P1 | `files` array from Events API parsed into attachments |
|
||||||
|
| WhatsApp media parsing | ✅ | ✅ | P1 | Image, audio, video, document parsed with caption as extracted_text |
|
||||||
|
| Discord attachment parsing | ✅ | ❌ | P2 | Discord interaction payloads don't include file attachments (needs message events) |
|
||||||
|
| HTTP tool save_to | N/A | ✅ | P1 | Download binary files to /tmp/ for attachment sending (50MB limit, path traversal protection) |
|
||||||
|
| Credential env var fallback | N/A | ✅ | P2 | Channels can use env vars (e.g., TELEGRAM_BOT_TOKEN) when secrets store not configured |
|
||||||
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
|
| Image processing (Sharp) | ✅ | ❌ | P2 | Resize, format convert |
|
||||||
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
|
| Configurable image resize dims | ✅ | ❌ | P2 | Per-agent dimension config |
|
||||||
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
|
| Multiple images per tool call | ✅ | ❌ | P2 | Single tool invocation, multiple images |
|
||||||
| Audio transcription | ✅ | ❌ | P2 | |
|
| Audio transcription | ✅ | ❌ | P2 | |
|
||||||
| Video support | ✅ | ❌ | P3 | |
|
| Video support | ✅ | ❌ | P3 | |
|
||||||
| PDF analysis tool | ✅ | ❌ | P2 | Native Anthropic/Gemini path with text/image extraction fallback |
|
| PDF parsing | ✅ | ❌ | P2 | pdfjs-dist |
|
||||||
| PDF parsing | ✅ | ❌ | P2 | `pdfjs-dist` fallback path |
|
| MIME detection | ✅ | ✅ | P2 | MIME allowlist in host validates attachment types |
|
||||||
| MIME detection | ✅ | ❌ | P2 | |
|
|
||||||
| Media caching | ✅ | ❌ | P3 | |
|
| Media caching | ✅ | ❌ | P3 | |
|
||||||
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
|
| Vision model integration | ✅ | ❌ | P2 | Image understanding |
|
||||||
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
|
| TTS (Edge TTS) | ✅ | ❌ | P3 | Text-to-speech |
|
||||||
| TTS (OpenAI) | ✅ | ❌ | P3 | |
|
| TTS (OpenAI) | ✅ | ❌ | P3 | |
|
||||||
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
|
| Incremental TTS playback | ✅ | ❌ | P3 | iOS progressive playback |
|
||||||
| Sticker-to-image | ✅ | ❌ | P3 | Telegram stickers |
|
| Sticker-to-image | ✅ | ✅ | P3 | Telegram stickers emitted as image/webp attachments |
|
||||||
|
|
||||||
### Owner: _Unassigned_
|
### Owner: _Unassigned_
|
||||||
|
|
||||||
@@ -298,8 +293,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ |
|
| Workspace-relative install | ✅ | ✅ | ~/.ironclaw/tools/ |
|
||||||
| Channel plugins | ✅ | ✅ | WASM channels |
|
| Channel plugins | ✅ | ✅ | WASM channels |
|
||||||
| Auth plugins | ✅ | ❌ | |
|
| Auth plugins | ✅ | ❌ | |
|
||||||
| Memory plugins | ✅ | ❌ | Custom backends + selectable memory slot |
|
| Memory plugins | ✅ | ❌ | Custom backends |
|
||||||
| Context-engine plugins | ✅ | ❌ | Custom context management + subagent/context hooks |
|
|
||||||
| Tool plugins | ✅ | ✅ | WASM tools |
|
| Tool plugins | ✅ | ✅ | WASM tools |
|
||||||
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
|
| Hook plugins | ✅ | ✅ | Declarative hooks from extension capabilities |
|
||||||
| Provider plugins | ✅ | ❌ | |
|
| Provider plugins | ✅ | ❌ | |
|
||||||
@@ -321,7 +315,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| JSON5 support | ✅ | ❌ | Comments, trailing commas |
|
| JSON5 support | ✅ | ❌ | Comments, trailing commas |
|
||||||
| YAML alternative | ✅ | ❌ | |
|
| YAML alternative | ✅ | ❌ | |
|
||||||
| Environment variable interpolation | ✅ | ✅ | `${VAR}` |
|
| Environment variable interpolation | ✅ | ✅ | `${VAR}` |
|
||||||
| Config validation/schema | ✅ | ✅ | Type-safe Config struct + `openclaw config validate` |
|
| Config validation/schema | ✅ | ✅ | Type-safe Config struct |
|
||||||
| Hot-reload | ✅ | ❌ | |
|
| Hot-reload | ✅ | ❌ | |
|
||||||
| Legacy migration | ✅ | ➖ | |
|
| Legacy migration | ✅ | ➖ | |
|
||||||
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
|
| State directory | ✅ `~/.openclaw-state/` | ✅ `~/.ironclaw/` | |
|
||||||
@@ -428,7 +422,6 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
| Feature | OpenClaw | IronClaw | Priority | Notes |
|
||||||
|---------|----------|----------|----------|-------|
|
|---------|----------|----------|----------|-------|
|
||||||
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
| Cron jobs | ✅ | ✅ | - | Routines with cron trigger |
|
||||||
| Per-job model fallback override | ✅ | ❌ | P2 | `payload.fallbacks` overrides agent-level fallbacks |
|
|
||||||
| Cron stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
|
| Cron stagger controls | ✅ | ❌ | P3 | Default stagger for scheduled jobs |
|
||||||
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
|
| Cron finished-run webhook | ✅ | ❌ | P3 | Webhook on job completion |
|
||||||
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
| Timezone support | ✅ | ✅ | - | Via cron expressions |
|
||||||
@@ -482,10 +475,10 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|
|||||||
| Elevated mode | ✅ | ❌ | |
|
| Elevated mode | ✅ | ❌ | |
|
||||||
| Safe bins allowlist | ✅ | ❌ | Hardened path trust |
|
| Safe bins allowlist | ✅ | ❌ | Hardened path trust |
|
||||||
| LD*/DYLD* validation | ✅ | ❌ | |
|
| LD*/DYLD* validation | ✅ | ❌ | |
|
||||||
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) + workspace-only tool mounts |
|
| Path traversal prevention | ✅ | ✅ | Including config includes (OC-06) |
|
||||||
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
|
| Credential theft via env injection | ✅ | 🚧 | Shell env scrubbing + command injection detection; no full OC-09 defense |
|
||||||
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
|
| Session file permissions (0o600) | ✅ | ✅ | Session token file set to 0o600 in llm/session.rs |
|
||||||
| Skill download path restriction | ✅ | ❌ | Validated download roots prevent arbitrary write targets |
|
| Skill download path restriction | ✅ | ❌ | Prevent arbitrary write targets |
|
||||||
| Webhook signature verification | ✅ | ✅ | |
|
| Webhook signature verification | ✅ | ✅ | |
|
||||||
| Media URL validation | ✅ | ❌ | |
|
| Media URL validation | ✅ | ❌ | |
|
||||||
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
|
| Prompt injection defense | ✅ | ✅ | Pattern detection, sanitization |
|
||||||
|
|||||||
+1
-3
@@ -9,9 +9,8 @@
|
|||||||
"api_key_required": true,
|
"api_key_required": true,
|
||||||
"base_url_env": "OPENAI_BASE_URL",
|
"base_url_env": "OPENAI_BASE_URL",
|
||||||
"model_env": "OPENAI_MODEL",
|
"model_env": "OPENAI_MODEL",
|
||||||
"default_model": "gpt-5-mini",
|
"default_model": "gpt-4o",
|
||||||
"description": "OpenAI GPT models (direct API)",
|
"description": "OpenAI GPT models (direct API)",
|
||||||
"unsupported_params": ["temperature"],
|
|
||||||
"setup": {
|
"setup": {
|
||||||
"kind": "api_key",
|
"kind": "api_key",
|
||||||
"secret_name": "llm_openai_api_key",
|
"secret_name": "llm_openai_api_key",
|
||||||
@@ -87,7 +86,6 @@
|
|||||||
"model_env": "TINFOIL_MODEL",
|
"model_env": "TINFOIL_MODEL",
|
||||||
"default_model": "kimi-k2-5",
|
"default_model": "kimi-k2-5",
|
||||||
"description": "Tinfoil private inference (hardware-attested TEE)",
|
"description": "Tinfoil private inference (hardware-attested TEE)",
|
||||||
"unsupported_params": ["temperature"],
|
|
||||||
"setup": {
|
"setup": {
|
||||||
"kind": "api_key",
|
"kind": "api_key",
|
||||||
"secret_name": "llm_tinfoil_api_key",
|
"secret_name": "llm_tinfoil_api_key",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "85b424604482da3fb9badb56a0360ff4c93670bc7be0ad7f57ef9d85ff972b6f"
|
"sha256": "030707431717bca3411a48f311c6ab5f92a45c747de26cafe4f6e3e23a8b3b2d"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286"
|
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664"
|
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "06bcf315df93af9f683134f4055eb810c602863d8c4a632e3733a10217cc5a89"
|
"sha256": "bd35cad18d87292ea8d2f52db9b514ed9f814a414de910f59073d475c26c4c14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "c443328a3f10b6a4cf4d3d62c9217aca204f6467ef753d986b58ca966ca53514"
|
"sha256": "6fcd32719a4ff15641a4b50fff8984686550f0c491dce60518f4126857d0c544"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "e4f0095890d22e3de8e9d516f2e1e91964f8ff4acdaaa19f0a7094a1f2d7786b"
|
"sha256": "023da7000b17568bf0e64b2e5013c8a042b2f323c85f1632339231c73d500e39"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "2d202bd838de94677c91ea6473c7155f021c0500cf91794d17639b1b27446b3d"
|
"sha256": "fc42277b65881d6e9bcc5403dc54c7f5b3ddeaaaf04617fce2c5da05d76325f0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "7a5e40fe58199e34f7625e11d22e5601cdfd2a94a10193a83f1925180bbb66df"
|
"sha256": "385c04abd1e6b8011ccc330e1f4bd7ce58577e488959b51594aa04eb26cbe7cc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "d19f856fde0ae0320fd3f636a34116af1df0b59698c3684b686e8412a60e887f"
|
"sha256": "1b107d575a5d52cc8c76d9a681802190f4373fb485f7f54f445533f097fa37c0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "e113c317f9fa21ea68d0ec8accbba4a62a8222ff3c4655ae85e1e58e01de3250"
|
"sha256": "c4f6b1e8c5126ac2c8a4b98e4283a3afa32223d2488fc3c3a609758c0c9beb90"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "7875a5ae1283e57937e0618bf14465f4bb4ee7f49110312382670202f4c567a5"
|
"sha256": "7110b8565340c888e51f99e9c013bf4de8f8a7f7b33bace00eb8fc47831ff20b"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286"
|
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664"
|
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"artifacts": {
|
"artifacts": {
|
||||||
"wasm32-wasip2": {
|
"wasm32-wasip2": {
|
||||||
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
|
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
|
||||||
"sha256": "dd7e54956ee0b3037ca3506dbcbd20efcc4cd2749175ed511b3640b09f77506a"
|
"sha256": "66cb2b9b00652385e9f30f17c74902b9222c17c53e9d3bd1ef42f5cab705bcf6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auth_summary": {
|
"auth_summary": {
|
||||||
|
|||||||
@@ -446,8 +446,6 @@ impl Agent {
|
|||||||
Arc::clone(workspace),
|
Arc::clone(workspace),
|
||||||
notify_tx,
|
notify_tx,
|
||||||
Some(self.scheduler.clone()),
|
Some(self.scheduler.clone()),
|
||||||
self.tools().clone(),
|
|
||||||
self.safety().clone(),
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Register routine tools
|
// Register routine tools
|
||||||
|
|||||||
@@ -1205,7 +1205,6 @@ mod tests {
|
|||||||
max_tool_iterations: 50,
|
max_tool_iterations: 50,
|
||||||
auto_approve_tools: false,
|
auto_approve_tools: false,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job: 0,
|
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
@@ -1264,96 +1263,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_always_approval_requirement_bypasses_session_auto_approve() {
|
|
||||||
// Regression test: even if tool is auto-approved in session,
|
|
||||||
// ApprovalRequirement::Always must still trigger approval.
|
|
||||||
use crate::tools::ApprovalRequirement;
|
|
||||||
|
|
||||||
let mut session = Session::new("user-1");
|
|
||||||
let tool_name = "tool_remove";
|
|
||||||
|
|
||||||
// Manually auto-approve tool_remove in this session
|
|
||||||
session.auto_approve_tool(tool_name);
|
|
||||||
assert!(
|
|
||||||
session.is_tool_auto_approved(tool_name),
|
|
||||||
"tool should be auto-approved"
|
|
||||||
);
|
|
||||||
|
|
||||||
// However, ApprovalRequirement::Always should always require approval
|
|
||||||
// This is verified by the dispatcher logic: Always => true (ignores session state)
|
|
||||||
let always_req = ApprovalRequirement::Always;
|
|
||||||
let requires_approval = match always_req {
|
|
||||||
ApprovalRequirement::Never => false,
|
|
||||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
|
|
||||||
ApprovalRequirement::Always => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
requires_approval,
|
|
||||||
"ApprovalRequirement::Always must require approval even when tool is auto-approved"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_always_approval_requirement_vs_unless_auto_approved() {
|
|
||||||
// Verify the two requirements behave differently
|
|
||||||
use crate::tools::ApprovalRequirement;
|
|
||||||
|
|
||||||
let mut session = Session::new("user-2");
|
|
||||||
let tool_name = "http";
|
|
||||||
|
|
||||||
// Scenario 1: Tool is auto-approved
|
|
||||||
session.auto_approve_tool(tool_name);
|
|
||||||
|
|
||||||
// UnlessAutoApproved → doesn't require approval if auto-approved
|
|
||||||
let unless_req = ApprovalRequirement::UnlessAutoApproved;
|
|
||||||
let unless_needs = match unless_req {
|
|
||||||
ApprovalRequirement::Never => false,
|
|
||||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
|
|
||||||
ApprovalRequirement::Always => true,
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
!unless_needs,
|
|
||||||
"UnlessAutoApproved should not need approval when auto-approved"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Always → always requires approval
|
|
||||||
let always_req = ApprovalRequirement::Always;
|
|
||||||
let always_needs = match always_req {
|
|
||||||
ApprovalRequirement::Never => false,
|
|
||||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(tool_name),
|
|
||||||
ApprovalRequirement::Always => true,
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
always_needs,
|
|
||||||
"Always must always require approval, even when auto-approved"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Scenario 2: Tool is NOT auto-approved
|
|
||||||
let new_tool = "new_tool";
|
|
||||||
assert!(!session.is_tool_auto_approved(new_tool));
|
|
||||||
|
|
||||||
// UnlessAutoApproved → requires approval
|
|
||||||
let unless_needs = match unless_req {
|
|
||||||
ApprovalRequirement::Never => false,
|
|
||||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(new_tool),
|
|
||||||
ApprovalRequirement::Always => true,
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
unless_needs,
|
|
||||||
"UnlessAutoApproved should need approval when not auto-approved"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Always → always requires approval
|
|
||||||
let always_needs = match always_req {
|
|
||||||
ApprovalRequirement::Never => false,
|
|
||||||
ApprovalRequirement::UnlessAutoApproved => !session.is_tool_auto_approved(new_tool),
|
|
||||||
ApprovalRequirement::Always => true,
|
|
||||||
};
|
|
||||||
assert!(always_needs, "Always must always require approval");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
|
fn test_pending_approval_serialization_backcompat_without_deferred_calls() {
|
||||||
// PendingApproval from before the deferred_tool_calls field was added
|
// PendingApproval from before the deferred_tool_calls field was added
|
||||||
@@ -2044,7 +1953,6 @@ mod tests {
|
|||||||
max_tool_iterations,
|
max_tool_iterations,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job: 0,
|
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
@@ -2161,7 +2069,6 @@ mod tests {
|
|||||||
max_tool_iterations: max_iter,
|
max_tool_iterations: max_iter,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job: 0,
|
|
||||||
},
|
},
|
||||||
deps,
|
deps,
|
||||||
Arc::new(ChannelManager::new()),
|
Arc::new(ChannelManager::new()),
|
||||||
|
|||||||
+14
-459
@@ -25,14 +25,10 @@ use crate::agent::routine::{
|
|||||||
};
|
};
|
||||||
use crate::channels::{IncomingMessage, OutgoingResponse};
|
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||||
use crate::config::RoutineConfig;
|
use crate::config::RoutineConfig;
|
||||||
use crate::context::JobContext;
|
|
||||||
use crate::db::Database;
|
use crate::db::Database;
|
||||||
use crate::error::RoutineError;
|
use crate::error::RoutineError;
|
||||||
use crate::llm::{
|
use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider};
|
||||||
ChatMessage, CompletionRequest, FinishReason, LlmProvider, ToolCall, ToolCompletionRequest,
|
use crate::tools::ApprovalContext;
|
||||||
};
|
|
||||||
use crate::safety::SafetyLayer;
|
|
||||||
use crate::tools::{ApprovalContext, ApprovalRequirement, ToolError, ToolRegistry, redact_params};
|
|
||||||
use crate::workspace::Workspace;
|
use crate::workspace::Workspace;
|
||||||
|
|
||||||
/// The routine execution engine.
|
/// The routine execution engine.
|
||||||
@@ -49,14 +45,9 @@ pub struct RoutineEngine {
|
|||||||
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
|
event_cache: Arc<RwLock<Vec<(Uuid, Routine, Regex)>>>,
|
||||||
/// Scheduler for dispatching jobs (FullJob mode).
|
/// Scheduler for dispatching jobs (FullJob mode).
|
||||||
scheduler: Option<Arc<Scheduler>>,
|
scheduler: Option<Arc<Scheduler>>,
|
||||||
/// Tool registry for lightweight routine tool execution.
|
|
||||||
tools: Arc<ToolRegistry>,
|
|
||||||
/// Safety layer for tool output sanitization.
|
|
||||||
safety: Arc<SafetyLayer>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoutineEngine {
|
impl RoutineEngine {
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: RoutineConfig,
|
config: RoutineConfig,
|
||||||
store: Arc<dyn Database>,
|
store: Arc<dyn Database>,
|
||||||
@@ -64,8 +55,6 @@ impl RoutineEngine {
|
|||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||||
scheduler: Option<Arc<Scheduler>>,
|
scheduler: Option<Arc<Scheduler>>,
|
||||||
tools: Arc<ToolRegistry>,
|
|
||||||
safety: Arc<SafetyLayer>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
@@ -76,8 +65,6 @@ impl RoutineEngine {
|
|||||||
running_count: Arc::new(AtomicUsize::new(0)),
|
running_count: Arc::new(AtomicUsize::new(0)),
|
||||||
event_cache: Arc::new(RwLock::new(Vec::new())),
|
event_cache: Arc::new(RwLock::new(Vec::new())),
|
||||||
scheduler,
|
scheduler,
|
||||||
tools,
|
|
||||||
safety,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,15 +240,12 @@ impl RoutineEngine {
|
|||||||
|
|
||||||
// Execute inline for manual triggers (caller wants to wait)
|
// Execute inline for manual triggers (caller wants to wait)
|
||||||
let engine = EngineContext {
|
let engine = EngineContext {
|
||||||
config: self.config.clone(),
|
|
||||||
store: self.store.clone(),
|
store: self.store.clone(),
|
||||||
llm: self.llm.clone(),
|
llm: self.llm.clone(),
|
||||||
workspace: self.workspace.clone(),
|
workspace: self.workspace.clone(),
|
||||||
notify_tx: self.notify_tx.clone(),
|
notify_tx: self.notify_tx.clone(),
|
||||||
running_count: self.running_count.clone(),
|
running_count: self.running_count.clone(),
|
||||||
scheduler: self.scheduler.clone(),
|
scheduler: self.scheduler.clone(),
|
||||||
tools: self.tools.clone(),
|
|
||||||
safety: self.safety.clone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -288,15 +272,12 @@ impl RoutineEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let engine = EngineContext {
|
let engine = EngineContext {
|
||||||
config: self.config.clone(),
|
|
||||||
store: self.store.clone(),
|
store: self.store.clone(),
|
||||||
llm: self.llm.clone(),
|
llm: self.llm.clone(),
|
||||||
workspace: self.workspace.clone(),
|
workspace: self.workspace.clone(),
|
||||||
notify_tx: self.notify_tx.clone(),
|
notify_tx: self.notify_tx.clone(),
|
||||||
running_count: self.running_count.clone(),
|
running_count: self.running_count.clone(),
|
||||||
scheduler: self.scheduler.clone(),
|
scheduler: self.scheduler.clone(),
|
||||||
tools: self.tools.clone(),
|
|
||||||
safety: self.safety.clone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Record the run in DB, then spawn execution
|
// Record the run in DB, then spawn execution
|
||||||
@@ -338,15 +319,12 @@ impl RoutineEngine {
|
|||||||
|
|
||||||
/// Shared context passed to the execution function.
|
/// Shared context passed to the execution function.
|
||||||
struct EngineContext {
|
struct EngineContext {
|
||||||
config: RoutineConfig,
|
|
||||||
store: Arc<dyn Database>,
|
store: Arc<dyn Database>,
|
||||||
llm: Arc<dyn LlmProvider>,
|
llm: Arc<dyn LlmProvider>,
|
||||||
workspace: Arc<Workspace>,
|
workspace: Arc<Workspace>,
|
||||||
notify_tx: mpsc::Sender<OutgoingResponse>,
|
notify_tx: mpsc::Sender<OutgoingResponse>,
|
||||||
running_count: Arc<AtomicUsize>,
|
running_count: Arc<AtomicUsize>,
|
||||||
scheduler: Option<Arc<Scheduler>>,
|
scheduler: Option<Arc<Scheduler>>,
|
||||||
tools: Arc<ToolRegistry>,
|
|
||||||
safety: Arc<SafetyLayer>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a routine run. Handles both lightweight and full_job modes.
|
/// Execute a routine run. Handles both lightweight and full_job modes.
|
||||||
@@ -560,10 +538,7 @@ async fn execute_full_job(
|
|||||||
Ok((RunStatus::Ok, Some(summary), None))
|
Ok((RunStatus::Ok, Some(summary), None))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a lightweight routine with optional tool support.
|
/// Execute a lightweight routine (single LLM call).
|
||||||
///
|
|
||||||
/// If tools are enabled, this runs a simplified agentic loop (max 3-5 iterations).
|
|
||||||
/// If tools are disabled, this does a single LLM call (original behavior).
|
|
||||||
async fn execute_lightweight(
|
async fn execute_lightweight(
|
||||||
ctx: &EngineContext,
|
ctx: &EngineContext,
|
||||||
routine: &Routine,
|
routine: &Routine,
|
||||||
@@ -595,7 +570,7 @@ async fn execute_lightweight(
|
|||||||
Err(_) => None,
|
Err(_) => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the user-facing prompt
|
// Build the prompt
|
||||||
let mut full_prompt = String::new();
|
let mut full_prompt = String::new();
|
||||||
full_prompt.push_str(prompt);
|
full_prompt.push_str(prompt);
|
||||||
|
|
||||||
@@ -623,6 +598,15 @@ async fn execute_lightweight(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let messages = if system_prompt.is_empty() {
|
||||||
|
vec![ChatMessage::user(&full_prompt)]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
ChatMessage::system(&system_prompt),
|
||||||
|
ChatMessage::user(&full_prompt),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
// Determine max_tokens from model metadata with fallback
|
// Determine max_tokens from model metadata with fallback
|
||||||
let effective_max_tokens = match ctx.llm.model_metadata().await {
|
let effective_max_tokens = match ctx.llm.model_metadata().await {
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
@@ -632,45 +616,6 @@ async fn execute_lightweight(
|
|||||||
Err(_) => max_tokens,
|
Err(_) => max_tokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
// If tools are enabled, use the tool execution loop; otherwise, single LLM call
|
|
||||||
if ctx.config.lightweight_tools_enabled {
|
|
||||||
execute_lightweight_with_tools(
|
|
||||||
ctx,
|
|
||||||
routine,
|
|
||||||
&system_prompt,
|
|
||||||
&full_prompt,
|
|
||||||
effective_max_tokens,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
} else {
|
|
||||||
execute_lightweight_no_tools(
|
|
||||||
ctx,
|
|
||||||
routine,
|
|
||||||
&system_prompt,
|
|
||||||
&full_prompt,
|
|
||||||
effective_max_tokens,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a lightweight routine without tool support (original single-call behavior).
|
|
||||||
async fn execute_lightweight_no_tools(
|
|
||||||
ctx: &EngineContext,
|
|
||||||
_routine: &Routine,
|
|
||||||
system_prompt: &str,
|
|
||||||
full_prompt: &str,
|
|
||||||
effective_max_tokens: u32,
|
|
||||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
|
||||||
let messages = if system_prompt.is_empty() {
|
|
||||||
vec![ChatMessage::user(full_prompt)]
|
|
||||||
} else {
|
|
||||||
vec![
|
|
||||||
ChatMessage::system(system_prompt),
|
|
||||||
ChatMessage::user(full_prompt),
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
let request = CompletionRequest::new(messages)
|
let request = CompletionRequest::new(messages)
|
||||||
.with_max_tokens(effective_max_tokens)
|
.with_max_tokens(effective_max_tokens)
|
||||||
.with_temperature(0.3);
|
.with_temperature(0.3);
|
||||||
@@ -686,7 +631,7 @@ async fn execute_lightweight_no_tools(
|
|||||||
let content = response.content.trim();
|
let content = response.content.trim();
|
||||||
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
|
let tokens_used = Some((response.input_tokens + response.output_tokens) as i32);
|
||||||
|
|
||||||
// Empty content guard
|
// Empty content guard (same as heartbeat)
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
return if response.finish_reason == FinishReason::Length {
|
return if response.finish_reason == FinishReason::Length {
|
||||||
Err(RoutineError::TruncatedResponse)
|
Err(RoutineError::TruncatedResponse)
|
||||||
@@ -703,269 +648,6 @@ async fn execute_lightweight_no_tools(
|
|||||||
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
|
Ok((RunStatus::Attention, Some(content.to_string()), tokens_used))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a text-only LLM response in lightweight routine execution.
|
|
||||||
///
|
|
||||||
/// Checks for the ROUTINE_OK sentinel, validates content, and returns appropriate status.
|
|
||||||
fn handle_text_response(
|
|
||||||
content: &str,
|
|
||||||
finish_reason: FinishReason,
|
|
||||||
total_input_tokens: u32,
|
|
||||||
total_output_tokens: u32,
|
|
||||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
|
||||||
let content = content.trim();
|
|
||||||
|
|
||||||
// Empty content guard
|
|
||||||
if content.is_empty() {
|
|
||||||
return if finish_reason == FinishReason::Length {
|
|
||||||
Err(RoutineError::TruncatedResponse)
|
|
||||||
} else {
|
|
||||||
Err(RoutineError::EmptyResponse)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for the "nothing to do" sentinel
|
|
||||||
if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") {
|
|
||||||
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
|
|
||||||
return Ok((RunStatus::Ok, None, total_tokens));
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_tokens = Some((total_input_tokens + total_output_tokens) as i32);
|
|
||||||
Ok((
|
|
||||||
RunStatus::Attention,
|
|
||||||
Some(content.to_string()),
|
|
||||||
total_tokens,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a lightweight routine with tool execution support (agentic loop).
|
|
||||||
///
|
|
||||||
/// This is a simplified version of the full dispatcher loop:
|
|
||||||
/// - Max 3-5 iterations (configurable)
|
|
||||||
/// - Sequential tool execution (not parallel)
|
|
||||||
/// - Auto-approval of non-Always tools
|
|
||||||
/// - No hooks or approval dialogs
|
|
||||||
async fn execute_lightweight_with_tools(
|
|
||||||
ctx: &EngineContext,
|
|
||||||
routine: &Routine,
|
|
||||||
system_prompt: &str,
|
|
||||||
full_prompt: &str,
|
|
||||||
effective_max_tokens: u32,
|
|
||||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
|
||||||
let mut messages = if system_prompt.is_empty() {
|
|
||||||
vec![ChatMessage::user(full_prompt)]
|
|
||||||
} else {
|
|
||||||
vec![
|
|
||||||
ChatMessage::system(system_prompt),
|
|
||||||
ChatMessage::user(full_prompt),
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
let max_iterations = ctx.config.lightweight_max_iterations.min(5);
|
|
||||||
let mut iteration = 0;
|
|
||||||
let mut total_input_tokens = 0;
|
|
||||||
let mut total_output_tokens = 0;
|
|
||||||
|
|
||||||
// Create a minimal job context for tool execution with unique run ID
|
|
||||||
let run_id = Uuid::new_v4();
|
|
||||||
let job_ctx = JobContext {
|
|
||||||
job_id: run_id,
|
|
||||||
user_id: routine.user_id.clone(),
|
|
||||||
title: "Lightweight Routine".to_string(),
|
|
||||||
description: routine.name.clone(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
loop {
|
|
||||||
iteration += 1;
|
|
||||||
|
|
||||||
// Force text-only response at iteration limit
|
|
||||||
let force_text = iteration >= max_iterations;
|
|
||||||
|
|
||||||
if force_text {
|
|
||||||
// Final iteration: no tools, just get text response
|
|
||||||
let request = CompletionRequest::new(messages)
|
|
||||||
.with_max_tokens(effective_max_tokens)
|
|
||||||
.with_temperature(0.3);
|
|
||||||
|
|
||||||
let response =
|
|
||||||
ctx.llm
|
|
||||||
.complete(request)
|
|
||||||
.await
|
|
||||||
.map_err(|e| RoutineError::LlmFailed {
|
|
||||||
reason: e.to_string(),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
total_input_tokens += response.input_tokens;
|
|
||||||
total_output_tokens += response.output_tokens;
|
|
||||||
|
|
||||||
return handle_text_response(
|
|
||||||
&response.content,
|
|
||||||
response.finish_reason,
|
|
||||||
total_input_tokens,
|
|
||||||
total_output_tokens,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Tool-enabled iteration
|
|
||||||
let tool_defs = ctx.tools.tool_definitions().await;
|
|
||||||
|
|
||||||
let request = ToolCompletionRequest::new(messages.clone(), tool_defs)
|
|
||||||
.with_max_tokens(effective_max_tokens)
|
|
||||||
.with_temperature(0.3);
|
|
||||||
|
|
||||||
let response = ctx.llm.complete_with_tools(request).await.map_err(|e| {
|
|
||||||
RoutineError::LlmFailed {
|
|
||||||
reason: e.to_string(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
total_input_tokens += response.input_tokens;
|
|
||||||
total_output_tokens += response.output_tokens;
|
|
||||||
|
|
||||||
// Check if LLM returned text (no tool calls)
|
|
||||||
if response.tool_calls.is_empty() {
|
|
||||||
let content = response.content.unwrap_or_default();
|
|
||||||
return handle_text_response(
|
|
||||||
&content,
|
|
||||||
response.finish_reason,
|
|
||||||
total_input_tokens,
|
|
||||||
total_output_tokens,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// LLM returned tool calls: add assistant message and execute tools
|
|
||||||
messages.push(ChatMessage::assistant_with_tool_calls(
|
|
||||||
response.content.clone(),
|
|
||||||
response.tool_calls.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Execute tools sequentially
|
|
||||||
for tc in response.tool_calls {
|
|
||||||
let result = execute_routine_tool(ctx, &job_ctx, &tc).await;
|
|
||||||
|
|
||||||
// Sanitize and wrap result (including errors)
|
|
||||||
let result_content = match result {
|
|
||||||
Ok(output) => {
|
|
||||||
let sanitized = ctx.safety.sanitize_tool_output(&tc.name, &output);
|
|
||||||
ctx.safety.wrap_for_llm(
|
|
||||||
&tc.name,
|
|
||||||
&sanitized.content,
|
|
||||||
sanitized.was_modified,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let error_msg = format!("Tool '{}' failed: {}", tc.name, e);
|
|
||||||
let sanitized = ctx.safety.sanitize_tool_output(&tc.name, &error_msg);
|
|
||||||
ctx.safety.wrap_for_llm(
|
|
||||||
&tc.name,
|
|
||||||
&sanitized.content,
|
|
||||||
sanitized.was_modified,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add tool result to context
|
|
||||||
messages.push(ChatMessage::tool_result(&tc.id, &tc.name, &result_content));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue loop to next LLM call
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Execute a single tool for a lightweight routine.
|
|
||||||
async fn execute_routine_tool(
|
|
||||||
ctx: &EngineContext,
|
|
||||||
job_ctx: &JobContext,
|
|
||||||
tc: &ToolCall,
|
|
||||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
// Check if tool exists
|
|
||||||
let tool = ctx
|
|
||||||
.tools
|
|
||||||
.get(&tc.name)
|
|
||||||
.await
|
|
||||||
.ok_or_else(|| format!("Tool '{}' not found", tc.name))?;
|
|
||||||
|
|
||||||
// Check approval requirement: only allow Never tools in lightweight routines.
|
|
||||||
// UnlessAutoApproved and Always tools are blocked to prevent prompt injection attacks.
|
|
||||||
// Lightweight routines can be triggered by external events and may process untrusted data,
|
|
||||||
// making them vulnerable to prompt injection that could trick the LLM into calling
|
|
||||||
// sensitive tools. Blocking these tools entirely is the safest approach.
|
|
||||||
match tool.requires_approval(&tc.arguments) {
|
|
||||||
ApprovalRequirement::Never => {}
|
|
||||||
ApprovalRequirement::UnlessAutoApproved | ApprovalRequirement::Always => {
|
|
||||||
return Err(format!(
|
|
||||||
"Tool '{}' requires manual approval and cannot be used in lightweight routines",
|
|
||||||
tc.name
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate tool parameters
|
|
||||||
let validation = ctx.safety.validator().validate_tool_params(&tc.arguments);
|
|
||||||
if !validation.is_valid {
|
|
||||||
let details = validation
|
|
||||||
.errors
|
|
||||||
.iter()
|
|
||||||
.map(|e| format!("{}: {}", e.field, e.message))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("; ");
|
|
||||||
return Err(format!("Invalid tool parameters: {}", details).into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let safe_params = redact_params(&tc.arguments, tool.sensitive_params());
|
|
||||||
tracing::debug!(
|
|
||||||
tool = %tc.name,
|
|
||||||
params = %safe_params,
|
|
||||||
"Lightweight routine tool call started"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute with per-tool timeout
|
|
||||||
let timeout = tool.execution_timeout();
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
let result = tokio::time::timeout(timeout, async {
|
|
||||||
tool.execute(tc.arguments.clone(), job_ctx).await
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let elapsed = start.elapsed();
|
|
||||||
|
|
||||||
match &result {
|
|
||||||
Ok(Ok(_)) => {
|
|
||||||
tracing::debug!(
|
|
||||||
tool = %tc.name,
|
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
|
||||||
"Lightweight routine tool call succeeded"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
tracing::debug!(
|
|
||||||
tool = %tc.name,
|
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
|
||||||
error = %e,
|
|
||||||
"Lightweight routine tool call failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
tracing::debug!(
|
|
||||||
tool = %tc.name,
|
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
|
||||||
timeout_secs = timeout.as_secs(),
|
|
||||||
"Lightweight routine tool call timed out"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = result
|
|
||||||
.map_err(|_| ToolError::Timeout(timeout))
|
|
||||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?
|
|
||||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
|
|
||||||
|
|
||||||
// Serialize result to JSON string
|
|
||||||
let result_str =
|
|
||||||
serde_json::to_string(&result.result).unwrap_or_else(|_| "<serialize error>".to_string());
|
|
||||||
Ok(result_str)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a notification based on the routine's notify config and run status.
|
/// Send a notification based on the routine's notify config and run status.
|
||||||
async fn send_notification(
|
async fn send_notification(
|
||||||
tx: &mpsc::Sender<OutgoingResponse>,
|
tx: &mpsc::Sender<OutgoingResponse>,
|
||||||
@@ -1045,7 +727,6 @@ fn truncate(s: &str, max: usize) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::agent::routine::{NotifyConfig, RunStatus};
|
use crate::agent::routine::{NotifyConfig, RunStatus};
|
||||||
use crate::config::RoutineConfig;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_notification_gating() {
|
fn test_notification_gating() {
|
||||||
@@ -1074,130 +755,4 @@ mod tests {
|
|||||||
let _ = status.to_string();
|
let _ = status.to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_routine_config_lightweight_tools_enabled_default() {
|
|
||||||
let config = RoutineConfig::default();
|
|
||||||
assert!(
|
|
||||||
config.lightweight_tools_enabled,
|
|
||||||
"Tools should be enabled by default"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_routine_config_lightweight_max_iterations_default() {
|
|
||||||
let config = RoutineConfig::default();
|
|
||||||
assert_eq!(
|
|
||||||
config.lightweight_max_iterations, 3,
|
|
||||||
"Default should be 3 iterations"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_routine_config_can_hold_uncapped_max_iterations() {
|
|
||||||
// The `RoutineConfig` struct can hold a value greater than the safety cap.
|
|
||||||
let config = RoutineConfig {
|
|
||||||
lightweight_max_iterations: 10, // Set a value higher than the cap.
|
|
||||||
..RoutineConfig::default()
|
|
||||||
};
|
|
||||||
// The actual capping to a maximum of 5 is handled at runtime in
|
|
||||||
// `execute_lightweight_with_tools` and during config resolution from env vars.
|
|
||||||
assert_eq!(
|
|
||||||
config.lightweight_max_iterations, 10,
|
|
||||||
"Config struct should store the provided value"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sanitize_routine_name_replaces_special_chars() {
|
|
||||||
let test_cases = vec![
|
|
||||||
("valid-routine", "valid-routine"),
|
|
||||||
("routine_with_underscore", "routine_with_underscore"),
|
|
||||||
("Routine With Spaces", "Routine_With_Spaces"),
|
|
||||||
("routine/with/slashes", "routine_with_slashes"),
|
|
||||||
("routine@with#symbols", "routine_with_symbols"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (input, expected) in test_cases {
|
|
||||||
let result = super::sanitize_routine_name(input);
|
|
||||||
assert_eq!(
|
|
||||||
result, expected,
|
|
||||||
"sanitize_routine_name({}) should be {}",
|
|
||||||
input, expected
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sanitize_routine_name_preserves_alphanumeric_dash_underscore() {
|
|
||||||
let names = vec!["routine123", "routine-name", "routine_name", "ROUTINE"];
|
|
||||||
for name in names {
|
|
||||||
let result = super::sanitize_routine_name(name);
|
|
||||||
assert_eq!(result, name, "Should preserve {}", name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_routine_sentinel_detection_exact_match() {
|
|
||||||
// The execute_lightweight_no_tools checks: content == "ROUTINE_OK" || content.contains("ROUTINE_OK")
|
|
||||||
// After trim(), whitespace is removed
|
|
||||||
let test_cases = vec![
|
|
||||||
("ROUTINE_OK", true),
|
|
||||||
(" ROUTINE_OK ", true), // After trim, whitespace is removed so matches
|
|
||||||
("something ROUTINE_OK something", true),
|
|
||||||
("ROUTINE_OK is done", true),
|
|
||||||
("done ROUTINE_OK", true),
|
|
||||||
("no sentinel here", false),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (content, should_match) in test_cases {
|
|
||||||
let trimmed = content.trim();
|
|
||||||
let matches = trimmed == "ROUTINE_OK" || trimmed.contains("ROUTINE_OK");
|
|
||||||
assert_eq!(
|
|
||||||
matches, should_match,
|
|
||||||
"Content '{}' sentinel detection should be {}, got {}",
|
|
||||||
content, should_match, matches
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_approval_requirement_pattern_matching() {
|
|
||||||
// Test the approval requirement logic (Never, UnlessAutoApproved, Always)
|
|
||||||
use crate::tools::ApprovalRequirement;
|
|
||||||
|
|
||||||
let requirements = vec![
|
|
||||||
(ApprovalRequirement::Never, "auto-approved"),
|
|
||||||
(ApprovalRequirement::UnlessAutoApproved, "auto-approved"),
|
|
||||||
(ApprovalRequirement::Always, "blocks"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (req, expected) in requirements {
|
|
||||||
let can_auto_approve = matches!(
|
|
||||||
req,
|
|
||||||
ApprovalRequirement::Never | ApprovalRequirement::UnlessAutoApproved
|
|
||||||
);
|
|
||||||
let label = if can_auto_approve {
|
|
||||||
"auto-approved"
|
|
||||||
} else {
|
|
||||||
"blocks"
|
|
||||||
};
|
|
||||||
assert_eq!(label, expected, "Approval pattern should match");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_response_handling() {
|
|
||||||
// Simulate the empty content guard logic
|
|
||||||
let empty_content = "";
|
|
||||||
let finish_reason_length = crate::llm::FinishReason::Length;
|
|
||||||
let finish_reason_stop = crate::llm::FinishReason::Stop;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
empty_content.trim().is_empty(),
|
|
||||||
"Should detect empty content"
|
|
||||||
);
|
|
||||||
assert_eq!(finish_reason_length, crate::llm::FinishReason::Length);
|
|
||||||
assert_eq!(finish_reason_stop, crate::llm::FinishReason::Stop);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,13 +160,6 @@ impl Scheduler {
|
|||||||
.create_job_for_user(user_id, title, description)
|
.create_job_for_user(user_id, title, description)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Apply token budget from config, allowing per-job metadata override.
|
|
||||||
let max_tokens = metadata
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| m.get("max_tokens"))
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.unwrap_or(self.config.max_tokens_per_job);
|
|
||||||
|
|
||||||
// Apply metadata if provided
|
// Apply metadata if provided
|
||||||
if let Some(meta) = metadata {
|
if let Some(meta) = metadata {
|
||||||
self.context_manager
|
self.context_manager
|
||||||
@@ -176,15 +169,6 @@ impl Scheduler {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set token budget (separate update to avoid overwriting metadata)
|
|
||||||
if max_tokens > 0 {
|
|
||||||
self.context_manager
|
|
||||||
.update_context(job_id, |ctx| {
|
|
||||||
ctx.max_tokens = max_tokens;
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist to DB before scheduling so the worker's FK references are valid
|
// Persist to DB before scheduling so the worker's FK references are valid
|
||||||
if let Some(ref store) = self.store {
|
if let Some(ref store) = self.store {
|
||||||
let ctx = self.context_manager.get_context(job_id).await?;
|
let ctx = self.context_manager.get_context(job_id).await?;
|
||||||
|
|||||||
+3
-100
@@ -417,8 +417,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
|
|
||||||
iteration += 1;
|
iteration += 1;
|
||||||
if iteration > max_iterations {
|
if iteration > max_iterations {
|
||||||
self.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
|
self.mark_stuck("Maximum iterations exceeded").await?;
|
||||||
.await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,8 +437,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
"LLM rate limited during tool selection, backing off"
|
"LLM rate limited during tool selection, backing off"
|
||||||
);
|
);
|
||||||
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
|
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
|
||||||
self.mark_failed("Persistent rate limiting: exceeded retry limit")
|
self.mark_stuck("Persistent rate limiting").await?;
|
||||||
.await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.log_event(
|
self.log_event(
|
||||||
@@ -469,8 +467,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
"LLM rate limited during respond_with_tools, backing off"
|
"LLM rate limited during respond_with_tools, backing off"
|
||||||
);
|
);
|
||||||
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
|
if consecutive_rate_limits >= MAX_CONSECUTIVE_RATE_LIMITS {
|
||||||
self.mark_failed("Persistent rate limiting: exceeded retry limit")
|
self.mark_stuck("Persistent rate limiting").await?;
|
||||||
.await?;
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.log_event(
|
self.log_event(
|
||||||
@@ -486,20 +483,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
|
|||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track token usage from LLM call against the job budget.
|
|
||||||
// NOTE: select_tools() also makes LLM calls but doesn't expose
|
|
||||||
// TokenUsage; only respond_with_tools() usage is tracked here.
|
|
||||||
let total_tokens = respond_output.usage.total() as u64;
|
|
||||||
if total_tokens > 0
|
|
||||||
&& let Err(msg) = self
|
|
||||||
.context_manager()
|
|
||||||
.update_context(self.job_id, |ctx| ctx.add_tokens(total_tokens))
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
self.mark_failed(&msg).await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
match respond_output.result {
|
match respond_output.result {
|
||||||
RespondResult::Text(response) => {
|
RespondResult::Text(response) => {
|
||||||
// Check for explicit completion phrases. Use word-boundary
|
// Check for explicit completion phrases. Use word-boundary
|
||||||
@@ -1779,84 +1762,4 @@ mod tests {
|
|||||||
"Always tool should be allowed with permission"
|
"Always tool should be allowed with permission"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_token_budget_exceeded_fails_job() {
|
|
||||||
let worker = make_worker(vec![]).await;
|
|
||||||
|
|
||||||
// Transition to InProgress (required for mark_failed)
|
|
||||||
worker
|
|
||||||
.context_manager()
|
|
||||||
.update_context(worker.job_id, |ctx| {
|
|
||||||
ctx.transition_to(JobState::InProgress, None)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Set a token budget
|
|
||||||
worker
|
|
||||||
.context_manager()
|
|
||||||
.update_context(worker.job_id, |ctx| {
|
|
||||||
ctx.max_tokens = 100;
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Simulate adding tokens that exceed the budget
|
|
||||||
let budget_result = worker
|
|
||||||
.context_manager()
|
|
||||||
.update_context(worker.job_id, |ctx| ctx.add_tokens(200))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
budget_result.is_err(),
|
|
||||||
"Should return error when token budget exceeded"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify that mark_failed transitions job to Failed
|
|
||||||
worker
|
|
||||||
.mark_failed(&budget_result.unwrap_err())
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let ctx = worker
|
|
||||||
.context_manager()
|
|
||||||
.get_context(worker.job_id)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(ctx.state, JobState::Failed);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_iteration_cap_marks_failed_not_stuck() {
|
|
||||||
let worker = make_worker(vec![]).await;
|
|
||||||
|
|
||||||
// Transition to InProgress (required for mark_failed)
|
|
||||||
worker
|
|
||||||
.context_manager()
|
|
||||||
.update_context(worker.job_id, |ctx| {
|
|
||||||
ctx.transition_to(JobState::InProgress, None)
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Simulate what the execution loop does when max_iterations is exceeded
|
|
||||||
worker
|
|
||||||
.mark_failed("Maximum iterations exceeded: job hit the iteration cap")
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let ctx = worker
|
|
||||||
.context_manager()
|
|
||||||
.get_context(worker.job_id)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
ctx.state,
|
|
||||||
JobState::Failed,
|
|
||||||
"Iteration cap should transition to Failed, not Stuck"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,25 +276,11 @@ pub async fn jobs_cancel_handler(
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to agent job cancellation: stop the worker via the scheduler
|
// Fall back to agent job cancellation via DB status update.
|
||||||
// (which updates the in-memory ContextManager AND aborts the task handle),
|
|
||||||
// then persist the status to the DB as a fallback.
|
|
||||||
if let Some(ref store) = state.store
|
if let Some(ref store) = state.store
|
||||||
&& let Ok(Some(job)) = store.get_job(job_id).await
|
&& let Ok(Some(job)) = store.get_job(job_id).await
|
||||||
{
|
{
|
||||||
if job.state.is_active() {
|
if job.state.is_active() {
|
||||||
// Try to stop via scheduler (aborts the worker task + updates
|
|
||||||
// in-memory ContextManager). This is best-effort — the job may
|
|
||||||
// not be in the scheduler map if it already finished.
|
|
||||||
if let Some(ref slot) = state.scheduler
|
|
||||||
&& let Some(ref scheduler) = *slot.read().await
|
|
||||||
{
|
|
||||||
let _ = scheduler.stop(job_id).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always persist cancellation to the DB so the state is
|
|
||||||
// consistent even if the scheduler wasn't available or the
|
|
||||||
// job wasn't in its in-memory map.
|
|
||||||
store
|
store
|
||||||
.update_job_status(
|
.update_job_status(
|
||||||
job_id,
|
job_id,
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ pub async fn routines_detail_handler(
|
|||||||
status: format!("{:?}", run.status),
|
status: format!("{:?}", run.status),
|
||||||
result_summary: run.result_summary.clone(),
|
result_summary: run.result_summary.clone(),
|
||||||
tokens_used: run.tokens_used,
|
tokens_used: run.tokens_used,
|
||||||
job_id: run.job_id,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -253,7 +252,6 @@ pub async fn routines_runs_handler(
|
|||||||
status: format!("{:?}", run.status),
|
status: format!("{:?}", run.status),
|
||||||
result_summary: run.result_summary.clone(),
|
result_summary: run.result_summary.clone(),
|
||||||
tokens_used: run.tokens_used,
|
tokens_used: run.tokens_used,
|
||||||
job_id: run.job_id,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -2017,7 +2017,6 @@ async fn routines_detail_handler(
|
|||||||
status: format!("{:?}", run.status),
|
status: format!("{:?}", run.status),
|
||||||
result_summary: run.result_summary.clone(),
|
result_summary: run.result_summary.clone(),
|
||||||
tokens_used: run.tokens_used,
|
tokens_used: run.tokens_used,
|
||||||
job_id: run.job_id,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -2170,7 +2169,6 @@ async fn routines_runs_handler(
|
|||||||
status: format!("{:?}", run.status),
|
status: format!("{:?}", run.status),
|
||||||
result_summary: run.result_summary.clone(),
|
result_summary: run.result_summary.clone(),
|
||||||
tokens_used: run.tokens_used,
|
tokens_used: run.tokens_used,
|
||||||
job_id: run.job_id,
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -776,7 +776,6 @@ pub struct RoutineRunInfo {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
pub result_summary: Option<String>,
|
pub result_summary: Option<String>,
|
||||||
pub tokens_used: Option<i32>,
|
pub tokens_used: Option<i32>,
|
||||||
pub job_id: Option<Uuid>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Settings ---
|
// --- Settings ---
|
||||||
|
|||||||
+1
-1
@@ -626,7 +626,7 @@ async fn save_servers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize and return the secrets store.
|
/// Get the secrets store for MCP authentication operations.
|
||||||
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Sync>> {
|
||||||
let config = Config::from_env().await?;
|
let config = Config::from_env().await?;
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ pub struct AgentConfig {
|
|||||||
pub auto_approve_tools: bool,
|
pub auto_approve_tools: bool,
|
||||||
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
||||||
pub default_timezone: String,
|
pub default_timezone: String,
|
||||||
/// Maximum tokens per job (0 = unlimited).
|
|
||||||
pub max_tokens_per_job: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentConfig {
|
impl AgentConfig {
|
||||||
@@ -52,7 +50,6 @@ impl AgentConfig {
|
|||||||
max_tool_iterations: 10,
|
max_tool_iterations: 10,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_tokens_per_job: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,10 +105,6 @@ impl AgentConfig {
|
|||||||
}
|
}
|
||||||
tz
|
tz
|
||||||
},
|
},
|
||||||
max_tokens_per_job: parse_optional_env(
|
|
||||||
"AGENT_MAX_TOKENS_PER_JOB",
|
|
||||||
settings.agent.max_tokens_per_job,
|
|
||||||
)?,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,7 +209,6 @@ impl LlmConfig {
|
|||||||
extra_headers_env,
|
extra_headers_env,
|
||||||
api_key_required,
|
api_key_required,
|
||||||
base_url_required,
|
base_url_required,
|
||||||
unsupported_params,
|
|
||||||
) = if let Some(def) = def {
|
) = if let Some(def) = def {
|
||||||
(
|
(
|
||||||
def.id.as_str(),
|
def.id.as_str(),
|
||||||
@@ -222,7 +221,6 @@ impl LlmConfig {
|
|||||||
def.extra_headers_env.as_deref(),
|
def.extra_headers_env.as_deref(),
|
||||||
def.api_key_required,
|
def.api_key_required,
|
||||||
def.base_url_required,
|
def.base_url_required,
|
||||||
def.unsupported_params.clone(),
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Absolute fallback: treat as generic openai_completions
|
// Absolute fallback: treat as generic openai_completions
|
||||||
@@ -237,7 +235,6 @@ impl LlmConfig {
|
|||||||
Some("LLM_EXTRA_HEADERS"),
|
Some("LLM_EXTRA_HEADERS"),
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
Vec::new(),
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,7 +338,6 @@ impl LlmConfig {
|
|||||||
extra_headers,
|
extra_headers,
|
||||||
oauth_token,
|
oauth_token,
|
||||||
cache_retention,
|
cache_retention,
|
||||||
unsupported_params,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -628,12 +624,6 @@ mod tests {
|
|||||||
let provider = cfg.provider.expect("provider config should be present");
|
let provider = cfg.provider.expect("provider config should be present");
|
||||||
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
|
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
|
||||||
assert_eq!(provider.model, "kimi-k2-5");
|
assert_eq!(provider.model, "kimi-k2-5");
|
||||||
assert!(
|
|
||||||
provider
|
|
||||||
.unsupported_params
|
|
||||||
.contains(&"temperature".to_string()),
|
|
||||||
"tinfoil should propagate unsupported_params from registry"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ pub struct RoutineConfig {
|
|||||||
pub default_cooldown_secs: u64,
|
pub default_cooldown_secs: u64,
|
||||||
/// Max output tokens for lightweight routine LLM calls.
|
/// Max output tokens for lightweight routine LLM calls.
|
||||||
pub max_lightweight_tokens: u32,
|
pub max_lightweight_tokens: u32,
|
||||||
/// Enable tool execution in lightweight routines (default: true).
|
|
||||||
pub lightweight_tools_enabled: bool,
|
|
||||||
/// Max tool iterations for lightweight routines (default: 3, max: 5).
|
|
||||||
pub lightweight_max_iterations: u32,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RoutineConfig {
|
impl Default for RoutineConfig {
|
||||||
@@ -28,23 +24,18 @@ impl Default for RoutineConfig {
|
|||||||
max_concurrent_routines: 10,
|
max_concurrent_routines: 10,
|
||||||
default_cooldown_secs: 300,
|
default_cooldown_secs: 300,
|
||||||
max_lightweight_tokens: 4096,
|
max_lightweight_tokens: 4096,
|
||||||
lightweight_tools_enabled: true,
|
|
||||||
lightweight_max_iterations: 3,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoutineConfig {
|
impl RoutineConfig {
|
||||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||||
let max_iterations: u32 = parse_optional_env("ROUTINES_LIGHTWEIGHT_MAX_ITERATIONS", 3)?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
enabled: parse_bool_env("ROUTINES_ENABLED", true)?,
|
enabled: parse_bool_env("ROUTINES_ENABLED", true)?,
|
||||||
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
|
cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?,
|
||||||
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
|
max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?,
|
||||||
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
|
default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?,
|
||||||
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?,
|
max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?,
|
||||||
lightweight_tools_enabled: parse_bool_env("ROUTINES_LIGHTWEIGHT_TOOLS", true)?,
|
|
||||||
lightweight_max_iterations: max_iterations.min(5), // cap at 5
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,14 @@ impl JobStore for LibSqlBackend {
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO agent_jobs (
|
INSERT INTO agent_jobs (
|
||||||
id, conversation_id, title, description, category, status, source,
|
id, conversation_id, title, description, category, status, source,
|
||||||
user_id,
|
|
||||||
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
||||||
actual_cost, repair_attempts, created_at, started_at, completed_at
|
actual_cost, repair_attempts, created_at, started_at, completed_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
description = excluded.description,
|
description = excluded.description,
|
||||||
category = excluded.category,
|
category = excluded.category,
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
user_id = excluded.user_id,
|
|
||||||
estimated_cost = excluded.estimated_cost,
|
estimated_cost = excluded.estimated_cost,
|
||||||
estimated_time_secs = excluded.estimated_time_secs,
|
estimated_time_secs = excluded.estimated_time_secs,
|
||||||
actual_cost = excluded.actual_cost,
|
actual_cost = excluded.actual_cost,
|
||||||
@@ -53,7 +51,6 @@ impl JobStore for LibSqlBackend {
|
|||||||
opt_text(ctx.category.as_deref()),
|
opt_text(ctx.category.as_deref()),
|
||||||
status,
|
status,
|
||||||
"direct",
|
"direct",
|
||||||
ctx.user_id.as_str(),
|
|
||||||
opt_text_owned(ctx.budget.map(|d| d.to_string())),
|
opt_text_owned(ctx.budget.map(|d| d.to_string())),
|
||||||
opt_text(ctx.budget_token.as_deref()),
|
opt_text(ctx.budget_token.as_deref()),
|
||||||
opt_text_owned(ctx.bid_amount.map(|d| d.to_string())),
|
opt_text_owned(ctx.bid_amount.map(|d| d.to_string())),
|
||||||
|
|||||||
@@ -482,24 +482,6 @@ mod tests {
|
|||||||
assert_eq!(timeout, 5000);
|
assert_eq!(timeout, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test: save_job must persist user_id and get_job must return it.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_save_job_persists_user_id() {
|
|
||||||
use crate::context::JobContext;
|
|
||||||
use crate::db::JobStore;
|
|
||||||
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let db_path = dir.path().join("test_user_id.db");
|
|
||||||
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
|
|
||||||
backend.run_migrations().await.unwrap();
|
|
||||||
|
|
||||||
let ctx = JobContext::with_user("test-user-42", "Test Job", "A test job");
|
|
||||||
backend.save_job(&ctx).await.unwrap();
|
|
||||||
|
|
||||||
let loaded = backend.get_job(ctx.job_id).await.unwrap().unwrap();
|
|
||||||
assert_eq!(loaded.user_id, "test-user-42");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_concurrent_writes_succeed() {
|
async fn test_concurrent_writes_succeed() {
|
||||||
// Use a temp file so connections share state (in-memory DBs are connection-local)
|
// Use a temp file so connections share state (in-memory DBs are connection-local)
|
||||||
|
|||||||
+20
-7
@@ -77,7 +77,7 @@ pub async fn connect_from_config(
|
|||||||
Ok(Arc::new(backend))
|
Ok(Arc::new(backend))
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
_ => {
|
crate::config::DatabaseBackend::Postgres => {
|
||||||
let pg = postgres::PgBackend::new(config)
|
let pg = postgres::PgBackend::new(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
||||||
@@ -85,9 +85,16 @@ pub async fn connect_from_config(
|
|||||||
Ok(Arc::new(pg))
|
Ok(Arc::new(pg))
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "postgres"))]
|
#[cfg(not(feature = "postgres"))]
|
||||||
_ => Err(DatabaseError::Pool(
|
crate::config::DatabaseBackend::Postgres => Err(DatabaseError::Pool(
|
||||||
"No database backend available. Enable 'postgres' or 'libsql' feature.".to_string(),
|
"No postgres backend available. Rebuild with --features postgres.".to_string(),
|
||||||
)),
|
)),
|
||||||
|
// Catches LibSql in postgres-only builds (libsql arm compiled out)
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => Err(DatabaseError::Pool(format!(
|
||||||
|
"Database backend {:?} not available in this build. \
|
||||||
|
Set DATABASE_BACKEND to a compiled-in backend, or rebuild with the matching feature flag.",
|
||||||
|
config.backend
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +137,7 @@ pub async fn create_secrets_store(
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
_ => {
|
crate::config::DatabaseBackend::Postgres => {
|
||||||
let pg = postgres::PgBackend::new(config)
|
let pg = postgres::PgBackend::new(config)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
.map_err(|e| DatabaseError::Pool(e.to_string()))?;
|
||||||
@@ -142,10 +149,16 @@ pub async fn create_secrets_store(
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "postgres"))]
|
#[cfg(not(feature = "postgres"))]
|
||||||
_ => Err(DatabaseError::Pool(
|
crate::config::DatabaseBackend::Postgres => Err(DatabaseError::Pool(
|
||||||
"No database backend available for secrets. Enable 'postgres' or 'libsql' feature."
|
"No postgres backend available. Rebuild with --features postgres.".to_string(),
|
||||||
.to_string(),
|
|
||||||
)),
|
)),
|
||||||
|
// Catches LibSql in postgres-only builds (libsql arm compiled out)
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => Err(DatabaseError::Pool(format!(
|
||||||
|
"Database backend {:?} not available in this build. \
|
||||||
|
Set DATABASE_BACKEND to a compiled-in backend, or rebuild with the matching feature flag.",
|
||||||
|
config.backend
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-36
@@ -149,16 +149,14 @@ impl Store {
|
|||||||
r#"
|
r#"
|
||||||
INSERT INTO agent_jobs (
|
INSERT INTO agent_jobs (
|
||||||
id, conversation_id, title, description, category, status, source,
|
id, conversation_id, title, description, category, status, source,
|
||||||
user_id,
|
|
||||||
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs,
|
||||||
actual_cost, repair_attempts, created_at, started_at, completed_at
|
actual_cost, repair_attempts, created_at, started_at, completed_at
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
category = EXCLUDED.category,
|
category = EXCLUDED.category,
|
||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
user_id = EXCLUDED.user_id,
|
|
||||||
estimated_cost = EXCLUDED.estimated_cost,
|
estimated_cost = EXCLUDED.estimated_cost,
|
||||||
estimated_time_secs = EXCLUDED.estimated_time_secs,
|
estimated_time_secs = EXCLUDED.estimated_time_secs,
|
||||||
actual_cost = EXCLUDED.actual_cost,
|
actual_cost = EXCLUDED.actual_cost,
|
||||||
@@ -174,7 +172,6 @@ impl Store {
|
|||||||
&ctx.category,
|
&ctx.category,
|
||||||
&status,
|
&status,
|
||||||
&"direct", // source
|
&"direct", // source
|
||||||
&ctx.user_id,
|
|
||||||
&ctx.budget,
|
&ctx.budget,
|
||||||
&ctx.budget_token,
|
&ctx.budget_token,
|
||||||
&ctx.bid_amount,
|
&ctx.bid_amount,
|
||||||
@@ -2136,36 +2133,4 @@ mod tests {
|
|||||||
assert_eq!(summary.channel, ch);
|
assert_eq!(summary.channel, ch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test: save_job must persist user_id and get_job must return it.
|
|
||||||
/// Requires a running PostgreSQL instance (integration tier).
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
#[tokio::test]
|
|
||||||
#[ignore]
|
|
||||||
async fn test_save_job_persists_user_id() {
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::context::JobContext;
|
|
||||||
|
|
||||||
let _ = dotenvy::dotenv();
|
|
||||||
let config = Config::from_env().await.expect("Failed to load config");
|
|
||||||
let store = Store::new(&config.database)
|
|
||||||
.await
|
|
||||||
.expect("Failed to connect to database");
|
|
||||||
store
|
|
||||||
.run_migrations()
|
|
||||||
.await
|
|
||||||
.expect("Failed to run migrations");
|
|
||||||
|
|
||||||
let ctx = JobContext::with_user("test-user-42", "PG user_id test", "regression test");
|
|
||||||
store.save_job(&ctx).await.unwrap();
|
|
||||||
|
|
||||||
let loaded = store.get_job(ctx.job_id).await.unwrap().unwrap();
|
|
||||||
assert_eq!(loaded.user_id, "test-user-42");
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let conn = store.conn().await.unwrap();
|
|
||||||
conn.execute("DELETE FROM agent_jobs WHERE id = $1", &[&ctx.job_id])
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
//!
|
//!
|
||||||
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
|
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -37,8 +35,6 @@ pub struct AnthropicOAuthProvider {
|
|||||||
model: String,
|
model: String,
|
||||||
base_url: Option<String>,
|
base_url: Option<String>,
|
||||||
active_model: std::sync::RwLock<String>,
|
active_model: std::sync::RwLock<String>,
|
||||||
/// Parameter names that this provider does not support.
|
|
||||||
unsupported_params: HashSet<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnthropicOAuthProvider {
|
impl AnthropicOAuthProvider {
|
||||||
@@ -65,45 +61,15 @@ impl AnthropicOAuthProvider {
|
|||||||
Some(config.base_url.clone())
|
Some(config.base_url.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
let unsupported_params: HashSet<String> =
|
|
||||||
config.unsupported_params.iter().cloned().collect();
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client,
|
client,
|
||||||
token,
|
token,
|
||||||
model: config.model.clone(),
|
model: config.model.clone(),
|
||||||
base_url,
|
base_url,
|
||||||
active_model,
|
active_model,
|
||||||
unsupported_params,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip unsupported fields from a `CompletionRequest` in place.
|
|
||||||
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
|
|
||||||
if self.unsupported_params.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("temperature") {
|
|
||||||
req.temperature = None;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("max_tokens") {
|
|
||||||
req.max_tokens = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
|
|
||||||
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
|
|
||||||
if self.unsupported_params.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("temperature") {
|
|
||||||
req.temperature = None;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("max_tokens") {
|
|
||||||
req.max_tokens = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api_url(&self) -> String {
|
fn api_url(&self) -> String {
|
||||||
if let Some(ref base) = self.base_url {
|
if let Some(ref base) = self.base_url {
|
||||||
let base = base.trim_end_matches('/');
|
let base = base.trim_end_matches('/');
|
||||||
@@ -231,9 +197,8 @@ impl AnthropicOAuthProvider {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LlmProvider for AnthropicOAuthProvider {
|
impl LlmProvider for AnthropicOAuthProvider {
|
||||||
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||||
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
let model = req.model.unwrap_or_else(|| self.active_model_name());
|
||||||
self.strip_unsupported_completion_params(&mut req);
|
|
||||||
let (system, messages) = convert_messages(req.messages);
|
let (system, messages) = convert_messages(req.messages);
|
||||||
|
|
||||||
let request = AnthropicRequest {
|
let request = AnthropicRequest {
|
||||||
@@ -268,10 +233,9 @@ impl LlmProvider for AnthropicOAuthProvider {
|
|||||||
|
|
||||||
async fn complete_with_tools(
|
async fn complete_with_tools(
|
||||||
&self,
|
&self,
|
||||||
mut req: ToolCompletionRequest,
|
req: ToolCompletionRequest,
|
||||||
) -> Result<ToolCompletionResponse, LlmError> {
|
) -> Result<ToolCompletionResponse, LlmError> {
|
||||||
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
|
let model = req.model.unwrap_or_else(|| self.active_model_name());
|
||||||
self.strip_unsupported_tool_params(&mut req);
|
|
||||||
let (system, messages) = convert_messages(req.messages);
|
let (system, messages) = convert_messages(req.messages);
|
||||||
|
|
||||||
let tools: Vec<AnthropicTool> = req
|
let tools: Vec<AnthropicTool> = req
|
||||||
|
|||||||
@@ -87,10 +87,6 @@ pub struct RegistryProviderConfig {
|
|||||||
pub oauth_token: Option<SecretString>,
|
pub oauth_token: Option<SecretString>,
|
||||||
/// Prompt cache retention (Anthropic-specific).
|
/// Prompt cache retention (Anthropic-specific).
|
||||||
pub cache_retention: CacheRetention,
|
pub cache_retention: CacheRetention,
|
||||||
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
|
|
||||||
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
|
||||||
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
|
|
||||||
pub unsupported_params: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration for AWS Bedrock (native Converse API).
|
/// Configuration for AWS Bedrock (native Converse API).
|
||||||
|
|||||||
+3
-9
@@ -228,9 +228,7 @@ fn create_openai_compat_from_registry(
|
|||||||
"Using OpenAI-compatible provider"
|
"Using OpenAI-compatible provider"
|
||||||
);
|
);
|
||||||
|
|
||||||
let adapter = RigAdapter::new(model, &config.model)
|
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||||
.with_unsupported_params(config.unsupported_params.clone());
|
|
||||||
Ok(Arc::new(adapter))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_anthropic_from_registry(
|
fn create_anthropic_from_registry(
|
||||||
@@ -298,9 +296,7 @@ fn create_anthropic_from_registry(
|
|||||||
);
|
);
|
||||||
|
|
||||||
Ok(Arc::new(
|
Ok(Arc::new(
|
||||||
RigAdapter::new(model, &config.model)
|
RigAdapter::new(model, &config.model).with_cache_retention(cache_retention),
|
||||||
.with_cache_retention(cache_retention)
|
|
||||||
.with_unsupported_params(config.unsupported_params.clone()),
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,9 +324,7 @@ fn create_ollama_from_registry(
|
|||||||
"Using Ollama provider"
|
"Using Ollama provider"
|
||||||
);
|
);
|
||||||
|
|
||||||
let adapter = RigAdapter::new(model, &config.model)
|
Ok(Arc::new(RigAdapter::new(model, &config.model)))
|
||||||
.with_unsupported_params(config.unsupported_params.clone());
|
|
||||||
Ok(Arc::new(adapter))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
|
||||||
|
|||||||
@@ -152,11 +152,6 @@ pub struct ProviderDefinition {
|
|||||||
/// Setup wizard hints.
|
/// Setup wizard hints.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub setup: Option<SetupHint>,
|
pub setup: Option<SetupHint>,
|
||||||
/// Parameter names that this provider does not support (e.g., `["temperature"]`).
|
|
||||||
/// Supported keys: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
|
||||||
/// Listed parameters are stripped from requests before sending to avoid 400 errors.
|
|
||||||
#[serde(default)]
|
|
||||||
pub unsupported_params: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registry of known LLM providers.
|
/// Registry of known LLM providers.
|
||||||
@@ -383,7 +378,6 @@ mod tests {
|
|||||||
description: "Custom tinfoil".to_string(),
|
description: "Custom tinfoil".to_string(),
|
||||||
extra_headers_env: None,
|
extra_headers_env: None,
|
||||||
setup: None,
|
setup: None,
|
||||||
unsupported_params: vec![],
|
|
||||||
});
|
});
|
||||||
let registry = ProviderRegistry::new(all);
|
let registry = ProviderRegistry::new(all);
|
||||||
let tf = registry.find("tinfoil").expect("tinfoil should exist");
|
let tf = registry.find("tinfoil").expect("tinfoil should exist");
|
||||||
@@ -523,7 +517,6 @@ mod tests {
|
|||||||
description: "No setup".to_string(),
|
description: "No setup".to_string(),
|
||||||
extra_headers_env: None,
|
extra_headers_env: None,
|
||||||
setup: None, // no setup hint
|
setup: None, // no setup hint
|
||||||
unsupported_params: vec![],
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let registry = ProviderRegistry::new(providers.clone());
|
let registry = ProviderRegistry::new(providers.clone());
|
||||||
@@ -553,7 +546,6 @@ mod tests {
|
|||||||
can_list_models: false,
|
can_list_models: false,
|
||||||
models_filter: None,
|
models_filter: None,
|
||||||
}),
|
}),
|
||||||
unsupported_params: vec![],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let registry = ProviderRegistry::new(providers);
|
let registry = ProviderRegistry::new(providers);
|
||||||
@@ -595,7 +587,6 @@ mod tests {
|
|||||||
can_list_models: false,
|
can_list_models: false,
|
||||||
models_filter: None,
|
models_filter: None,
|
||||||
}),
|
}),
|
||||||
unsupported_params: vec![],
|
|
||||||
},
|
},
|
||||||
// User override removes setup
|
// User override removes setup
|
||||||
ProviderDefinition {
|
ProviderDefinition {
|
||||||
@@ -612,7 +603,6 @@ mod tests {
|
|||||||
description: "No setup now".to_string(),
|
description: "No setup now".to_string(),
|
||||||
extra_headers_env: None,
|
extra_headers_env: None,
|
||||||
setup: None,
|
setup: None,
|
||||||
unsupported_params: vec![],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -650,7 +640,6 @@ mod tests {
|
|||||||
display_name: "A".to_string(),
|
display_name: "A".to_string(),
|
||||||
can_list_models: false,
|
can_list_models: false,
|
||||||
}),
|
}),
|
||||||
unsupported_params: vec![],
|
|
||||||
},
|
},
|
||||||
ProviderDefinition {
|
ProviderDefinition {
|
||||||
id: "bbb".to_string(),
|
id: "bbb".to_string(),
|
||||||
@@ -669,7 +658,6 @@ mod tests {
|
|||||||
display_name: "B".to_string(),
|
display_name: "B".to_string(),
|
||||||
can_list_models: false,
|
can_list_models: false,
|
||||||
}),
|
}),
|
||||||
unsupported_params: vec![],
|
|
||||||
},
|
},
|
||||||
ProviderDefinition {
|
ProviderDefinition {
|
||||||
id: "ccc".to_string(),
|
id: "ccc".to_string(),
|
||||||
@@ -688,7 +676,6 @@ mod tests {
|
|||||||
display_name: "C".to_string(),
|
display_name: "C".to_string(),
|
||||||
can_list_models: false,
|
can_list_models: false,
|
||||||
}),
|
}),
|
||||||
unsupported_params: vec![],
|
|
||||||
},
|
},
|
||||||
// User override for B
|
// User override for B
|
||||||
ProviderDefinition {
|
ProviderDefinition {
|
||||||
@@ -708,7 +695,6 @@ mod tests {
|
|||||||
display_name: "B".to_string(),
|
display_name: "B".to_string(),
|
||||||
can_list_models: false,
|
can_list_models: false,
|
||||||
}),
|
}),
|
||||||
unsupported_params: vec![],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -722,48 +708,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_unsupported_params_deserialized() {
|
|
||||||
let providers: Vec<ProviderDefinition> =
|
|
||||||
serde_json::from_str(include_str!("../../providers.json")).unwrap();
|
|
||||||
|
|
||||||
// Tinfoil should have temperature in unsupported_params
|
|
||||||
let tinfoil = providers.iter().find(|p| p.id == "tinfoil").unwrap();
|
|
||||||
assert!(
|
|
||||||
tinfoil
|
|
||||||
.unsupported_params
|
|
||||||
.contains(&"temperature".to_string()),
|
|
||||||
"tinfoil should have 'temperature' in unsupported_params"
|
|
||||||
);
|
|
||||||
|
|
||||||
// OpenAI should also have temperature in unsupported_params
|
|
||||||
let openai = providers.iter().find(|p| p.id == "openai").unwrap();
|
|
||||||
assert!(
|
|
||||||
openai
|
|
||||||
.unsupported_params
|
|
||||||
.contains(&"temperature".to_string()),
|
|
||||||
"openai should have 'temperature' in unsupported_params"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Providers without the field in JSON should deserialize to empty vec
|
|
||||||
let groq = providers.iter().find(|p| p.id == "groq").unwrap();
|
|
||||||
assert!(
|
|
||||||
groq.unsupported_params.is_empty(),
|
|
||||||
"groq should have empty unsupported_params (field absent in JSON)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Every non-empty entry should contain valid param names
|
|
||||||
for def in &providers {
|
|
||||||
for param in &def.unsupported_params {
|
|
||||||
assert!(
|
|
||||||
!param.is_empty(),
|
|
||||||
"{}: unsupported_params contains empty string",
|
|
||||||
def.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_all_builtin_api_key_providers_have_api_key_env() {
|
fn test_all_builtin_api_key_providers_have_api_key_env() {
|
||||||
// Every built-in provider with SetupHint::ApiKey must have api_key_env
|
// Every built-in provider with SetupHint::ApiKey must have api_key_env
|
||||||
|
|||||||
+2
-144
@@ -42,9 +42,6 @@ pub struct RigAdapter<M: CompletionModel> {
|
|||||||
/// via `additional_params` for Anthropic automatic caching. Also controls
|
/// via `additional_params` for Anthropic automatic caching. Also controls
|
||||||
/// the cost multiplier for cache-creation tokens.
|
/// the cost multiplier for cache-creation tokens.
|
||||||
cache_retention: CacheRetention,
|
cache_retention: CacheRetention,
|
||||||
/// Parameter names that this provider does not support (e.g., `"temperature"`).
|
|
||||||
/// These are stripped from requests before sending to avoid 400 errors.
|
|
||||||
unsupported_params: HashSet<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M: CompletionModel> RigAdapter<M> {
|
impl<M: CompletionModel> RigAdapter<M> {
|
||||||
@@ -59,7 +56,6 @@ impl<M: CompletionModel> RigAdapter<M> {
|
|||||||
input_cost,
|
input_cost,
|
||||||
output_cost,
|
output_cost,
|
||||||
cache_retention: CacheRetention::None,
|
cache_retention: CacheRetention::None,
|
||||||
unsupported_params: HashSet::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,44 +84,6 @@ impl<M: CompletionModel> RigAdapter<M> {
|
|||||||
}
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the list of unsupported parameter names for this provider.
|
|
||||||
///
|
|
||||||
/// Parameters in this set are stripped from requests before sending.
|
|
||||||
/// Supported parameter names: `"temperature"`, `"max_tokens"`, `"stop_sequences"`.
|
|
||||||
pub fn with_unsupported_params(mut self, params: Vec<String>) -> Self {
|
|
||||||
self.unsupported_params = params.into_iter().collect();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip unsupported fields from a `CompletionRequest` in place.
|
|
||||||
fn strip_unsupported_completion_params(&self, req: &mut CompletionRequest) {
|
|
||||||
if self.unsupported_params.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("temperature") {
|
|
||||||
req.temperature = None;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("max_tokens") {
|
|
||||||
req.max_tokens = None;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("stop_sequences") {
|
|
||||||
req.stop_sequences = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip unsupported fields from a `ToolCompletionRequest` in place.
|
|
||||||
fn strip_unsupported_tool_params(&self, req: &mut ToolCompletionRequest) {
|
|
||||||
if self.unsupported_params.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("temperature") {
|
|
||||||
req.temperature = None;
|
|
||||||
}
|
|
||||||
if self.unsupported_params.contains("max_tokens") {
|
|
||||||
req.max_tokens = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Type conversion helpers --
|
// -- Type conversion helpers --
|
||||||
@@ -581,10 +539,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn complete(
|
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
|
||||||
&self,
|
|
||||||
mut request: CompletionRequest,
|
|
||||||
) -> Result<CompletionResponse, LlmError> {
|
|
||||||
if let Some(requested_model) = request.model.as_deref()
|
if let Some(requested_model) = request.model.as_deref()
|
||||||
&& requested_model != self.model_name.as_str()
|
&& requested_model != self.model_name.as_str()
|
||||||
{
|
{
|
||||||
@@ -595,8 +550,6 @@ where
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.strip_unsupported_completion_params(&mut request);
|
|
||||||
|
|
||||||
let mut messages = request.messages;
|
let mut messages = request.messages;
|
||||||
crate::llm::provider::sanitize_tool_messages(&mut messages);
|
crate::llm::provider::sanitize_tool_messages(&mut messages);
|
||||||
let (preamble, history) = convert_messages(&messages);
|
let (preamble, history) = convert_messages(&messages);
|
||||||
@@ -646,7 +599,7 @@ where
|
|||||||
|
|
||||||
async fn complete_with_tools(
|
async fn complete_with_tools(
|
||||||
&self,
|
&self,
|
||||||
mut request: ToolCompletionRequest,
|
request: ToolCompletionRequest,
|
||||||
) -> Result<ToolCompletionResponse, LlmError> {
|
) -> Result<ToolCompletionResponse, LlmError> {
|
||||||
if let Some(requested_model) = request.model.as_deref()
|
if let Some(requested_model) = request.model.as_deref()
|
||||||
&& requested_model != self.model_name.as_str()
|
&& requested_model != self.model_name.as_str()
|
||||||
@@ -658,8 +611,6 @@ where
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.strip_unsupported_tool_params(&mut request);
|
|
||||||
|
|
||||||
let known_tool_names: HashSet<String> =
|
let known_tool_names: HashSet<String> =
|
||||||
request.tools.iter().map(|t| t.name.clone()).collect();
|
request.tools.iter().map(|t| t.name.clone()).collect();
|
||||||
|
|
||||||
@@ -1205,97 +1156,4 @@ mod tests {
|
|||||||
assert!(!supports_prompt_cache("gpt-4o"));
|
assert!(!supports_prompt_cache("gpt-4o"));
|
||||||
assert!(!supports_prompt_cache("llama3"));
|
assert!(!supports_prompt_cache("llama3"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_with_unsupported_params_populates_set() {
|
|
||||||
use rig::client::CompletionClient;
|
|
||||||
use rig::providers::openai;
|
|
||||||
|
|
||||||
let client: openai::Client = openai::Client::builder()
|
|
||||||
.api_key("test-key")
|
|
||||||
.base_url("http://localhost:0")
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let client = client.completions_api();
|
|
||||||
let model = client.completion_model("test-model");
|
|
||||||
let adapter = RigAdapter::new(model, "test-model")
|
|
||||||
.with_unsupported_params(vec!["temperature".to_string()]);
|
|
||||||
|
|
||||||
assert!(adapter.unsupported_params.contains("temperature"));
|
|
||||||
assert!(!adapter.unsupported_params.contains("max_tokens"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_unsupported_completion_params() {
|
|
||||||
use rig::client::CompletionClient;
|
|
||||||
use rig::providers::openai;
|
|
||||||
|
|
||||||
let client: openai::Client = openai::Client::builder()
|
|
||||||
.api_key("test-key")
|
|
||||||
.base_url("http://localhost:0")
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let client = client.completions_api();
|
|
||||||
let model = client.completion_model("test-model");
|
|
||||||
let adapter = RigAdapter::new(model, "test-model").with_unsupported_params(vec![
|
|
||||||
"temperature".to_string(),
|
|
||||||
"stop_sequences".to_string(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let mut req = CompletionRequest::new(vec![ChatMessage::user("hi")]);
|
|
||||||
req.temperature = Some(0.7);
|
|
||||||
req.max_tokens = Some(100);
|
|
||||||
req.stop_sequences = Some(vec!["STOP".to_string()]);
|
|
||||||
|
|
||||||
adapter.strip_unsupported_completion_params(&mut req);
|
|
||||||
|
|
||||||
assert!(req.temperature.is_none(), "temperature should be stripped");
|
|
||||||
assert_eq!(req.max_tokens, Some(100), "max_tokens should be preserved");
|
|
||||||
assert!(
|
|
||||||
req.stop_sequences.is_none(),
|
|
||||||
"stop_sequences should be stripped"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_strip_unsupported_tool_params() {
|
|
||||||
use rig::client::CompletionClient;
|
|
||||||
use rig::providers::openai;
|
|
||||||
|
|
||||||
let client: openai::Client = openai::Client::builder()
|
|
||||||
.api_key("test-key")
|
|
||||||
.base_url("http://localhost:0")
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let client = client.completions_api();
|
|
||||||
let model = client.completion_model("test-model");
|
|
||||||
let adapter = RigAdapter::new(model, "test-model")
|
|
||||||
.with_unsupported_params(vec!["temperature".to_string(), "max_tokens".to_string()]);
|
|
||||||
|
|
||||||
let mut req = ToolCompletionRequest::new(vec![ChatMessage::user("hi")], vec![]);
|
|
||||||
req.temperature = Some(0.5);
|
|
||||||
req.max_tokens = Some(200);
|
|
||||||
|
|
||||||
adapter.strip_unsupported_tool_params(&mut req);
|
|
||||||
|
|
||||||
assert!(req.temperature.is_none(), "temperature should be stripped");
|
|
||||||
assert!(req.max_tokens.is_none(), "max_tokens should be stripped");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_unsupported_params_empty_by_default() {
|
|
||||||
use rig::client::CompletionClient;
|
|
||||||
use rig::providers::openai;
|
|
||||||
|
|
||||||
let client: openai::Client = openai::Client::builder()
|
|
||||||
.api_key("test-key")
|
|
||||||
.base_url("http://localhost:0")
|
|
||||||
.build()
|
|
||||||
.unwrap();
|
|
||||||
let client = client.completions_api();
|
|
||||||
let model = client.completion_model("test-model");
|
|
||||||
let adapter = RigAdapter::new(model, "test-model");
|
|
||||||
|
|
||||||
assert!(adapter.unsupported_params.is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -386,10 +386,6 @@ pub struct AgentSettings {
|
|||||||
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
||||||
#[serde(default = "default_timezone")]
|
#[serde(default = "default_timezone")]
|
||||||
pub default_timezone: String,
|
pub default_timezone: String,
|
||||||
|
|
||||||
/// Maximum tokens per job (0 = unlimited).
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_tokens_per_job: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_agent_name() -> String {
|
fn default_agent_name() -> String {
|
||||||
@@ -446,7 +442,6 @@ impl Default for AgentSettings {
|
|||||||
max_tool_iterations: default_max_tool_iterations(),
|
max_tool_iterations: default_max_tool_iterations(),
|
||||||
auto_approve_tools: false,
|
auto_approve_tools: false,
|
||||||
default_timezone: default_timezone(),
|
default_timezone: default_timezone(),
|
||||||
max_tokens_per_job: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-104
@@ -1573,18 +1573,46 @@ impl SetupWizard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch available models from the NEAR AI API.
|
/// Fetch available models from the NEAR AI API.
|
||||||
///
|
|
||||||
/// Uses [`build_nearai_model_fetch_config`] to construct the provider config,
|
|
||||||
/// which reads `NEARAI_API_KEY` from the environment when present.
|
|
||||||
async fn fetch_nearai_models(&self) -> Vec<String> {
|
async fn fetch_nearai_models(&self) -> Vec<String> {
|
||||||
let session = match self.session_manager {
|
let session = match self.session_manager {
|
||||||
Some(ref s) => Arc::clone(s),
|
Some(ref s) => Arc::clone(s),
|
||||||
None => return vec![],
|
None => return vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::config::LlmConfig;
|
||||||
use crate::llm::create_llm_provider;
|
use crate::llm::create_llm_provider;
|
||||||
|
|
||||||
let config = build_nearai_model_fetch_config();
|
let base_url = std::env::var("NEARAI_BASE_URL")
|
||||||
|
.unwrap_or_else(|_| "https://private.near.ai".to_string());
|
||||||
|
let auth_base_url = std::env::var("NEARAI_AUTH_URL")
|
||||||
|
.unwrap_or_else(|_| "https://private.near.ai".to_string());
|
||||||
|
|
||||||
|
let config = LlmConfig {
|
||||||
|
backend: "nearai".to_string(),
|
||||||
|
session: crate::llm::session::SessionConfig {
|
||||||
|
auth_base_url,
|
||||||
|
session_path: crate::config::llm::default_session_path(),
|
||||||
|
},
|
||||||
|
nearai: crate::config::NearAiConfig {
|
||||||
|
model: "dummy".to_string(),
|
||||||
|
cheap_model: None,
|
||||||
|
base_url,
|
||||||
|
api_key: None,
|
||||||
|
fallback_model: None,
|
||||||
|
max_retries: 3,
|
||||||
|
circuit_breaker_threshold: None,
|
||||||
|
circuit_breaker_recovery_secs: 30,
|
||||||
|
response_cache_enabled: false,
|
||||||
|
response_cache_ttl_secs: 3600,
|
||||||
|
response_cache_max_entries: 1000,
|
||||||
|
failover_cooldown_secs: 300,
|
||||||
|
failover_cooldown_threshold: 3,
|
||||||
|
smart_routing_cascade: true,
|
||||||
|
},
|
||||||
|
provider: None,
|
||||||
|
bedrock: None,
|
||||||
|
request_timeout_secs: 120,
|
||||||
|
};
|
||||||
|
|
||||||
match create_llm_provider(&config, session).await {
|
match create_llm_provider(&config, session).await {
|
||||||
Ok(provider) => match provider.list_models().await {
|
Ok(provider) => match provider.list_models().await {
|
||||||
@@ -3212,52 +3240,6 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa
|
|||||||
/// Mask an API key for display: show first 6 + last 4 chars.
|
/// Mask an API key for display: show first 6 + last 4 chars.
|
||||||
///
|
///
|
||||||
/// Uses char-based indexing to avoid panicking on multi-byte UTF-8.
|
/// Uses char-based indexing to avoid panicking on multi-byte UTF-8.
|
||||||
/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models.
|
|
||||||
///
|
|
||||||
/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated
|
|
||||||
/// via Cloud API key (option 4) don't get re-prompted during model selection.
|
|
||||||
fn build_nearai_model_fetch_config() -> crate::config::LlmConfig {
|
|
||||||
let base_url =
|
|
||||||
std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
|
|
||||||
let auth_base_url =
|
|
||||||
std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string());
|
|
||||||
|
|
||||||
// If the user authenticated via API key (option 4), the key is stored
|
|
||||||
// as an env var. Pass it through so `resolve_bearer_token()` doesn't
|
|
||||||
// re-trigger the interactive auth prompt.
|
|
||||||
let api_key = std::env::var("NEARAI_API_KEY")
|
|
||||||
.ok()
|
|
||||||
.filter(|k| !k.is_empty())
|
|
||||||
.map(secrecy::SecretString::from);
|
|
||||||
|
|
||||||
crate::config::LlmConfig {
|
|
||||||
backend: "nearai".to_string(),
|
|
||||||
session: crate::llm::session::SessionConfig {
|
|
||||||
auth_base_url,
|
|
||||||
session_path: crate::config::llm::default_session_path(),
|
|
||||||
},
|
|
||||||
nearai: crate::config::NearAiConfig {
|
|
||||||
model: "dummy".to_string(),
|
|
||||||
cheap_model: None,
|
|
||||||
base_url,
|
|
||||||
api_key,
|
|
||||||
fallback_model: None,
|
|
||||||
max_retries: 3,
|
|
||||||
circuit_breaker_threshold: None,
|
|
||||||
circuit_breaker_recovery_secs: 30,
|
|
||||||
response_cache_enabled: false,
|
|
||||||
response_cache_ttl_secs: 3600,
|
|
||||||
response_cache_max_entries: 1000,
|
|
||||||
failover_cooldown_secs: 300,
|
|
||||||
failover_cooldown_threshold: 3,
|
|
||||||
smart_routing_cascade: true,
|
|
||||||
},
|
|
||||||
provider: None,
|
|
||||||
bedrock: None,
|
|
||||||
request_timeout_secs: 120,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask_api_key(key: &str) -> String {
|
fn mask_api_key(key: &str) -> String {
|
||||||
let chars: Vec<char> = key.chars().collect();
|
let chars: Vec<char> = key.chars().collect();
|
||||||
if chars.len() < 12 {
|
if chars.len() < 12 {
|
||||||
@@ -3658,14 +3640,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl EnvGuard {
|
impl EnvGuard {
|
||||||
fn set(key: &'static str, value: &str) -> Self {
|
|
||||||
let original = std::env::var(key).ok();
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var(key, value);
|
|
||||||
}
|
|
||||||
Self { key, original }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear(key: &'static str) -> Self {
|
fn clear(key: &'static str) -> Self {
|
||||||
let original = std::env::var(key).ok();
|
let original = std::env::var(key).ok();
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -3813,7 +3787,6 @@ mod tests {
|
|||||||
description: "Custom provider with no setup wizard".to_string(),
|
description: "Custom provider with no setup wizard".to_string(),
|
||||||
extra_headers_env: None,
|
extra_headers_env: None,
|
||||||
setup: None,
|
setup: None,
|
||||||
unsupported_params: vec![],
|
|
||||||
});
|
});
|
||||||
let registry = crate::llm::ProviderRegistry::new(providers);
|
let registry = crate::llm::ProviderRegistry::new(providers);
|
||||||
|
|
||||||
@@ -3853,49 +3826,4 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(settings.secrets_master_key_hex.is_some());
|
assert!(settings.secrets_master_key_hex.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression test for #799: `fetch_nearai_models` hardcoded `api_key: None`,
|
|
||||||
/// causing the auth prompt to re-appear during model selection when the user
|
|
||||||
/// had authenticated via NEAR AI Cloud API key (option 4).
|
|
||||||
#[test]
|
|
||||||
fn test_build_nearai_model_fetch_config_picks_up_api_key_env() {
|
|
||||||
use secrecy::ExposeSecret;
|
|
||||||
|
|
||||||
let _guard = EnvGuard::set("NEARAI_API_KEY", "test-cloud-api-key-12345");
|
|
||||||
|
|
||||||
let config = build_nearai_model_fetch_config();
|
|
||||||
assert!(
|
|
||||||
config.nearai.api_key.is_some(),
|
|
||||||
"config should include NEARAI_API_KEY from env"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
config.nearai.api_key.as_ref().unwrap().expose_secret(),
|
|
||||||
"test-cloud-api-key-12345"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression test for #799: when NEARAI_API_KEY is absent or empty,
|
|
||||||
/// the config should have `api_key: None` (session token path).
|
|
||||||
#[test]
|
|
||||||
fn test_build_nearai_model_fetch_config_none_when_no_api_key() {
|
|
||||||
let _guard = EnvGuard::clear("NEARAI_API_KEY");
|
|
||||||
|
|
||||||
let config = build_nearai_model_fetch_config();
|
|
||||||
assert!(
|
|
||||||
config.nearai.api_key.is_none(),
|
|
||||||
"config should have no api_key when env var is absent"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Regression test for #799: empty NEARAI_API_KEY should be treated as absent.
|
|
||||||
#[test]
|
|
||||||
fn test_build_nearai_model_fetch_config_none_when_empty_api_key() {
|
|
||||||
let _guard = EnvGuard::set("NEARAI_API_KEY", "");
|
|
||||||
|
|
||||||
let config = build_nearai_model_fetch_config();
|
|
||||||
assert!(
|
|
||||||
config.nearai.api_key.is_none(),
|
|
||||||
"config should have no api_key when env var is empty"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,8 +451,8 @@ impl Tool for ToolRemoveTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Permanently remove an installed extension (channel, tool, or MCP server) from disk. \
|
"Remove an installed extension (channel, tool, or MCP server). \
|
||||||
This action cannot be undone — the WASM binary and configuration files will be deleted."
|
Unregisters tools and deletes configuration."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@@ -492,7 +492,7 @@ impl Tool for ToolRemoveTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
ApprovalRequirement::Always
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,38 +701,10 @@ mod tests {
|
|||||||
assert_eq!(tool.name(), "tool_remove");
|
assert_eq!(tool.name(), "tool_remove");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(&serde_json::json!({})),
|
tool.requires_approval(&serde_json::json!({})),
|
||||||
ApprovalRequirement::Always
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tool_remove_always_requires_approval_regardless_of_params() {
|
|
||||||
use crate::tools::tool::ApprovalRequirement;
|
|
||||||
let tool = ToolRemoveTool {
|
|
||||||
manager: test_manager_stub(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let test_cases = vec![
|
|
||||||
("no params", serde_json::json!({})),
|
|
||||||
("empty name", serde_json::json!({"name": ""})),
|
|
||||||
("slack", serde_json::json!({"name": "slack"})),
|
|
||||||
("github-cli", serde_json::json!({"name": "github-cli"})),
|
|
||||||
(
|
|
||||||
"with extra fields",
|
|
||||||
serde_json::json!({"name": "tool", "extra": "field"}),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (case_name, params) in test_cases {
|
|
||||||
assert_eq!(
|
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::Always,
|
|
||||||
"tool_remove must always require approval for case: {}",
|
|
||||||
case_name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tool_upgrade_schema() {
|
fn test_tool_upgrade_schema() {
|
||||||
use crate::tools::tool::ApprovalRequirement;
|
use crate::tools::tool::ApprovalRequirement;
|
||||||
|
|||||||
@@ -709,8 +709,7 @@ impl Tool for SkillRemoveTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Permanently remove an installed skill from disk. This action cannot be undone — \
|
"Remove an installed skill by name. Only user-installed skills can be removed."
|
||||||
the skill files will be deleted."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@@ -771,7 +770,7 @@ impl Tool for SkillRemoveTool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
ApprovalRequirement::Always
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,41 +837,12 @@ mod tests {
|
|||||||
assert_eq!(tool.name(), "skill_remove");
|
assert_eq!(tool.name(), "skill_remove");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tool.requires_approval(&serde_json::json!({})),
|
tool.requires_approval(&serde_json::json!({})),
|
||||||
ApprovalRequirement::Always
|
ApprovalRequirement::UnlessAutoApproved
|
||||||
);
|
);
|
||||||
let schema = tool.parameters_schema();
|
let schema = tool.parameters_schema();
|
||||||
assert!(schema["properties"].get("name").is_some());
|
assert!(schema["properties"].get("name").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn skill_remove_always_requires_approval_regardless_of_params() {
|
|
||||||
use crate::tools::tool::ApprovalRequirement;
|
|
||||||
let tool = SkillRemoveTool::new(test_registry());
|
|
||||||
|
|
||||||
let test_cases = vec![
|
|
||||||
("no params", serde_json::json!({})),
|
|
||||||
("empty name", serde_json::json!({"name": ""})),
|
|
||||||
(
|
|
||||||
"deployment skill",
|
|
||||||
serde_json::json!({"name": "deployment"}),
|
|
||||||
),
|
|
||||||
("custom skill", serde_json::json!({"name": "custom-skill"})),
|
|
||||||
(
|
|
||||||
"with extra fields",
|
|
||||||
serde_json::json!({"name": "skill", "extra": "field"}),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (case_name, params) in test_cases {
|
|
||||||
assert_eq!(
|
|
||||||
tool.requires_approval(¶ms),
|
|
||||||
ApprovalRequirement::Always,
|
|
||||||
"skill_remove must always require approval for case: {}",
|
|
||||||
case_name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_validate_fetch_url_allows_https() {
|
fn test_validate_fetch_url_allows_https() {
|
||||||
assert!(super::validate_fetch_url("https://clawhub.ai/api/v1/download?slug=foo").is_ok());
|
assert!(super::validate_fetch_url("https://clawhub.ai/api/v1/download?slug=foo").is_ok());
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ mod tests {
|
|||||||
use ironclaw::agent::routine_engine::RoutineEngine;
|
use ironclaw::agent::routine_engine::RoutineEngine;
|
||||||
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
|
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
|
||||||
use ironclaw::channels::IncomingMessage;
|
use ironclaw::channels::IncomingMessage;
|
||||||
use ironclaw::config::{RoutineConfig, SafetyConfig};
|
use ironclaw::config::RoutineConfig;
|
||||||
use ironclaw::db::Database;
|
use ironclaw::db::Database;
|
||||||
use ironclaw::safety::SafetyLayer;
|
|
||||||
use ironclaw::tools::ToolRegistry;
|
|
||||||
use ironclaw::workspace::Workspace;
|
use ironclaw::workspace::Workspace;
|
||||||
use ironclaw::workspace::hygiene::HygieneConfig;
|
use ironclaw::workspace::hygiene::HygieneConfig;
|
||||||
|
|
||||||
@@ -105,14 +103,6 @@ mod tests {
|
|||||||
|
|
||||||
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel(16);
|
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::channel(16);
|
||||||
|
|
||||||
// Create minimal ToolRegistry and SafetyLayer for test.
|
|
||||||
let tools = Arc::new(ToolRegistry::new());
|
|
||||||
let safety_config = SafetyConfig {
|
|
||||||
max_output_length: 100_000,
|
|
||||||
injection_check_enabled: true,
|
|
||||||
};
|
|
||||||
let safety = Arc::new(SafetyLayer::new(&safety_config));
|
|
||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
db.clone(),
|
db.clone(),
|
||||||
@@ -120,8 +110,6 @@ mod tests {
|
|||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
None,
|
None,
|
||||||
tools,
|
|
||||||
safety,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Insert a cron routine with next_fire_at in the past.
|
// Insert a cron routine with next_fire_at in the past.
|
||||||
@@ -182,14 +170,6 @@ mod tests {
|
|||||||
let llm = Arc::new(TraceLlm::from_trace(trace));
|
let llm = Arc::new(TraceLlm::from_trace(trace));
|
||||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
||||||
|
|
||||||
// Create minimal ToolRegistry and SafetyLayer for test.
|
|
||||||
let tools = Arc::new(ToolRegistry::new());
|
|
||||||
let safety_config = SafetyConfig {
|
|
||||||
max_output_length: 100_000,
|
|
||||||
injection_check_enabled: true,
|
|
||||||
};
|
|
||||||
let safety = Arc::new(SafetyLayer::new(&safety_config));
|
|
||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
db.clone(),
|
db.clone(),
|
||||||
@@ -197,8 +177,6 @@ mod tests {
|
|||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
None,
|
None,
|
||||||
tools,
|
|
||||||
safety,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Insert an event routine matching "deploy.*production".
|
// Insert an event routine matching "deploy.*production".
|
||||||
@@ -280,14 +258,6 @@ mod tests {
|
|||||||
let llm = Arc::new(TraceLlm::from_trace(trace));
|
let llm = Arc::new(TraceLlm::from_trace(trace));
|
||||||
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16);
|
||||||
|
|
||||||
// Create minimal ToolRegistry and SafetyLayer for test.
|
|
||||||
let tools = Arc::new(ToolRegistry::new());
|
|
||||||
let safety_config = SafetyConfig {
|
|
||||||
max_output_length: 100_000,
|
|
||||||
injection_check_enabled: true,
|
|
||||||
};
|
|
||||||
let safety = Arc::new(SafetyLayer::new(&safety_config));
|
|
||||||
|
|
||||||
let engine = Arc::new(RoutineEngine::new(
|
let engine = Arc::new(RoutineEngine::new(
|
||||||
RoutineConfig::default(),
|
RoutineConfig::default(),
|
||||||
db.clone(),
|
db.clone(),
|
||||||
@@ -295,8 +265,6 @@ mod tests {
|
|||||||
ws,
|
ws,
|
||||||
notify_tx,
|
notify_tx,
|
||||||
None,
|
None,
|
||||||
tools,
|
|
||||||
safety,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
// Insert an event routine with 1-hour cooldown.
|
// Insert an event routine with 1-hour cooldown.
|
||||||
|
|||||||
@@ -575,8 +575,6 @@ impl TestRigBuilder {
|
|||||||
Arc::clone(ws),
|
Arc::clone(ws),
|
||||||
notify_tx,
|
notify_tx,
|
||||||
None,
|
None,
|
||||||
components.tools.clone(),
|
|
||||||
components.safety.clone(),
|
|
||||||
));
|
));
|
||||||
components
|
components
|
||||||
.tools
|
.tools
|
||||||
@@ -646,8 +644,6 @@ impl TestRigBuilder {
|
|||||||
max_concurrent_routines: 3,
|
max_concurrent_routines: 3,
|
||||||
default_cooldown_secs: 300,
|
default_cooldown_secs: 300,
|
||||||
max_lightweight_tokens: 4096,
|
max_lightweight_tokens: 4096,
|
||||||
lightweight_tools_enabled: true,
|
|
||||||
lightweight_max_iterations: 3,
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
Reference in New Issue
Block a user