Compare commits

..
Author SHA1 Message Date
Nick Pismenkov b01bfecb48 remove CLAUDE.md changes 2026-03-09 17:13:46 -07:00
Nick Pismenkov 940c182fea fix: remove accidentally committed binary 2026-03-09 17:13:04 -07:00
Nick Pismenkov 654bda9239 Merge main into fix/libsql-crash
Resolves conflict in CLAUDE.md by keeping our detailed Configuration section.

[skip-regression-check]
2026-03-09 16:40:16 -07:00
Nick Pismenkov 13cde37722 fix
[skip-regression-check]
2026-03-09 16:40:11 -07:00
Nick PismenkovandClaude Haiku 4.5 f5123055b5 docs: document libSQL CLI connection crash issue
Add detailed documentation for the libSQL CLI crash that blocks interactive
setup flows on libSQL deployments (ironclaw tool setup, ironclaw secret set,
ironclaw mcp auth).

- Add known issue comment to src/db/mod.rs create_secrets_store() documenting
  the "invalid connection string" crash on libSQL CLI subcommands
- Add related comments to src/cli/tool.rs init_secrets_store() and
  src/cli/mcp.rs get_secrets_store() functions
- Add to CLAUDE.md Current Limitations section as priority item #1
- Include workaround: manually read master key from /proc/PID/environ,
  encrypt secrets with AES-256-GCM, write directly to secrets table
- Root cause unknown: libSQL works fine in main process but fails when
  called from CLI subcommands (possibly path resolution, file permissions,
  or WAL mode conflicts)
- Related: #655 (libSQL backend gaps)

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-03-09 16:01:07 -07:00
53 changed files with 163 additions and 1418 deletions
-2
View File
@@ -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
+1 -2
View File
@@ -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:
+1 -7
View File
@@ -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
+7 -12
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
-75
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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": {
-2
View File
@@ -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
-93
View File
@@ -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
View File
@@ -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);
}
} }
-16
View File
@@ -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
View File
@@ -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"
);
}
} }
+1 -15
View File
@@ -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,
-2
View File
@@ -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();
-2
View File
@@ -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();
-1
View File
@@ -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
View File
@@ -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?;
-7
View File
@@ -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,
)?,
}) })
} }
} }
-10
View File
@@ -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]
-9
View File
@@ -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
}) })
} }
} }
+1 -4
View File
@@ -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())),
-18
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
} }
+4 -40
View File
@@ -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
-4
View File
@@ -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
View File
@@ -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).
-56
View File
@@ -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
View File
@@ -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());
}
} }
-5
View File
@@ -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
View File
@@ -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"
);
}
} }
+4 -32
View File
@@ -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(&params),
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;
+3 -33
View File
@@ -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(&params),
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());
+1 -33
View File
@@ -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.
-4
View File
@@ -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