Compare commits

...
Author SHA1 Message Date
[email protected]andClaude Opus 4.6 d535c93494 fix: address review feedback on idempotency cache
- Remove is_idempotent from tools with mutable external state: time,
  read_file, list_dir, list_jobs, job_status, memory_search,
  memory_read, memory_tree. Only truly pure tools (echo, json) remain.
- Tighten is_idempotent() doc to require pure-function semantics (no
  external state dependency, no side effects).
- Fix cache key canonicalization: recursively sort JSON object keys via
  BTreeMap so {"a":1,"b":2} and {"b":2,"a":1} produce the same hash.
  Added two tests for order independence (flat and nested).
- Move worker cache lookup from before approval/hooks/validation to
  after, so those checks always run even on cache hits. Cache now keys
  on post-hook params for consistency between get and put.
- Merge origin/main to pick up latest changes.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-10 12:20:30 -07:00
[email protected] 66873acfc2 Merge remote-tracking branch 'origin/main' into feat/tool-idempotency-cache-v2 2026-03-10 12:14:43 -07:00
Henry ParkandGitHub b442a1f5ca Merge pull request #807 from nearai/staging-promote/83950d11-22884429853
chore: promote staging to main (2026-03-10 02:35 UTC)
2026-03-10 11:40:37 -07:00
Henry ParkandGitHub 9c35c2a4ba Merge branch 'main' into staging-promote/83950d11-22884429853 2026-03-10 11:21:32 -07:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
9d4cf308ef chore: update WASM artifact SHA256 checksums [skip ci] (#876)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-10 17:55:38 +00:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
be57a7684d chore: release v0.17.0 (#842)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-10 16:30:41 +00:00
Henry ParkandGitHub c6ca2b7f58 Merge branch 'main' into staging-promote/83950d11-22884429853 2026-03-10 07:46:51 -07:00
[email protected]andClaude Opus 4.6 5fcb4d3c03 feat: add tool execution idempotency cache
When the LLM re-requests the same idempotent tool with identical args
(common during self-repair recovery, stuck job retries, or chat-mode
retry loops), the cache returns the previous result instantly without
re-executing.

Design improvements over the closed PR #204:
- Single global LRU cache (lru crate) instead of per-job HashMap with
  O(N) manual eviction
- Global cap of 2000 entries prevents memory leaks from chat-path
  ephemeral job IDs that never get invalidated
- TTL expiry (30min) checked on read; LRU eviction handles the rest
- Job invalidation still runs on worker completion for prompt cleanup

Idempotent tools: echo, time, json, memory_read/search/tree,
read_file, list_dir, list_jobs, job_status.

Closes #204

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-10 02:01:58 -07:00
2016693b0c feat(llm): per-provider unsupported parameter filtering (#749, #728) (#809)
Add declarative `unsupported_params` field to provider definitions in
providers.json. Parameters listed are stripped from requests before
sending, preventing 400 errors from providers that reject them (e.g.
gpt-5 family and kimi-k2.5 rejecting custom temperature values).

- Add `unsupported_params` to ProviderDefinition and RegistryProviderConfig
- Propagate from registry through config resolution
- Generic strip helpers handle temperature, max_tokens, stop_sequences
- Apply filtering in RigAdapter and AnthropicOAuthProvider
- Mark openai and tinfoil providers as unsupporting temperature
- Update openai default model to gpt-5-mini

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 07:11:26 +00:00
bcef04b821 feat: persist user_id in save_job and expose job_id on routine runs (#709)
* feat: persist worker events to DB and fix activity tab rendering

In-process Worker (used by Scheduler::dispatch_job) now persists events
via save_job_event at key execution points: plan creation, LLM
responses, tool_use, tool_result, and job completion/failure/stuck.
Event data shapes match the container worker format so the gateway
activity tab renders them correctly.

Frontend: tool_result errors now show a red X icon with danger styling
instead of a silent empty output. The result event falls back to the
error field when message is absent.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat: wire RoutineEngine into gateway for direct manual trigger firing

Replace the message-channel hack in routines_trigger_handler with a
direct call to RoutineEngine::fire_manual(), ensuring FullJob routines
dispatch correctly when triggered from the web UI. Inject the engine
into GatewayState from Agent::run after construction.

Also persists user_id in save_job for both PG and libSQL backends,
removes the source='sandbox' filter so all jobs are visible, and
exposes job_id on RoutineRunInfo for the frontend job link.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: remove stale gateway_state argument from Agent::new test call sites

The gateway_state parameter was removed from Agent::new during rebase
(replaced by post-construction set_routine_engine_slot), but three test
call sites still passed the extra None argument.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review — restore sandbox source filter, remove blank lines

- Revert removal of `source = 'sandbox'` filter in all SandboxStore
  queries (8 sites across PG and libSQL). Sandbox-specific APIs should
  stay scoped to sandbox jobs; unified job listing for the Jobs tab
  should use a separate query path.
- Remove extra blank lines in agent_loop.rs and worker.rs that caused
  formatting CI failure.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address review — regenerate Cargo.lock, add user_id regression test

- Regenerate Cargo.lock from main's lockfile to eliminate dependency
  version downgrades (anyhow, syn, etc.) that were churn from rebase.
- Add regression test verifying user_id round-trips through save_job
  and get_job in the libSQL backend.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: remove trailing blank line in libsql jobs.rs

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test: add Postgres-side regression test for user_id persistence in save_job

Mirrors the existing libSQL test (test_save_job_persists_user_id) for the
Postgres backend. Gated behind #[cfg(feature = "postgres")] + #[ignore]
since it requires a running PostgreSQL instance (integration tier).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-10 01:51:43 +00:00
6e12ce6f2d fix(ci): run fmt + clippy on staging PRs, skip Windows clippy [skip-regression-check] (#802)
- Remove branches:[main] filter from code_style.yml so it runs on all PRs
- Gate clippy-windows with `if: github.base_ref == 'main'` (skip on staging PRs)
- Update rollup job to allow skipped clippy-windows
- Simplify claude-review.yml to only trigger on labeled event (avoids duplicate runs)

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-09 18:43:16 -07:00
b53986f00b fix(ci): clean up staging pipeline — remove hacks, skip redundant checks [skip-regression-check] (#794)
- Remove continue-on-error from staging-ci.yml app token steps (secrets are configured)
- Skip test.yml and code_style.yml on PRs targeting staging (staging-ci.yml
  already runs tests before promoting, promotion PR gets full CI on main)
- Allow ironclaw-ci[bot] in Claude Code review for bot-created promotion PRs

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-09 17:35:06 -07:00
1440ec7422 fix(ci): secrets can't be used in step if conditions [skip-regression-check] (#787)
GitHub Actions step-level `if:` doesn't have access to `secrets` context.
Replace `if: secrets.X != ''` with `continue-on-error: true` and let
the Set token step handle the fallback.

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-03-09 23:58:56 +00:00
38 changed files with 835 additions and 41 deletions
+75
View File
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
- 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]]
name = "ironclaw"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"aes-gcm",
"aho-corasick",
+1 -1
View File
@@ -18,7 +18,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.16.1"
version = "0.17.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
+3 -1
View File
@@ -9,8 +9,9 @@
"api_key_required": true,
"base_url_env": "OPENAI_BASE_URL",
"model_env": "OPENAI_MODEL",
"default_model": "gpt-4o",
"default_model": "gpt-5-mini",
"description": "OpenAI GPT models (direct API)",
"unsupported_params": ["temperature"],
"setup": {
"kind": "api_key",
"secret_name": "llm_openai_api_key",
@@ -86,6 +87,7 @@
"model_env": "TINFOIL_MODEL",
"default_model": "kimi-k2-5",
"description": "Tinfoil private inference (hardware-attested TEE)",
"unsupported_params": ["temperature"],
"setup": {
"kind": "api_key",
"secret_name": "llm_tinfoil_api_key",
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/discord-wasm32-wasip2.tar.gz",
"sha256": "030707431717bca3411a48f311c6ab5f92a45c747de26cafe4f6e3e23a8b3b2d"
"sha256": "85b424604482da3fb9badb56a0360ff4c93670bc7be0ad7f57ef9d85ff972b6f"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-wasm32-wasip2.tar.gz",
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-wasm32-wasip2.tar.gz",
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/whatsapp-wasm32-wasip2.tar.gz",
"sha256": "bd35cad18d87292ea8d2f52db9b514ed9f814a414de910f59073d475c26c4c14"
"sha256": "06bcf315df93af9f683134f4055eb810c602863d8c4a632e3733a10217cc5a89"
}
},
"auth_summary": {
+1 -1
View File
@@ -20,7 +20,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/github-wasm32-wasip2.tar.gz",
"sha256": "6fcd32719a4ff15641a4b50fff8984686550f0c491dce60518f4126857d0c544"
"sha256": "c443328a3f10b6a4cf4d3d62c9217aca204f6467ef753d986b58ca966ca53514"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/gmail-wasm32-wasip2.tar.gz",
"sha256": "023da7000b17568bf0e64b2e5013c8a042b2f323c85f1632339231c73d500e39"
"sha256": "e4f0095890d22e3de8e9d516f2e1e91964f8ff4acdaaa19f0a7094a1f2d7786b"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-calendar-wasm32-wasip2.tar.gz",
"sha256": "fc42277b65881d6e9bcc5403dc54c7f5b3ddeaaaf04617fce2c5da05d76325f0"
"sha256": "2d202bd838de94677c91ea6473c7155f021c0500cf91794d17639b1b27446b3d"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-docs-wasm32-wasip2.tar.gz",
"sha256": "385c04abd1e6b8011ccc330e1f4bd7ce58577e488959b51594aa04eb26cbe7cc"
"sha256": "7a5e40fe58199e34f7625e11d22e5601cdfd2a94a10193a83f1925180bbb66df"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-drive-wasm32-wasip2.tar.gz",
"sha256": "1b107d575a5d52cc8c76d9a681802190f4373fb485f7f54f445533f097fa37c0"
"sha256": "d19f856fde0ae0320fd3f636a34116af1df0b59698c3684b686e8412a60e887f"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-sheets-wasm32-wasip2.tar.gz",
"sha256": "c4f6b1e8c5126ac2c8a4b98e4283a3afa32223d2488fc3c3a609758c0c9beb90"
"sha256": "e113c317f9fa21ea68d0ec8accbba4a62a8222ff3c4655ae85e1e58e01de3250"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/google-slides-wasm32-wasip2.tar.gz",
"sha256": "7110b8565340c888e51f99e9c013bf4de8f8a7f7b33bace00eb8fc47831ff20b"
"sha256": "7875a5ae1283e57937e0618bf14465f4bb4ee7f49110312382670202f4c567a5"
}
},
"auth_summary": {
+1 -1
View File
@@ -18,7 +18,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/slack-tool-wasm32-wasip2.tar.gz",
"sha256": "6ed36077b67ac70a041f06f760f93ba79b33269885413c3c3f2c8c87ee60807e"
"sha256": "9190b8250bd20c22a8c97b1ea19a6590624a69d6c63a5f5c240a7840a4966286"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/telegram-mtproto-wasm32-wasip2.tar.gz",
"sha256": "98c86895a9c4b0a1e19fe8a47f1ccbfe7e972e112b05e584bc897130dc32283a"
"sha256": "55f2a56e7afd129a48fd49b019f12f9638705defa53fa323ad3b8978d7c59664"
}
},
"auth_summary": {
+1 -1
View File
@@ -19,7 +19,7 @@
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/latest/download/web-search-wasm32-wasip2.tar.gz",
"sha256": "66cb2b9b00652385e9f30f17c74902b9222c17c53e9d3bd1ef42f5cab705bcf6"
"sha256": "dd7e54956ee0b3037ca3506dbcbd20efcc4cd2749175ed511b3640b09f77506a"
}
},
"auth_summary": {
+4
View File
@@ -29,6 +29,7 @@ use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::skills::SkillRegistry;
use crate::tools::ToolRegistry;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::workspace::Workspace;
/// Collapse a tool output string into a single-line preview for display.
@@ -81,6 +82,8 @@ pub struct AgentDeps {
pub transcription: Option<Arc<crate::transcription::TranscriptionMiddleware>>,
/// Document text extraction middleware for PDF, DOCX, PPTX, etc.
pub document_extraction: Option<Arc<crate::document_extraction::DocumentExtractionMiddleware>>,
/// Idempotency cache for tool executions.
pub idempotency_cache: ToolIdempotencyCache,
}
/// The main agent that coordinates all components.
@@ -130,6 +133,7 @@ impl Agent {
deps.tools.clone(),
deps.store.clone(),
deps.hooks.clone(),
deps.idempotency_cache.clone(),
);
if let Some(ref tx) = deps.sse_tx {
scheduler.set_sse_sender(tx.clone());
+59 -8
View File
@@ -15,6 +15,7 @@ use crate::channels::{IncomingMessage, StatusUpdate};
use crate::context::JobContext;
use crate::error::Error;
use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult};
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::redact_params;
/// Result of the agentic loop execution.
@@ -569,6 +570,7 @@ impl Agent {
let pf_idx = *pf_idx;
let tools = self.tools().clone();
let safety = self.safety().clone();
let idempotency_cache = self.deps.idempotency_cache.clone();
let channels = self.channels.clone();
let job_ctx = job_ctx.clone();
let tc = tc.clone();
@@ -589,6 +591,7 @@ impl Agent {
let result = execute_chat_tool_standalone(
&tools,
&safety,
&idempotency_cache,
&tc.name,
&tc.arguments,
&job_ctx,
@@ -859,7 +862,15 @@ impl Agent {
params: &serde_json::Value,
job_ctx: &JobContext,
) -> Result<String, Error> {
execute_chat_tool_standalone(self.tools(), self.safety(), tool_name, params, job_ctx).await
execute_chat_tool_standalone(
self.tools(),
self.safety(),
&self.deps.idempotency_cache,
tool_name,
params,
job_ctx,
)
.await
}
}
@@ -868,9 +879,11 @@ impl Agent {
/// This standalone function enables parallel invocation from spawned JoinSet
/// tasks, which cannot borrow `&self`. It replicates the logic from
/// `Agent::execute_chat_tool`.
#[allow(clippy::too_many_arguments)]
pub(super) async fn execute_chat_tool_standalone(
tools: &crate::tools::ToolRegistry,
safety: &crate::safety::SafetyLayer,
idempotency_cache: &ToolIdempotencyCache,
tool_name: &str,
params: &serde_json::Value,
job_ctx: &crate::context::JobContext,
@@ -882,6 +895,15 @@ pub(super) async fn execute_chat_tool_standalone(
name: tool_name.to_string(),
})?;
// Check idempotency cache
let job_id_str = job_ctx.job_id.to_string();
if tool.is_idempotent()
&& let Some(cached) = idempotency_cache.get(&job_id_str, tool_name, params).await
{
tracing::debug!(tool = %tool_name, "Idempotency cache hit (chat)");
return Ok(cached);
}
// Validate tool parameters
let validation = safety.validator().validate_tool_params(params);
if !validation.is_valid {
@@ -953,13 +975,25 @@ pub(super) async fn execute_chat_tool_standalone(
reason: e.to_string(),
})?;
serde_json::to_string_pretty(&result.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
})
let result_str: Result<String, Error> =
serde_json::to_string_pretty(&result.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
});
// Cache successful results for idempotent tools
if let Ok(ref output_str) = result_str
&& tool.is_idempotent()
{
idempotency_cache
.put(&job_id_str, tool_name, params, output_str.clone())
.await;
}
result_str
}
/// Parsed auth result fields for emitting StatusUpdate::AuthRequired.
@@ -1187,6 +1221,9 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
@@ -1520,9 +1557,13 @@ mod tests {
let job_ctx = JobContext::with_user("test", "chat", "test session");
let cache = crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
);
let result = super::execute_chat_tool_standalone(
&registry,
&safety,
&cache,
"echo",
&serde_json::json!({"message": "hello"}),
&job_ctx,
@@ -1548,9 +1589,13 @@ mod tests {
});
let job_ctx = JobContext::with_user("test", "chat", "test session");
let cache = crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
);
let result = super::execute_chat_tool_standalone(
&registry,
&safety,
&cache,
"nonexistent",
&serde_json::json!({}),
&job_ctx,
@@ -2026,6 +2071,9 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
@@ -2143,6 +2191,9 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Agent::new(
+7
View File
@@ -18,6 +18,7 @@ use crate::error::{Error, JobError};
use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
use crate::safety::SafetyLayer;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::{ApprovalContext, ToolRegistry};
/// Message to send to a worker.
@@ -58,6 +59,8 @@ pub struct Scheduler {
sse_tx: Option<tokio::sync::broadcast::Sender<SseEvent>>,
/// HTTP interceptor for trace recording/replay (propagated to workers).
http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
/// Idempotency cache for tool executions (shared across all workers).
idempotency_cache: ToolIdempotencyCache,
/// Running jobs (main LLM-driven jobs).
jobs: Arc<RwLock<HashMap<Uuid, ScheduledJob>>>,
/// Running sub-tasks (tool executions, background tasks).
@@ -66,6 +69,7 @@ pub struct Scheduler {
impl Scheduler {
/// Create a new scheduler.
#[allow(clippy::too_many_arguments)]
pub fn new(
config: AgentConfig,
context_manager: Arc<ContextManager>,
@@ -74,6 +78,7 @@ impl Scheduler {
tools: Arc<ToolRegistry>,
store: Option<Arc<dyn Database>>,
hooks: Arc<HookRegistry>,
idempotency_cache: ToolIdempotencyCache,
) -> Self {
Self {
config,
@@ -85,6 +90,7 @@ impl Scheduler {
hooks,
sse_tx: None,
http_interceptor: None,
idempotency_cache,
jobs: Arc::new(RwLock::new(HashMap::new())),
subtasks: Arc::new(RwLock::new(HashMap::new())),
}
@@ -254,6 +260,7 @@ impl Scheduler {
sse_tx: self.sse_tx.clone(),
approval_context,
http_interceptor: self.http_interceptor.clone(),
idempotency_cache: self.idempotency_cache.clone(),
};
let worker = Worker::new(job_id, deps);
+2
View File
@@ -959,6 +959,7 @@ impl Agent {
for (spawn_idx, tc) in runnable.iter().enumerate() {
let tools = self.tools().clone();
let safety = self.safety().clone();
let idempotency_cache = self.deps.idempotency_cache.clone();
let channels = self.channels.clone();
let job_ctx = job_ctx.clone();
let tc = tc.clone();
@@ -979,6 +980,7 @@ impl Agent {
let result = execute_chat_tool_standalone(
&tools,
&safety,
&idempotency_cache,
&tc.name,
&tc.arguments,
&job_ctx,
+52 -7
View File
@@ -19,6 +19,7 @@ use crate::llm::{
ToolSelection,
};
use crate::safety::SafetyLayer;
use crate::tools::idempotency::ToolIdempotencyCache;
use crate::tools::rate_limiter::RateLimitResult;
use crate::tools::{ApprovalContext, ToolRegistry, redact_params};
@@ -44,6 +45,8 @@ pub struct WorkerDeps {
pub approval_context: Option<ApprovalContext>,
/// HTTP interceptor for trace recording/replay (propagated to JobContext).
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
/// Idempotency cache for tool executions.
pub idempotency_cache: ToolIdempotencyCache,
}
/// Worker that executes a single job.
@@ -285,6 +288,12 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
}
}
// Clean up idempotency cache entries for this job
self.deps
.idempotency_cache
.invalidate_job(&self.job_id.to_string())
.await;
Ok(())
}
@@ -737,6 +746,8 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
name: tool_name.to_string(),
})?;
let job_id_str = job_id.to_string();
// Check approval: use context-aware check if available, else block all non-Never tools
let requirement = tool.requires_approval(params);
let blocked =
@@ -832,6 +843,22 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
.into());
}
// Check idempotency cache after approval/hooks/validation so those
// checks always run. Uses post-hook params for consistency with put().
if tool.is_idempotent()
&& let Some(cached) = deps
.idempotency_cache
.get(&job_id_str, tool_name, &params)
.await
{
tracing::debug!(
tool = %tool_name,
job = %job_id,
"Idempotency cache hit"
);
return Ok(cached);
}
// Redact sensitive parameter values (e.g. secret_save's "value") before
// they touch any observability or audit path.
let safe_params = redact_params(&params, tool.sensitive_params());
@@ -967,13 +994,25 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
})?;
// Return result as string
serde_json::to_string_pretty(&output.result).map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
})
let result_str: Result<String, Error> = serde_json::to_string_pretty(&output.result)
.map_err(|e| {
crate::error::ToolError::ExecutionFailed {
name: tool_name.to_string(),
reason: format!("Failed to serialize result: {}", e),
}
.into()
});
// Cache successful results for idempotent tools
if let Ok(ref output_str) = result_str
&& tool.is_idempotent()
{
deps.idempotency_cache
.put(&job_id_str, tool_name, &params, output_str.clone())
.await;
}
result_str
}
/// Process a tool execution result and add it to the reasoning context.
@@ -1386,6 +1425,9 @@ mod tests {
sse_tx: None,
approval_context: None,
http_interceptor: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Worker::new(job_id, deps)
@@ -1648,6 +1690,9 @@ mod tests {
sse_tx: None,
approval_context,
http_interceptor: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
Worker::new(job_id, deps)
+10
View File
@@ -209,6 +209,7 @@ impl LlmConfig {
extra_headers_env,
api_key_required,
base_url_required,
unsupported_params,
) = if let Some(def) = def {
(
def.id.as_str(),
@@ -221,6 +222,7 @@ impl LlmConfig {
def.extra_headers_env.as_deref(),
def.api_key_required,
def.base_url_required,
def.unsupported_params.clone(),
)
} else {
// Absolute fallback: treat as generic openai_completions
@@ -235,6 +237,7 @@ impl LlmConfig {
Some("LLM_EXTRA_HEADERS"),
false,
true,
Vec::new(),
)
};
@@ -338,6 +341,7 @@ impl LlmConfig {
extra_headers,
oauth_token,
cache_retention,
unsupported_params,
})
}
}
@@ -624,6 +628,12 @@ mod tests {
let provider = cfg.provider.expect("provider config should be present");
assert_eq!(provider.base_url, "https://inference.tinfoil.sh/v1");
assert_eq!(provider.model, "kimi-k2-5");
assert!(
provider
.unsupported_params
.contains(&"temperature".to_string()),
"tinfoil should propagate unsupported_params from registry"
);
}
#[test]
+40 -4
View File
@@ -6,6 +6,8 @@
//!
//! Pattern follows `nearai_chat.rs`: direct HTTP calls via `reqwest::Client`.
use std::collections::HashSet;
use async_trait::async_trait;
use reqwest::Client;
use rust_decimal::Decimal;
@@ -35,6 +37,8 @@ pub struct AnthropicOAuthProvider {
model: String,
base_url: Option<String>,
active_model: std::sync::RwLock<String>,
/// Parameter names that this provider does not support.
unsupported_params: HashSet<String>,
}
impl AnthropicOAuthProvider {
@@ -61,15 +65,45 @@ impl AnthropicOAuthProvider {
Some(config.base_url.clone())
};
let unsupported_params: HashSet<String> =
config.unsupported_params.iter().cloned().collect();
Ok(Self {
client,
token,
model: config.model.clone(),
base_url,
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 {
if let Some(ref base) = self.base_url {
let base = base.trim_end_matches('/');
@@ -197,8 +231,9 @@ impl AnthropicOAuthProvider {
#[async_trait]
impl LlmProvider for AnthropicOAuthProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name());
async fn complete(&self, mut req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_completion_params(&mut req);
let (system, messages) = convert_messages(req.messages);
let request = AnthropicRequest {
@@ -233,9 +268,10 @@ impl LlmProvider for AnthropicOAuthProvider {
async fn complete_with_tools(
&self,
req: ToolCompletionRequest,
mut req: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
let model = req.model.unwrap_or_else(|| self.active_model_name());
let model = req.model.take().unwrap_or_else(|| self.active_model_name());
self.strip_unsupported_tool_params(&mut req);
let (system, messages) = convert_messages(req.messages);
let tools: Vec<AnthropicTool> = req
+4
View File
@@ -87,6 +87,10 @@ pub struct RegistryProviderConfig {
pub oauth_token: Option<SecretString>,
/// Prompt cache retention (Anthropic-specific).
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).
+9 -3
View File
@@ -228,7 +228,9 @@ fn create_openai_compat_from_registry(
"Using OpenAI-compatible provider"
);
Ok(Arc::new(RigAdapter::new(model, &config.model)))
let adapter = RigAdapter::new(model, &config.model)
.with_unsupported_params(config.unsupported_params.clone());
Ok(Arc::new(adapter))
}
fn create_anthropic_from_registry(
@@ -296,7 +298,9 @@ fn create_anthropic_from_registry(
);
Ok(Arc::new(
RigAdapter::new(model, &config.model).with_cache_retention(cache_retention),
RigAdapter::new(model, &config.model)
.with_cache_retention(cache_retention)
.with_unsupported_params(config.unsupported_params.clone()),
))
}
@@ -324,7 +328,9 @@ fn create_ollama_from_registry(
"Using Ollama provider"
);
Ok(Arc::new(RigAdapter::new(model, &config.model)))
let adapter = 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).
+56
View File
@@ -152,6 +152,11 @@ pub struct ProviderDefinition {
/// Setup wizard hints.
#[serde(default)]
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.
@@ -378,6 +383,7 @@ mod tests {
description: "Custom tinfoil".to_string(),
extra_headers_env: None,
setup: None,
unsupported_params: vec![],
});
let registry = ProviderRegistry::new(all);
let tf = registry.find("tinfoil").expect("tinfoil should exist");
@@ -517,6 +523,7 @@ mod tests {
description: "No setup".to_string(),
extra_headers_env: None,
setup: None, // no setup hint
unsupported_params: vec![],
}];
let registry = ProviderRegistry::new(providers.clone());
@@ -546,6 +553,7 @@ mod tests {
can_list_models: false,
models_filter: None,
}),
unsupported_params: vec![],
});
let registry = ProviderRegistry::new(providers);
@@ -587,6 +595,7 @@ mod tests {
can_list_models: false,
models_filter: None,
}),
unsupported_params: vec![],
},
// User override removes setup
ProviderDefinition {
@@ -603,6 +612,7 @@ mod tests {
description: "No setup now".to_string(),
extra_headers_env: None,
setup: None,
unsupported_params: vec![],
},
];
@@ -640,6 +650,7 @@ mod tests {
display_name: "A".to_string(),
can_list_models: false,
}),
unsupported_params: vec![],
},
ProviderDefinition {
id: "bbb".to_string(),
@@ -658,6 +669,7 @@ mod tests {
display_name: "B".to_string(),
can_list_models: false,
}),
unsupported_params: vec![],
},
ProviderDefinition {
id: "ccc".to_string(),
@@ -676,6 +688,7 @@ mod tests {
display_name: "C".to_string(),
can_list_models: false,
}),
unsupported_params: vec![],
},
// User override for B
ProviderDefinition {
@@ -695,6 +708,7 @@ mod tests {
display_name: "B".to_string(),
can_list_models: false,
}),
unsupported_params: vec![],
},
];
@@ -708,6 +722,48 @@ 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]
fn test_all_builtin_api_key_providers_have_api_key_env() {
// Every built-in provider with SetupHint::ApiKey must have api_key_env
+144 -2
View File
@@ -42,6 +42,9 @@ pub struct RigAdapter<M: CompletionModel> {
/// via `additional_params` for Anthropic automatic caching. Also controls
/// the cost multiplier for cache-creation tokens.
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> {
@@ -56,6 +59,7 @@ impl<M: CompletionModel> RigAdapter<M> {
input_cost,
output_cost,
cache_retention: CacheRetention::None,
unsupported_params: HashSet::new(),
}
}
@@ -84,6 +88,44 @@ impl<M: CompletionModel> RigAdapter<M> {
}
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 --
@@ -539,7 +581,10 @@ where
}
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
async fn complete(
&self,
mut request: CompletionRequest,
) -> Result<CompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str()
{
@@ -550,6 +595,8 @@ where
);
}
self.strip_unsupported_completion_params(&mut request);
let mut messages = request.messages;
crate::llm::provider::sanitize_tool_messages(&mut messages);
let (preamble, history) = convert_messages(&messages);
@@ -599,7 +646,7 @@ where
async fn complete_with_tools(
&self,
request: ToolCompletionRequest,
mut request: ToolCompletionRequest,
) -> Result<ToolCompletionResponse, LlmError> {
if let Some(requested_model) = request.model.as_deref()
&& requested_model != self.model_name.as_str()
@@ -611,6 +658,8 @@ where
);
}
self.strip_unsupported_tool_params(&mut request);
let known_tool_names: HashSet<String> =
request.tools.iter().map(|t| t.name.clone()).collect();
@@ -1156,4 +1205,97 @@ mod tests {
assert!(!supports_prompt_cache("gpt-4o"));
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());
}
}
+3
View File
@@ -623,6 +623,9 @@ async fn async_main() -> anyhow::Result<()> {
document_extraction: Some(Arc::new(
ironclaw::document_extraction::DocumentExtractionMiddleware::new(),
)),
idempotency_cache: ironclaw::tools::idempotency::ToolIdempotencyCache::new(
ironclaw::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
let mut agent = Agent::new(
+1
View File
@@ -3996,6 +3996,7 @@ mod tests {
description: "Custom provider with no setup wizard".to_string(),
extra_headers_env: None,
setup: None,
unsupported_params: vec![],
});
let registry = crate::llm::ProviderRegistry::new(providers);
+3
View File
@@ -453,6 +453,9 @@ impl TestHarnessBuilder {
http_interceptor: None,
transcription: None,
document_extraction: None,
idempotency_cache: crate::tools::idempotency::ToolIdempotencyCache::new(
crate::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
TestHarness {
+4
View File
@@ -46,4 +46,8 @@ impl Tool for EchoTool {
fn requires_sanitization(&self) -> bool {
false // Internal tool, no external data
}
fn is_idempotent(&self) -> bool {
true
}
}
+4
View File
@@ -132,6 +132,10 @@ impl Tool for JsonTool {
fn requires_sanitization(&self) -> bool {
false // Internal tool, no external data
}
fn is_idempotent(&self) -> bool {
true
}
}
fn parse_json_input(data: &serde_json::Value) -> Result<serde_json::Value, ToolError> {
+316
View File
@@ -0,0 +1,316 @@
//! Tool execution idempotency cache.
//!
//! Caches results of idempotent tool calls (same tool + same args = same result)
//! to avoid redundant re-execution during LLM retry loops, self-repair recovery,
//! and stuck job retries.
use std::sync::Arc;
use std::time::{Duration, Instant};
use lru::LruCache;
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
/// Cached tool result with expiration.
#[derive(Clone, Debug)]
struct CachedResult {
/// The serialized tool output string.
output: String,
/// When this entry was inserted.
inserted_at: Instant,
}
/// Configuration for the idempotency cache.
#[derive(Debug, Clone)]
pub struct IdempotencyCacheConfig {
/// Maximum total entries across all jobs. Default: 2000.
pub max_entries: usize,
/// Time-to-live for cached entries. Default: 30 minutes.
pub ttl: Duration,
}
impl Default for IdempotencyCacheConfig {
fn default() -> Self {
Self {
max_entries: 2000,
ttl: Duration::from_secs(30 * 60),
}
}
}
/// Global idempotency cache for tool executions.
///
/// Uses a single LRU cache with composite keys (job_id + tool_name + args_hash).
/// Entries expire after `ttl` and the total size is bounded by `max_entries`.
#[derive(Clone)]
pub struct ToolIdempotencyCache {
inner: Arc<Mutex<LruCache<String, CachedResult>>>,
config: IdempotencyCacheConfig,
}
impl ToolIdempotencyCache {
/// Create a new cache with the given configuration.
pub fn new(config: IdempotencyCacheConfig) -> Self {
let cap = std::num::NonZeroUsize::new(config.max_entries)
.unwrap_or(std::num::NonZeroUsize::new(1).expect("nonzero"));
Self {
inner: Arc::new(Mutex::new(LruCache::new(cap))),
config,
}
}
/// Look up a cached result. Returns `None` if absent or expired.
pub async fn get(
&self,
job_id: &str,
tool_name: &str,
params: &serde_json::Value,
) -> Option<String> {
let key = Self::cache_key(job_id, tool_name, params);
let mut cache = self.inner.lock().await;
if let Some(entry) = cache.get(&key) {
if entry.inserted_at.elapsed() < self.config.ttl {
return Some(entry.output.clone());
}
// Expired — remove it
cache.pop(&key);
}
None
}
/// Store a successful tool result in the cache.
pub async fn put(
&self,
job_id: &str,
tool_name: &str,
params: &serde_json::Value,
output: String,
) {
let key = Self::cache_key(job_id, tool_name, params);
let entry = CachedResult {
output,
inserted_at: Instant::now(),
};
let mut cache = self.inner.lock().await;
cache.put(key, entry);
}
/// Remove all cached entries for a specific job (call on job completion).
pub async fn invalidate_job(&self, job_id: &str) {
let prefix = format!("{}:", job_id);
let mut cache = self.inner.lock().await;
// Collect keys to remove (can't mutate while iterating)
let keys_to_remove: Vec<String> = cache
.iter()
.filter_map(|(k, _)| {
if k.starts_with(&prefix) {
Some(k.clone())
} else {
None
}
})
.collect();
for key in keys_to_remove {
cache.pop(&key);
}
}
/// Build a deterministic cache key from job_id, tool name, and params.
///
/// JSON object keys are sorted recursively to ensure order-independent
/// hashing (`{"a":1,"b":2}` and `{"b":2,"a":1}` produce the same key).
fn cache_key(job_id: &str, tool_name: &str, params: &serde_json::Value) -> String {
let mut hasher = Sha256::new();
hasher.update(tool_name.as_bytes());
hasher.update(b":");
let canonical = Self::canonicalize(params);
let params_str = serde_json::to_string(&canonical).unwrap_or_default();
hasher.update(params_str.as_bytes());
let hash = format!("{:x}", hasher.finalize());
format!("{}:{}:{}", job_id, tool_name, hash)
}
/// Recursively sort JSON object keys for canonical serialization.
fn canonicalize(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted: std::collections::BTreeMap<String, serde_json::Value> =
std::collections::BTreeMap::new();
for (k, v) in map {
sorted.insert(k.clone(), Self::canonicalize(v));
}
serde_json::Value::Object(sorted.into_iter().collect())
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(Self::canonicalize).collect())
}
other => other.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> IdempotencyCacheConfig {
IdempotencyCacheConfig {
max_entries: 10,
ttl: Duration::from_secs(60),
}
}
#[tokio::test]
async fn test_cache_hit() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
cache
.put("job1", "read_file", &params, "file contents".into())
.await;
let result = cache.get("job1", "read_file", &params).await;
assert_eq!(result, Some("file contents".into()));
}
#[tokio::test]
async fn test_cache_miss() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
let result = cache.get("job1", "read_file", &params).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_different_params_miss() {
let cache = ToolIdempotencyCache::new(config());
let params1 = serde_json::json!({"path": "/etc/hosts"});
let params2 = serde_json::json!({"path": "/etc/passwd"});
cache
.put("job1", "read_file", &params1, "hosts".into())
.await;
let result = cache.get("job1", "read_file", &params2).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_different_jobs_isolated() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"path": "/etc/hosts"});
cache
.put("job1", "read_file", &params, "from job1".into())
.await;
let result = cache.get("job2", "read_file", &params).await;
assert_eq!(result, None);
}
#[tokio::test]
async fn test_invalidate_job() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"q": "test"});
cache.put("job1", "echo", &params, "echo1".into()).await;
cache
.put("job1", "time", &serde_json::json!({}), "now".into())
.await;
cache.put("job2", "echo", &params, "echo2".into()).await;
cache.invalidate_job("job1").await;
assert_eq!(cache.get("job1", "echo", &params).await, None);
assert_eq!(
cache.get("job1", "time", &serde_json::json!({})).await,
None
);
// job2 unaffected
assert_eq!(
cache.get("job2", "echo", &params).await,
Some("echo2".into())
);
}
#[tokio::test]
async fn test_ttl_expiry() {
let cache = ToolIdempotencyCache::new(IdempotencyCacheConfig {
max_entries: 10,
ttl: Duration::from_millis(1),
});
let params = serde_json::json!({"x": 1});
cache.put("job1", "echo", &params, "val".into()).await;
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(cache.get("job1", "echo", &params).await, None);
}
#[tokio::test]
async fn test_lru_eviction() {
let cache = ToolIdempotencyCache::new(IdempotencyCacheConfig {
max_entries: 3,
ttl: Duration::from_secs(60),
});
// Fill to capacity
for i in 0..3 {
let params = serde_json::json!({"i": i});
cache.put("job1", "echo", &params, format!("val{i}")).await;
}
// Insert one more, evicting the oldest (i=0)
let params_new = serde_json::json!({"i": 99});
cache.put("job1", "echo", &params_new, "val99".into()).await;
assert_eq!(
cache
.get("job1", "echo", &serde_json::json!({"i": 0}))
.await,
None
);
assert_eq!(
cache
.get("job1", "echo", &serde_json::json!({"i": 2}))
.await,
Some("val2".into())
);
assert_eq!(
cache.get("job1", "echo", &params_new).await,
Some("val99".into())
);
}
#[tokio::test]
async fn test_cache_key_determinism() {
let key1 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"a": 1, "b": 2}));
let key2 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"a": 1, "b": 2}));
assert_eq!(key1, key2);
}
#[tokio::test]
async fn test_cache_key_order_independent() {
// JSON objects with different key insertion order must produce the same cache key
let key1 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"a": 1, "b": 2}));
let key2 =
ToolIdempotencyCache::cache_key("j1", "echo", &serde_json::json!({"b": 2, "a": 1}));
assert_eq!(key1, key2);
}
#[tokio::test]
async fn test_cache_key_nested_order_independent() {
let key1 = ToolIdempotencyCache::cache_key(
"j1",
"tool",
&serde_json::json!({"x": {"c": 3, "d": 4}, "y": 1}),
);
let key2 = ToolIdempotencyCache::cache_key(
"j1",
"tool",
&serde_json::json!({"y": 1, "x": {"d": 4, "c": 3}}),
);
assert_eq!(key1, key2);
}
#[tokio::test]
async fn test_overwrite_existing_entry() {
let cache = ToolIdempotencyCache::new(config());
let params = serde_json::json!({"x": 1});
cache.put("job1", "echo", &params, "old".into()).await;
cache.put("job1", "echo", &params, "new".into()).await;
assert_eq!(cache.get("job1", "echo", &params).await, Some("new".into()));
}
}
+1
View File
@@ -9,6 +9,7 @@
pub mod builder;
pub mod builtin;
pub mod idempotency;
pub mod mcp;
pub mod rate_limiter;
pub mod schema_validator;
+19
View File
@@ -312,6 +312,25 @@ pub trait Tool: Send + Sync {
&[]
}
/// Whether this tool is a pure function of its input parameters.
///
/// A tool marked idempotent must produce the same output for the same input
/// regardless of when it is called — no dependency on external mutable state
/// (filesystem, time, database, network) and no side effects.
///
/// Results of idempotent tools are cached to avoid re-execution when the LLM
/// re-requests the same tool with identical arguments (common during
/// self-repair recovery or retry loops).
///
/// Examples: `echo` (returns input), `json` (parse/format).
/// Counter-examples: `read_file` (filesystem changes), `time` (clock),
/// `memory_search` (workspace mutations), `list_jobs` (job state changes).
///
/// Default: `false`. Override to return `true` only for pure functions.
fn is_idempotent(&self) -> bool {
false
}
/// Per-invocation rate limit for this tool.
///
/// Return `Some(config)` to throttle how often this tool can be called per user.
+3
View File
@@ -624,6 +624,9 @@ impl TestRigBuilder {
},
transcription: None,
document_extraction: None,
idempotency_cache: ironclaw::tools::idempotency::ToolIdempotencyCache::new(
ironclaw::tools::idempotency::IdempotencyCacheConfig::default(),
),
};
// 7. Create TestChannel and ChannelManager.