diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aad499357..9acc56ada6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.22.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.21.0...ironclaw-v0.22.0) - 2026-03-25 + +### Added + +- *(agent)* thread per-tool reasoning through provider, session, and all surfaces ([#1513](https://github.com/nearai/ironclaw/pull/1513)) +- *(cli)* show credential auth status in tool info ([#1572](https://github.com/nearai/ironclaw/pull/1572)) +- multi-tenant auth with per-user workspace isolation ([#1118](https://github.com/nearai/ironclaw/pull/1118)) +- *(cli)* add ironclaw models subcommands (list/status/set/set-provider) ([#1043](https://github.com/nearai/ironclaw/pull/1043)) +- *(workspace)* multi-scope workspace reads ([#1117](https://github.com/nearai/ironclaw/pull/1117)) +- *(ux)* complete UX overhaul — design system, onboarding, web polish ([#1277](https://github.com/nearai/ironclaw/pull/1277)) +- *(gemini_oauth)* full Gemini CLI OAuth integration with Cloud Code API ([#1356](https://github.com/nearai/ironclaw/pull/1356)) +- *(shell)* add Low/Medium/High risk levels for graduated command approval (closes #172) ([#368](https://github.com/nearai/ironclaw/pull/368)) +- *(agent)* queue and merge messages during active turns ([#1412](https://github.com/nearai/ironclaw/pull/1412)) +- *(cli)* add `ironclaw hooks list` subcommand ([#1023](https://github.com/nearai/ironclaw/pull/1023)) +- *(extensions)* support text setup fields in web configure modal ([#496](https://github.com/nearai/ironclaw/pull/496)) +- *(llm)* add GitHub Copilot as LLM provider ([#1512](https://github.com/nearai/ironclaw/pull/1512)) +- *(workspace)* layered memory with sensitivity-based privacy redirect ([#1112](https://github.com/nearai/ironclaw/pull/1112)) +- *(webhooks)* add public webhook trigger endpoint for routines ([#736](https://github.com/nearai/ironclaw/pull/736)) +- *(llm)* Add OpenAI Codex (ChatGPT subscription) as LLM provider ([#1461](https://github.com/nearai/ironclaw/pull/1461)) +- *(web)* add light theme with dark/light/system toggle ([#1457](https://github.com/nearai/ironclaw/pull/1457)) +- *(agent)* activate stuck_threshold for time-based stuck job detection ([#1234](https://github.com/nearai/ironclaw/pull/1234)) +- chat onboarding and routine advisor ([#927](https://github.com/nearai/ironclaw/pull/927)) + +### Fixed + +- ensure LLM calls always end with user message (closes #763) ([#1259](https://github.com/nearai/ironclaw/pull/1259)) +- restore owner-scoped gateway startup ([#1625](https://github.com/nearai/ironclaw/pull/1625)) +- remove stale stream_token gate from channel-relay activation ([#1623](https://github.com/nearai/ironclaw/pull/1623)) +- *(agent)* case-insensitive channel match and user_id filter for event triggers ([#1211](https://github.com/nearai/ironclaw/pull/1211)) +- *(routines)* normalize status display across web and CLI ([#1469](https://github.com/nearai/ironclaw/pull/1469)) +- *(tunnel)* managed tunnels target wrong port and die from SIGPIPE ([#1093](https://github.com/nearai/ironclaw/pull/1093)) +- *(agent)* persist /model selection to .env, TOML, and DB ([#1581](https://github.com/nearai/ironclaw/pull/1581)) +- post-merge review sweep — 8 fixes across security, perf, and correctness ([#1550](https://github.com/nearai/ironclaw/pull/1550)) +- generate Mistral-compatible 9-char alphanumeric tool call IDs ([#1242](https://github.com/nearai/ironclaw/pull/1242)) +- *(mcp)* handle empty 202 notification acknowledgements ([#1539](https://github.com/nearai/ironclaw/pull/1539)) +- *(tests)* eliminate env mutex poison cascade ([#1558](https://github.com/nearai/ironclaw/pull/1558)) +- *(safety)* escape tool output XML content and remove misleading sanitized attr ([#1067](https://github.com/nearai/ironclaw/pull/1067)) +- *(oauth)* reject malformed ic2.* states in decode_hosted_oauth_state ([#1441](https://github.com/nearai/ironclaw/pull/1441)) ([#1454](https://github.com/nearai/ironclaw/pull/1454)) +- parameter coercion and validation for oneOf/anyOf/allOf schemas ([#1397](https://github.com/nearai/ironclaw/pull/1397)) +- persist startup-loaded MCP clients in ExtensionManager ([#1509](https://github.com/nearai/ironclaw/pull/1509)) +- *(deps)* patch rustls-webpki vulnerability (RUSTSEC-2026-0049) +- *(routines)* add missing extension_manager field in trigger_manual EngineContext +- *(ci)* serialize env-mutating OAuth wildcard tests with ENV_MUTEX ([#1280](https://github.com/nearai/ironclaw/pull/1280)) ([#1468](https://github.com/nearai/ironclaw/pull/1468)) +- *(setup)* remove redundant LLM config and API keys from bootstrap .env ([#1448](https://github.com/nearai/ironclaw/pull/1448)) +- resolve wasm broadcast merge conflicts with staging ([#395](https://github.com/nearai/ironclaw/pull/395)) ([#1460](https://github.com/nearai/ironclaw/pull/1460)) +- skip credential validation for Bedrock backend ([#1011](https://github.com/nearai/ironclaw/pull/1011)) +- register sandbox jobs in ContextManager for query tool visibility ([#1426](https://github.com/nearai/ironclaw/pull/1426)) +- prefer execution-local message routing metadata ([#1449](https://github.com/nearai/ironclaw/pull/1449)) +- *(security)* validate embedding base URLs to prevent SSRF ([#1221](https://github.com/nearai/ironclaw/pull/1221)) +- f32→f64 precision artifact in temperature causes provider 400 errors ([#1450](https://github.com/nearai/ironclaw/pull/1450)) +- *(routines)* surface errors when sandbox unavailable for full_job routines ([#769](https://github.com/nearai/ironclaw/pull/769)) +- restore libSQL vector search with dynamic dimensions ([#1393](https://github.com/nearai/ironclaw/pull/1393)) +- staging CI triage — consolidate retry parsing, fix flaky tests, add docs ([#1427](https://github.com/nearai/ironclaw/pull/1427)) + +### Other + +- Merge branch 'main' into staging-promote/455f543b-23329172268 +- Merge pull request #1655 from nearai/codex/fix-staging-promotion-1451-version-bumps +- Merge pull request #1499 from nearai/staging-promote/9603fefd-23364438978 +- Fix libsql prompt scope regressions ([#1651](https://github.com/nearai/ironclaw/pull/1651)) +- Normalize cron schedules on routine create ([#1648](https://github.com/nearai/ironclaw/pull/1648)) +- Fix MCP lifecycle trace user scope ([#1646](https://github.com/nearai/ironclaw/pull/1646)) +- Fix REPL single-message hang and cap CI test duration ([#1643](https://github.com/nearai/ironclaw/pull/1643)) +- extract AppEvent to crates/ironclaw_common ([#1615](https://github.com/nearai/ironclaw/pull/1615)) +- Fix hosted OAuth refresh via proxy ([#1602](https://github.com/nearai/ironclaw/pull/1602)) +- *(agent)* optimize approval thread resolution (UUID parsing + lock contention) ([#1592](https://github.com/nearai/ironclaw/pull/1592)) +- *(tools)* auto-compact WASM tool schemas, add descriptions, improve credential prompts ([#1525](https://github.com/nearai/ironclaw/pull/1525)) +- Default new lightweight routines to tools-enabled ([#1573](https://github.com/nearai/ironclaw/pull/1573)) +- Google OAuth URL broken when initiated from Telegram channel ([#1165](https://github.com/nearai/ironclaw/pull/1165)) +- add gitcgr code graph badge ([#1563](https://github.com/nearai/ironclaw/pull/1563)) +- Fix owner-scoped message routing fallbacks ([#1574](https://github.com/nearai/ironclaw/pull/1574)) +- *(tools)* remove unconditional params clone in shared execution (fix #893) ([#926](https://github.com/nearai/ironclaw/pull/926)) +- *(llm)* move transcription module into src/llm/ ([#1559](https://github.com/nearai/ironclaw/pull/1559)) +- *(agent)* avoid preview allocations for non-truncated strings (fix #894) ([#924](https://github.com/nearai/ironclaw/pull/924)) +- Expand AGENTS.md with coding agents guidance ([#1392](https://github.com/nearai/ironclaw/pull/1392)) +- Fix CI approval flows and stale fixtures ([#1478](https://github.com/nearai/ironclaw/pull/1478)) +- Use live owner tool scope for autonomous routines and jobs ([#1453](https://github.com/nearai/ironclaw/pull/1453)) +- use Arc in embedding cache to avoid clones on miss path ([#1438](https://github.com/nearai/ironclaw/pull/1438)) +- Add owner-scoped permissions for full-job routines ([#1440](https://github.com/nearai/ironclaw/pull/1440)) + +## [0.21.0](https://github.com/nearai/ironclaw/compare/v0.20.0...v0.21.0) - 2026-03-20 + +### Added + +- structured fallback deliverables for failed/stuck jobs ([#236](https://github.com/nearai/ironclaw/pull/236)) +- LRU embedding cache for workspace search ([#1423](https://github.com/nearai/ironclaw/pull/1423)) +- receive relay events via webhook callbacks ([#1254](https://github.com/nearai/ironclaw/pull/1254)) + +### Fixed + +- bump Feishu channel version for promotion +- *(approval)* make "always" auto-approve work for credentialed HTTP requests ([#1257](https://github.com/nearai/ironclaw/pull/1257)) +- skip NEAR AI session check when backend is not nearai ([#1413](https://github.com/nearai/ironclaw/pull/1413)) + +### Other + +- Make hosted OAuth and MCP auth generic ([#1375](https://github.com/nearai/ironclaw/pull/1375)) + +## [0.20.0](https://github.com/nearai/ironclaw/compare/v0.19.0...v0.20.0) - 2026-03-19 + +### Added + +- *(self-repair)* wire stuck_threshold, store, and builder ([#712](https://github.com/nearai/ironclaw/pull/712)) +- *(testing)* add FaultInjector framework for StubLlm ([#1233](https://github.com/nearai/ironclaw/pull/1233)) +- *(gateway)* unified settings page with subtabs ([#1191](https://github.com/nearai/ironclaw/pull/1191)) +- upgrade MiniMax default model to M2.7 ([#1357](https://github.com/nearai/ironclaw/pull/1357)) + +### Fixed + +- navigate telegram E2E tests to channels subtab ([#1408](https://github.com/nearai/ironclaw/pull/1408)) +- add missing `builder` field and update E2E extensions tab navigation ([#1400](https://github.com/nearai/ironclaw/pull/1400)) +- remove debug_assert guards that panic on valid error paths ([#1385](https://github.com/nearai/ironclaw/pull/1385)) +- address valid review comments from PR #1359 ([#1380](https://github.com/nearai/ironclaw/pull/1380)) +- full_job routine runs stay running until linked job completion ([#1374](https://github.com/nearai/ironclaw/pull/1374)) +- full_job routine concurrency tracks linked job lifetime ([#1372](https://github.com/nearai/ironclaw/pull/1372)) +- remove -x from coverage pytest to prevent suite-blocking failures ([#1360](https://github.com/nearai/ironclaw/pull/1360)) +- add debug_assert invariant guards to critical code paths ([#1312](https://github.com/nearai/ironclaw/pull/1312)) +- *(mcp)* retry after missing session id errors ([#1355](https://github.com/nearai/ironclaw/pull/1355)) +- *(telegram)* preserve polling after secret-blocked updates ([#1353](https://github.com/nearai/ironclaw/pull/1353)) +- *(llm)* cap retry-after delays ([#1351](https://github.com/nearai/ironclaw/pull/1351)) +- *(setup)* remove nonexistent webhook secret command hint ([#1349](https://github.com/nearai/ironclaw/pull/1349)) +- Rate limiter returns retry after None instead of a duration ([#1269](https://github.com/nearai/ironclaw/pull/1269)) + +### Other + +- bump telegram channel version to 0.2.5 ([#1410](https://github.com/nearai/ironclaw/pull/1410)) +- *(ci)* enforce test requirement for state machine and resilience changes ([#1230](https://github.com/nearai/ironclaw/pull/1230)) ([#1304](https://github.com/nearai/ironclaw/pull/1304)) +- Fix duplicate LLM responses for matched event routines ([#1275](https://github.com/nearai/ironclaw/pull/1275)) +- add Japanese README ([#1306](https://github.com/nearai/ironclaw/pull/1306)) +- *(ci)* add coverage gates via codecov.yml ([#1228](https://github.com/nearai/ironclaw/pull/1228)) ([#1291](https://github.com/nearai/ironclaw/pull/1291)) +- Redesign routine create requests for LLMs ([#1147](https://github.com/nearai/ironclaw/pull/1147)) + ## [0.19.0](https://github.com/nearai/ironclaw/compare/v0.18.0...v0.19.0) - 2026-03-17 ### Added diff --git a/Cargo.lock b/Cargo.lock index 27c258c1c0..c374759079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3390,7 +3390,7 @@ dependencies = [ [[package]] name = "ironclaw" -version = "0.19.0" +version = "0.22.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -3496,7 +3496,7 @@ dependencies = [ [[package]] name = "ironclaw_safety" -version = "0.1.0" +version = "0.2.0" dependencies = [ "aho-corasick", "regex", @@ -5481,7 +5481,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6388,7 +6388,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 395e42d3e0..41895b16ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ exclude = [ [package] name = "ironclaw" -version = "0.19.0" +version = "0.22.0" edition = "2024" rust-version = "1.92" description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly" @@ -104,7 +104,7 @@ cron = "0.13" ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" } # Safety/sanitization -ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.1.0" } +ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" } regex = "1" aho-corasick = "1" diff --git a/crates/ironclaw_common/Cargo.toml b/crates/ironclaw_common/Cargo.toml index 353ab747fb..6e7db5a42d 100644 --- a/crates/ironclaw_common/Cargo.toml +++ b/crates/ironclaw_common/Cargo.toml @@ -8,7 +8,6 @@ authors = ["NEAR AI "] license = "MIT OR Apache-2.0" homepage = "https://github.com/nearai/ironclaw" repository = "https://github.com/nearai/ironclaw" -publish = false [package.metadata.dist] dist = false diff --git a/crates/ironclaw_safety/Cargo.toml b/crates/ironclaw_safety/Cargo.toml index d12aa90930..38b8718ac9 100644 --- a/crates/ironclaw_safety/Cargo.toml +++ b/crates/ironclaw_safety/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ironclaw_safety" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.92" description = "Prompt injection defense, input validation, secret leak detection, and safety policy enforcement" @@ -8,7 +8,6 @@ authors = ["NEAR AI "] license = "MIT OR Apache-2.0" homepage = "https://github.com/nearai/ironclaw" repository = "https://github.com/nearai/ironclaw" -publish = false [package.metadata.dist] dist = false diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 66cecf1dd2..a75309437d 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -2,7 +2,7 @@ "name": "feishu", "display_name": "Feishu / Lark Channel", "kind": "channel", - "version": "0.1.1", + "version": "0.1.3", "wit_version": "0.3.0", "description": "Talk to your agent through a Feishu or Lark bot", "keywords": [ @@ -19,8 +19,8 @@ }, "artifacts": { "wasm32-wasip2": { - "sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714", - "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz" + "sha256": "a66ff0dafb67d2216d8161bb7e96e724a94acb0ab993b85d2782d30412f8fe94", + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/channel-feishu-0.1.3-wasm32-wasip2.tar.gz" } }, "auth_summary": { diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index 85d793edff..52f66ce306 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz", - "sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8" + "url": "https://github.com/nearai/ironclaw/releases/download/v0.20.0/channel-telegram-0.2.5-wasm32-wasip2.tar.gz", + "sha256": "1ef20a538f55b379e049356e4d6758006251846bc3365ceaa1c87eba8379a329" } }, "auth_summary": { diff --git a/registry/tools/github.json b/registry/tools/github.json index e760c4df0a..bb35125960 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -2,7 +2,7 @@ "name": "github", "display_name": "GitHub", "kind": "tool", - "version": "0.2.1", + "version": "0.2.2", "wit_version": "0.3.0", "description": "GitHub integration for issues, PRs, repos, and code search", "keywords": [ @@ -19,8 +19,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-github-0.2.1-wasm32-wasip2.tar.gz", - "sha256": "92c530b3ad172e2372d819744b5233f1d8f65768e26eb5a6c213eba3ce1de758" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-github-0.2.2-wasm32-wasip2.tar.gz", + "sha256": "70b55af593193d8fa495c0f702ea23284d83a624124f8a5f7564916ec5032c3f" } }, "auth_summary": { diff --git a/registry/tools/gmail.json b/registry/tools/gmail.json index 08913ce697..c4772129c5 100644 --- a/registry/tools/gmail.json +++ b/registry/tools/gmail.json @@ -2,7 +2,7 @@ "name": "gmail", "display_name": "Gmail", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Read, send, and manage Gmail messages and threads", "keywords": [ @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/gmail-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "ee9574e02e92bc1d481f1310eb88afd99ee52bf6971074ab33bd76bf99b34b1d" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-gmail-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "79025b40ee70ce1120acc4320bae50da095d7afb0ef67bd56d99b064b72ea779" } }, "auth_summary": { diff --git a/registry/tools/google-calendar.json b/registry/tools/google-calendar.json index c43112d33b..73065a6705 100644 --- a/registry/tools/google-calendar.json +++ b/registry/tools/google-calendar.json @@ -2,7 +2,7 @@ "name": "google-calendar", "display_name": "Google Calendar", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Create, read, update, and delete Google Calendar events", "keywords": [ @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-calendar-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "2fa47150ea222e787c122182ad6f4dfa2ffaf5fe490d05e8de887a76445f8d2d" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-calendar-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "86bcc075010b08f5ab2f98f504cec1c6c9e0ca144857d185cbecf72a11f504bf" } }, "auth_summary": { diff --git a/registry/tools/google-docs.json b/registry/tools/google-docs.json index 9f1ab133f0..02cc94fe0b 100644 --- a/registry/tools/google-docs.json +++ b/registry/tools/google-docs.json @@ -2,7 +2,7 @@ "name": "google-docs", "display_name": "Google Docs", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Create and edit Google Docs documents", "keywords": [ @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-docs-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "40e134a1c1564f832ca861c3396895d4e33ec67b99313fc1f97baf8d971423a9" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-docs-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "39d476029764949498a53a6a223f9952b5f4df151be7b8b19bf3fe4d401a57cd" } }, "auth_summary": { diff --git a/registry/tools/google-drive.json b/registry/tools/google-drive.json index 9766e555d9..719690f7b3 100644 --- a/registry/tools/google-drive.json +++ b/registry/tools/google-drive.json @@ -2,7 +2,7 @@ "name": "google-drive", "display_name": "Google Drive", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Upload, download, search, and manage Google Drive files and folders", "keywords": [ @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-drive-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "002a341a1d58125563a7c69561b26fbc2629b04ea723cade744102bdc0fbb71f" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-drive-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "6e9a700fab93865c852af718666af64c5b534ad6a419fb4b736e07740188f494" } }, "auth_summary": { diff --git a/registry/tools/google-sheets.json b/registry/tools/google-sheets.json index b63265e1c8..09aae5743e 100644 --- a/registry/tools/google-sheets.json +++ b/registry/tools/google-sheets.json @@ -2,7 +2,7 @@ "name": "google-sheets", "display_name": "Google Sheets", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Read and write Google Sheets spreadsheet data", "keywords": [ @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-sheets-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "8aa2c9d52f033edea3a6c2311b0ec694ccb6d0a54ef07e94d72bf8be1ce8009a" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-sheets-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "1f8c381799a916be83263cac9d497d52946e21b1b588592a3a42ca94a73b7051" } }, "auth_summary": { diff --git a/registry/tools/google-slides.json b/registry/tools/google-slides.json index 54187531f8..64bc0e4532 100644 --- a/registry/tools/google-slides.json +++ b/registry/tools/google-slides.json @@ -2,7 +2,7 @@ "name": "google-slides", "display_name": "Google Slides", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Create and edit Google Slides presentations", "keywords": [ @@ -17,8 +17,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.18.0/google-slides-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "e931a97d4fd0b0b938e464dc7c7f2be6ea6b4d1508f5ea3cd931d44db23f05f5" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-google-slides-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "e2528be5da02f1b8cfc8ee9b0cdd849516c53d412e2f75c6175b3bded7f512cb" } }, "auth_summary": { diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json index e4e9808c5f..422f2e1820 100644 --- a/registry/tools/llm-context.json +++ b/registry/tools/llm-context.json @@ -2,7 +2,7 @@ "name": "llm-context", "display_name": "LLM Context", "kind": "tool", - "version": "0.1.0", + "version": "0.1.1", "wit_version": "0.3.0", "description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)", "keywords": [ @@ -21,8 +21,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-llm-context-0.1.0-wasm32-wasip2.tar.gz", - "sha256": "d9ced2b1226b879135891e0ee40e072c7c95412e1b2462925a23853e1f92497e" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-llm-context-0.1.1-wasm32-wasip2.tar.gz", + "sha256": "9b19e2fd05dbbbe3c8bd55309a91db09124e8415eb0f767828b6e10b55771e63" } }, "auth_summary": { diff --git a/registry/tools/slack.json b/registry/tools/slack.json index 8e1df98968..236062a406 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -2,7 +2,7 @@ "name": "slack-tool", "display_name": "Slack Tool", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Your agent uses Slack to post and read messages in your workspace", "keywords": [ @@ -17,8 +17,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-slack-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "ccfb0415d7a04f9497726c712d15216de36e86f498b849101283c017f5ab4efb" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-slack-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "927519e5b7734beeb022d3b8bbd152e0e6b9f67c9452a8ad47809d3c4221a137" } }, "auth_summary": { diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index 12e58c684d..e684ca94d4 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -2,7 +2,7 @@ "name": "telegram-mtproto", "display_name": "Telegram Tool", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Your agent uses your Telegram account to read and send messages", "keywords": [ @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-telegram-0.2.0-wasm32-wasip2.tar.gz", - "sha256": "c17065ca41fae5f2a7c43b36144686718cd310a2f22442313bb1aa82bbad0ae4" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-telegram-0.2.1-wasm32-wasip2.tar.gz", + "sha256": "1e57d0755fc9c7b3ec013d079f30168898b484a6919f9edd105f0cd80131c1cd" } }, "auth_summary": { diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 5c1dedefde..014466afdd 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -2,7 +2,7 @@ "name": "web-search", "display_name": "Web Search", "kind": "tool", - "version": "0.2.1", + "version": "0.2.2", "wit_version": "0.3.0", "description": "Search the web using Brave Search API", "keywords": [ @@ -18,8 +18,8 @@ }, "artifacts": { "wasm32-wasip2": { - "url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/tool-web-search-0.2.1-wasm32-wasip2.tar.gz", - "sha256": "bad275ca4ec314adea5241d6b92c44ccf9cebcbca8e30ba2493cc0bcb4b57218" + "url": "https://github.com/nearai/ironclaw/releases/download/ironclaw-v0.22.0/tool-web-search-0.2.2-wasm32-wasip2.tar.gz", + "sha256": "47382b50c1ea7525b20d59dc02fab04e336d018665826c2f24710bdf460779ae" } }, "auth_summary": { diff --git a/release-plz.toml b/release-plz.toml index b003952dd3..e8e0670fce 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -1,7 +1,2 @@ [workspace] git_release_enable = false - -[[package]] -name = "ironclaw_safety" -publish = false -release = false diff --git a/src/agent/agentic_loop.rs b/src/agent/agentic_loop.rs index e61856dc8f..27c2ab726a 100644 --- a/src/agent/agentic_loop.rs +++ b/src/agent/agentic_loop.rs @@ -10,7 +10,7 @@ use std::borrow::Cow; use crate::agent::session::PendingApproval; use crate::error::Error; -use crate::llm::{ChatMessage, Reasoning, ReasoningContext, RespondResult}; +use crate::llm::{ChatMessage, FinishReason, Reasoning, ReasoningContext, RespondResult}; /// Signal from the delegate indicating how the loop should proceed. pub enum LoopSignal { @@ -134,6 +134,9 @@ pub async fn run_agentic_loop( config: &AgenticLoopConfig, ) -> Result { let mut consecutive_tool_intent_nudges: u32 = 0; + // Accumulates across all iterations (not reset by text responses) so + // non-consecutive truncations still escalate to force_text. + let mut truncation_count: u32 = 0; for iteration in 1..=config.max_iterations { // Check for external signals (stop, cancellation, user messages) @@ -215,7 +218,35 @@ pub async fn run_agentic_loop( tool_calls, content, } => { + // If the response was truncated, tool call parameters are likely + // incomplete. Discard them and tell the LLM to try a different + // approach rather than executing malformed tool calls. + if output.finish_reason == FinishReason::Length { + truncation_count += 1; + let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect(); + tracing::warn!( + iteration, + tools = ?names, + truncation_count, + "Discarding truncated tool calls (finish_reason=Length)" + ); + if let Some(ref text) = content { + reason_ctx.messages.push(ChatMessage::assistant(text)); + } + reason_ctx + .messages + .push(ChatMessage::user(crate::llm::TRUNCATED_TOOL_CALL_NOTICE)); + // After repeated truncations, force text-only mode so the LLM + // stops attempting tool calls it can't fit in the output budget. + if truncation_count >= 3 { + reason_ctx.force_text = true; + } + delegate.after_iteration(iteration).await; + continue; + } + consecutive_tool_intent_nudges = 0; + truncation_count = 0; if let Some(outcome) = delegate .execute_tool_calls(tool_calls, content, reason_ctx) @@ -271,6 +302,7 @@ mod tests { RespondOutput { result: RespondResult::Text(text.to_string()), usage: zero_usage(), + finish_reason: FinishReason::Stop, } } @@ -281,6 +313,7 @@ mod tests { content: None, }, usage: zero_usage(), + finish_reason: FinishReason::ToolUse, } } @@ -622,4 +655,95 @@ mod tests { let result = truncate_for_preview("café", 4); assert_eq!(result, "caf..."); } + + #[tokio::test] + async fn test_truncated_tool_calls_discarded_on_length() { + let truncated_tool_call = ToolCall { + id: "call_1".to_string(), + name: "memory_write".to_string(), + arguments: serde_json::json!({}), // empty — truncated + reasoning: None, + }; + let truncated_output = RespondOutput { + result: RespondResult::ToolCalls { + tool_calls: vec![truncated_tool_call], + content: Some("I'll write the report.".to_string()), + }, + usage: zero_usage(), + finish_reason: FinishReason::Length, // response was truncated + }; + let delegate = MockDelegate::new(vec![truncated_output, text_output("Summarized it.")]); + let reasoning = stub_reasoning(); + let mut ctx = ReasoningContext::new(); + let config = AgenticLoopConfig { + max_iterations: 5, + ..Default::default() + }; + + let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config) + .await + .unwrap(); + + // Tool calls should NOT have been executed + assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 0); + // The loop should have continued and returned the text response + assert!(matches!(outcome, LoopOutcome::Response(ref t) if t == "Summarized it.")); + // A truncation notice should have been injected into context + assert!( + ctx.messages + .iter() + .any(|m| m.role == crate::llm::Role::User && m.content.contains("truncated")), + "Should inject truncation notice into context" + ); + // The partial assistant content should have been preserved + assert!( + ctx.messages + .iter() + .any(|m| m.role == crate::llm::Role::Assistant + && m.content.contains("write the report")), + "Should preserve partial assistant content" + ); + } + + #[tokio::test] + async fn test_repeated_truncations_force_text_mode() { + let make_truncated = || RespondOutput { + result: RespondResult::ToolCalls { + tool_calls: vec![ToolCall { + id: "call_1".to_string(), + name: "memory_write".to_string(), + arguments: serde_json::json!({}), + reasoning: None, + }], + content: None, + }, + usage: zero_usage(), + finish_reason: FinishReason::Length, + }; + // Three truncated responses, then a text response + let delegate = MockDelegate::new(vec![ + make_truncated(), + make_truncated(), + make_truncated(), + text_output("Gave up on tool calls."), + ]); + let reasoning = stub_reasoning(); + let mut ctx = ReasoningContext::new(); + let config = AgenticLoopConfig { + max_iterations: 5, + ..Default::default() + }; + + let outcome = run_agentic_loop(&delegate, &reasoning, &mut ctx, &config) + .await + .unwrap(); + + assert!(matches!(outcome, LoopOutcome::Response(_))); + assert_eq!(delegate.tool_exec_count.load(Ordering::SeqCst), 0); + // After 3 truncations, force_text should be set + assert!( + ctx.force_text, + "Should escalate to force_text after repeated truncations" + ); + } } diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 96bca19750..a5f9cd6fb1 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -306,6 +306,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> { // Update context for this iteration reason_ctx.available_tools = tool_defs; + // Preserve force_text if already set (e.g. by truncation escalation). + let force_text = force_text || reason_ctx.force_text; reason_ctx.system_prompt = Some(if force_text { self.cached_prompt_no_tools.clone() } else { diff --git a/src/channels/relay/client.rs b/src/channels/relay/client.rs index 81fbb56c93..b67f2c5ea7 100644 --- a/src/channels/relay/client.rs +++ b/src/channels/relay/client.rs @@ -122,18 +122,32 @@ impl RelayClient { /// instance_url in chat-api. IronClaw only passes an optional CSRF nonce /// for validating the callback — no URLs. pub async fn initiate_oauth(&self, state_nonce: Option<&str>) -> Result { + let url = format!("{}/oauth/slack/auth", self.base_url); + tracing::debug!(relay_url = %url, "RelayClient::initiate_oauth: sending request"); let mut query: Vec<(&str, &str)> = vec![]; if let Some(nonce) = state_nonce { query.push(("state_nonce", nonce)); } let resp = self .http - .get(format!("{}/oauth/slack/auth", self.base_url)) + .get(&url) .bearer_auth(self.api_key.expose_secret()) .query(&query) .send() .await - .map_err(|e| RelayError::Network(e.to_string()))?; + .map_err(|e| { + tracing::warn!( + relay_url = %url, + error = %e, + "RelayClient::initiate_oauth: network request failed" + ); + RelayError::Network(e.to_string()) + })?; + tracing::debug!( + relay_url = %url, + status = %resp.status(), + "RelayClient::initiate_oauth: received response" + ); let status = resp.status(); if status.is_redirection() { @@ -224,20 +238,39 @@ impl RelayClient { method: &str, body: serde_json::Value, ) -> Result { + let url = format!("{}/proxy/{}/{}", self.base_url, provider, method); + tracing::debug!( + relay_url = %url, + provider = %provider, + method = %method, + "RelayClient::proxy_provider: sending request" + ); let query: Vec<(&str, &str)> = vec![("team_id", team_id)]; let resp = self .http - .post(format!("{}/proxy/{}/{}", self.base_url, provider, method)) + .post(&url) .bearer_auth(self.api_key.expose_secret()) .query(&query) .json(&body) .send() .await - .map_err(|e| RelayError::Network(e.to_string()))?; + .map_err(|e| { + tracing::warn!( + relay_url = %url, + error = %e, + "RelayClient::proxy_provider: network request failed" + ); + RelayError::Network(e.to_string()) + })?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); + tracing::warn!( + relay_url = %url, + status = status, + "RelayClient::proxy_provider: channel-relay returned error" + ); return Err(RelayError::Api { status, message: body, @@ -255,23 +288,45 @@ impl RelayClient { /// 32-byte secret. Called once at activation time; the result is cached in the /// extension manager so subsequent calls to `relay_signing_secret()` use it. pub async fn get_signing_secret(&self, team_id: &str) -> Result, RelayError> { + let url = format!("{}/relay/signing-secret", self.base_url); + tracing::debug!( + relay_url = %url, + "RelayClient::get_signing_secret: fetching signing secret" + ); let resp = self .http - .get(format!("{}/relay/signing-secret", self.base_url)) + .get(&url) .bearer_auth(self.api_key.expose_secret()) .query(&[("team_id", team_id)]) .send() .await - .map_err(|e| RelayError::Network(e.to_string()))?; + .map_err(|e| { + tracing::warn!( + relay_url = %url, + error = %e, + "RelayClient::get_signing_secret: network request failed" + ); + RelayError::Network(e.to_string()) + })?; if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); + tracing::warn!( + relay_url = %url, + status = status, + body = %body, + "RelayClient::get_signing_secret: channel-relay returned error" + ); return Err(RelayError::Api { status, message: body, }); } + tracing::debug!( + relay_url = %url, + "RelayClient::get_signing_secret: received successful response" + ); let body: serde_json::Value = resp .json() diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 4bf4de3702..26c005d406 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -1177,11 +1177,31 @@ async fn slack_relay_oauth_callback_handler( // Store team_id in settings let team_id_key = format!("relay:{}:team_id", DEFAULT_RELAY_NAME); - let _ = store + tracing::info!( + relay = DEFAULT_RELAY_NAME, + owner_id = %state.owner_id, + team_id_key = %team_id_key, + "relay OAuth callback: storing team_id in settings" + ); + store .set_setting(&state.owner_id, &team_id_key, &serde_json::json!(team_id)) - .await; + .await + .map_err(|e| { + tracing::error!( + relay = DEFAULT_RELAY_NAME, + owner_id = %state.owner_id, + error = %e, + "relay OAuth callback: failed to persist team_id to settings store" + ); + format!("Failed to persist relay team_id: {e}") + })?; // Activate the relay channel + tracing::info!( + relay = DEFAULT_RELAY_NAME, + owner_id = %state.owner_id, + "relay OAuth callback: activating relay channel" + ); ext_mgr .activate_stored_relay(DEFAULT_RELAY_NAME, &state.owner_id) .await @@ -2181,6 +2201,11 @@ async fn extensions_activate_handler( AuthenticatedUser(user): AuthenticatedUser, Path(name): Path, ) -> Result, (StatusCode, String)> { + tracing::debug!( + extension = %name, + user_id = %user.user_id, + "extensions_activate_handler: received activate request" + ); let ext_mgr = state.extension_manager.as_ref().ok_or(( StatusCode::NOT_IMPLEMENTED, "Extension manager not available (secrets store required)".to_string(), @@ -2188,6 +2213,10 @@ async fn extensions_activate_handler( match ext_mgr.activate(&name, &user.user_id).await { Ok(result) => { + tracing::info!( + extension = %name, + "extensions_activate_handler: activation succeeded" + ); // Activation loaded the WASM module. Check if the tool needs // OAuth scope expansion (e.g., adding google-docs when gmail // already has a token but missing the documents scope). @@ -2206,6 +2235,13 @@ async fn extensions_activate_handler( crate::extensions::ExtensionError::AuthRequired ); + tracing::debug!( + extension = %name, + error = %activate_err, + needs_auth = needs_auth, + "extensions_activate_handler: activation failed, attempting auth fallback" + ); + if !needs_auth { return Ok(Json(ActionResponse::fail(activate_err.to_string()))); } @@ -2213,10 +2249,21 @@ async fn extensions_activate_handler( // Activation failed due to auth; try authenticating first. match ext_mgr.auth(&name, &user.user_id).await { Ok(auth_result) if auth_result.is_authenticated() => { + tracing::debug!( + extension = %name, + "extensions_activate_handler: auth reports authenticated, retrying activate" + ); // Auth succeeded, retry activation. match ext_mgr.activate(&name, &user.user_id).await { Ok(result) => Ok(Json(ActionResponse::ok(result.message))), - Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), + Err(e) => { + tracing::warn!( + extension = %name, + error = %e, + "extensions_activate_handler: retry after auth still failed" + ); + Ok(Json(ActionResponse::fail(e.to_string()))) + } } } Ok(auth_result) => { diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 9092076738..47b45a0fbd 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -659,6 +659,66 @@ impl ExtensionManager { }) } + /// Resolve the relay URL override for an extension from settings. + /// + /// Returns `Some(url)` if a non-empty per-extension `relay_url` override is + /// set for the given extension; otherwise returns `None` and callers should + /// fall back to the env-level `RelayConfig`. + /// + /// Uses `self.user_id` (owner scope) for consistency with `configure()`, + /// which also writes setting_path fields under the owner scope. + /// + /// The override is validated: only `http` / `https` schemes are accepted + /// and the URL must not contain userinfo (embedded credentials). This + /// prevents a malicious override from exfiltrating the instance-wide relay + /// API key to an attacker-controlled host. + async fn effective_relay_url(&self, name: &str) -> Option { + if let Some(ref store) = self.store { + let key = format!("extensions.{name}.relay_url"); + if let Ok(Some(v)) = store.get_setting(&self.user_id, &key).await { + let url = v + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(ref u) = url { + // Validate the override to prevent API-key exfiltration: + // only allow http(s) with no embedded credentials. + match url::Url::parse(u) { + Ok(parsed) + if (parsed.scheme() == "http" || parsed.scheme() == "https") + && parsed.username().is_empty() + && parsed.password().is_none() => + { + tracing::debug!( + extension = %name, + relay_url_host = %parsed.host_str().unwrap_or("unknown"), + "effective_relay_url: using per-extension override from settings" + ); + return url; + } + Ok(parsed) => { + tracing::warn!( + extension = %name, + scheme = %parsed.scheme(), + has_userinfo = !parsed.username().is_empty() || parsed.password().is_some(), + "effective_relay_url: rejecting override — \ + only http/https without embedded credentials is allowed" + ); + } + Err(e) => { + tracing::warn!( + extension = %name, + error = %e, + "effective_relay_url: rejecting override — invalid URL" + ); + } + } + } + } + } + None + } + /// Get the shared relay event sender for the webhook endpoint. pub fn relay_event_tx( &self, @@ -892,6 +952,46 @@ impl ExtensionManager { false } + /// Check whether a stored `team_id` setting exists for the given relay extension. + /// + /// Unlike [`is_relay_channel`], this does **not** consult the in-memory + /// `installed_relay_extensions` set — it only looks at the persistent settings + /// store. This distinction matters for `auth_channel_relay`: an extension can + /// be *installed* (present in the in-memory set) but not yet *authenticated* + /// (no OAuth completed, no team_id stored). + async fn has_stored_team_id(&self, name: &str, _user_id: &str) -> bool { + if let Some(ref store) = self.store { + let key = format!("relay:{}:team_id", name); + // Use owner scope (self.user_id) for consistency: the OAuth callback + // stores team_id under state.owner_id which maps to self.user_id. + match store.get_setting(&self.user_id, &key).await { + Ok(Some(v)) => { + let has_id = v.as_str().is_some_and(|s| !s.is_empty()); + tracing::debug!( + extension = %name, + has_team_id = has_id, + "has_stored_team_id: checked store" + ); + return has_id; + } + Ok(None) => { + tracing::debug!( + extension = %name, + "has_stored_team_id: no team_id setting found" + ); + } + Err(e) => { + tracing::warn!( + extension = %name, + error = %e, + "has_stored_team_id: failed to read from settings store" + ); + } + } + } + false + } + /// Restore persisted relay channels after startup. /// /// Loads the persisted active channel list, filters to relay types (those with @@ -1418,7 +1518,7 @@ impl ExtensionManager { let errors = self.activation_errors.read().await; for name in installed.iter() { let active = active_names.contains(name); - let authenticated = self.is_relay_channel(name, user_id).await; + let authenticated = self.has_stored_team_id(name, user_id).await; let activation_error = errors.get(name).cloned(); let registry_entry = self .registry @@ -4191,20 +4291,69 @@ impl ExtensionManager { name: &str, user_id: &str, ) -> Result { - // Check if already authenticated (team_id setting exists) - if self.is_relay_channel(name, user_id).await { + tracing::debug!( + extension = %name, + user_id = %user_id, + "auth_channel_relay: starting" + ); + + // Check if already authenticated by looking for a stored team_id. + // We intentionally skip the `installed_relay_extensions` in-memory set + // here because that set only tracks *installed* extensions — an extension + // can be installed (via registry) but not yet authenticated (no OAuth + // completed). Checking just `is_relay_channel()` would short-circuit + // to "authenticated" even when no team_id exists, preventing the OAuth + // flow from being offered to the user. + if self.has_stored_team_id(name, user_id).await { + tracing::debug!( + extension = %name, + "auth_channel_relay: already authenticated (team_id in store)" + ); return Ok(AuthResult::authenticated(name, ExtensionKind::ChannelRelay)); } + tracing::debug!( + extension = %name, + "auth_channel_relay: no stored team_id, initiating OAuth" + ); + // Use relay config captured at startup - let relay_config = self.relay_config()?; + let relay_config = self.relay_config().map_err(|e| { + tracing::warn!( + extension = %name, + error = %e, + "auth_channel_relay: relay config not available — \ + CHANNEL_RELAY_URL and CHANNEL_RELAY_API_KEY must be set" + ); + e + })?; + + // Allow per-extension URL override from settings + let effective_url = self + .effective_relay_url(name) + .await + .unwrap_or_else(|| relay_config.url.clone()); + + tracing::debug!( + extension = %name, + relay_url = %effective_url, + "auth_channel_relay: creating relay client for OAuth" + ); let client = crate::channels::relay::RelayClient::new( - relay_config.url.clone(), + effective_url.clone(), relay_config.api_key.clone(), relay_config.request_timeout_secs, ) - .map_err(|e| ExtensionError::Config(e.to_string()))?; + .map_err(|e| { + tracing::warn!( + extension = %name, + relay_url = %effective_url, + error = %e, + "auth_channel_relay: failed to create relay HTTP client" + ); + ExtensionError::Config(e.to_string()) + })?; // Generate CSRF nonce — IronClaw validates this on the callback to ensure // the OAuth completion is legitimate. Channel-relay embeds it in the signed @@ -4216,18 +4365,44 @@ impl ExtensionManager { self.secrets .create(user_id, CreateSecretParams::new(&state_key, &state_nonce)) .await - .map_err(|e| ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")))?; + .map_err(|e| { + tracing::warn!( + extension = %name, + error = %e, + "auth_channel_relay: failed to store OAuth state nonce" + ); + ExtensionError::AuthFailed(format!("Failed to store OAuth state: {e}")) + })?; // Channel-relay derives all URLs from trusted instance_url in chat-api. // We only pass the nonce for CSRF validation on the callback. + tracing::debug!( + extension = %name, + relay_url = %effective_url, + "auth_channel_relay: calling initiate_oauth on channel-relay" + ); match client.initiate_oauth(Some(&state_nonce)).await { - Ok(auth_url) => Ok(AuthResult::awaiting_authorization( - name, - ExtensionKind::ChannelRelay, - auth_url, - "redirect".to_string(), - )), - Err(e) => Err(ExtensionError::AuthFailed(e.to_string())), + Ok(auth_url) => { + tracing::info!( + extension = %name, + "auth_channel_relay: OAuth URL obtained, awaiting user authorization" + ); + Ok(AuthResult::awaiting_authorization( + name, + ExtensionKind::ChannelRelay, + auth_url, + "redirect".to_string(), + )) + } + Err(e) => { + tracing::warn!( + extension = %name, + relay_url = %effective_url, + error = %e, + "auth_channel_relay: initiate_oauth call to channel-relay failed" + ); + Err(ExtensionError::AuthFailed(e.to_string())) + } } } @@ -4237,40 +4412,112 @@ impl ExtensionManager { name: &str, user_id: &str, ) -> Result { + tracing::debug!( + extension = %name, + user_id = %user_id, + "activate_channel_relay: starting" + ); + let team_id_key = format!("relay:{}:team_id", name); // Get team_id from settings (stored by the OAuth callback) let team_id = if let Some(ref store) = self.store { - store - .get_setting(user_id, &team_id_key) - .await - .ok() - .flatten() - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_default() + match store.get_setting(user_id, &team_id_key).await { + Ok(Some(v)) => { + let id = v.as_str().map(|s| s.to_string()).unwrap_or_default(); + tracing::debug!( + extension = %name, + team_id_empty = id.is_empty(), + "activate_channel_relay: loaded team_id from store" + ); + id + } + Ok(None) => { + tracing::debug!( + extension = %name, + setting_key = %team_id_key, + "activate_channel_relay: no team_id in settings store" + ); + String::new() + } + Err(e) => { + tracing::warn!( + extension = %name, + error = %e, + "activate_channel_relay: failed to read team_id from settings store" + ); + String::new() + } + } } else { + tracing::debug!( + extension = %name, + "activate_channel_relay: no settings store available" + ); String::new() }; if team_id.is_empty() { + tracing::debug!( + extension = %name, + "activate_channel_relay: team_id is empty, returning AuthRequired" + ); return Err(ExtensionError::AuthRequired); } // Use relay config captured at startup - let relay_config = self.relay_config()?; + let relay_config = self.relay_config().map_err(|e| { + tracing::warn!( + extension = %name, + error = %e, + "activate_channel_relay: relay config not available" + ); + e + })?; + + // Allow per-extension URL override from settings + let effective_url = self + .effective_relay_url(name) + .await + .unwrap_or_else(|| relay_config.url.clone()); + + tracing::debug!( + extension = %name, + relay_url = %effective_url, + "activate_channel_relay: relay config loaded" + ); let instance_id = self.relay_instance_id(relay_config, user_id); let client = crate::channels::relay::RelayClient::new( - relay_config.url.clone(), + effective_url.clone(), relay_config.api_key.clone(), relay_config.request_timeout_secs, ) - .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; + .map_err(|e| { + tracing::warn!( + extension = %name, + relay_url = %effective_url, + error = %e, + "activate_channel_relay: failed to create relay HTTP client" + ); + ExtensionError::ActivationFailed(e.to_string()) + })?; // Fetch the per-instance signing secret from channel-relay. // This must succeed — there is no fallback. + tracing::debug!( + extension = %name, + relay_url = %effective_url, + "activate_channel_relay: fetching signing secret from channel-relay" + ); let signing_secret = client.get_signing_secret(&team_id).await.map_err(|e| { + tracing::warn!( + extension = %name, + relay_url = %effective_url, + error = %e, + "activate_channel_relay: failed to fetch signing secret from channel-relay" + ); ExtensionError::Config(format!("Failed to fetch relay signing secret: {e}")) })?; @@ -4289,16 +4536,29 @@ impl ExtensionManager { // Hot-add to channel manager let cm_guard = self.relay_channel_manager.read().await; let channel_mgr = cm_guard.as_ref().ok_or_else(|| { + tracing::warn!( + extension = %name, + "activate_channel_relay: channel manager not initialized" + ); ExtensionError::ActivationFailed("Channel manager not initialized".to_string()) })?; - channel_mgr - .hot_add(Box::new(channel)) - .await - .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; + channel_mgr.hot_add(Box::new(channel)).await.map_err(|e| { + tracing::warn!( + extension = %name, + error = %e, + "activate_channel_relay: hot_add to channel manager failed" + ); + ExtensionError::ActivationFailed(e.to_string()) + })?; if let Ok(mut cache) = self.relay_signing_secret_cache.lock() { *cache = Some(signing_secret); + } else { + tracing::warn!( + extension = %name, + "activate_channel_relay: failed to cache signing secret (mutex poisoned)" + ); } // Store the event sender so the web gateway's relay webhook endpoint can push events @@ -4316,6 +4576,12 @@ impl ExtensionManager { self.broadcast_extension_status(name, "active", Some(&status_msg)) .await; + tracing::info!( + extension = %name, + instance_id = %instance_id, + "activate_channel_relay: relay channel activated successfully" + ); + Ok(ActivateResult { name: name.to_string(), kind: ExtensionKind::ChannelRelay, @@ -4595,6 +4861,41 @@ impl ExtensionManager { } Ok(ExtensionSetupSchema { secrets, fields }) } + ExtensionKind::ChannelRelay => { + let relay_url_key = format!("extensions.{name}.relay_url"); + let current_url = if let Some(ref store) = self.store { + match store.get_setting(&self.user_id, &relay_url_key).await { + Ok(value_opt) => value_opt + .and_then(|v| v.as_str().map(|s| s.to_string())) + .filter(|s| !s.is_empty()), + Err(e) => { + tracing::warn!( + extension = %name, + setting_key = %relay_url_key, + error = %e, + "get_setup_schema: failed to read relay_url from settings" + ); + None + } + } + } else { + None + }; + let env_url = self.relay_config.as_ref().map(|c| c.url.as_str()); + Ok(ExtensionSetupSchema { + secrets: Vec::new(), + fields: vec![crate::channels::web::types::SetupFieldInfo { + name: "relay_url".to_string(), + prompt: format!( + "Channel-relay service URL (leave empty to use env default{})", + env_url.map(|u| format!(": {u}")).unwrap_or_default() + ), + optional: true, + provided: current_url.is_some(), + input_type: crate::tools::wasm::ToolSetupFieldInputType::Text, + }], + }) + } _ => Ok(ExtensionSetupSchema { secrets: Vec::new(), fields: Vec::new(), @@ -4997,7 +5298,17 @@ impl ExtensionManager { names.insert(server.token_secret_name()); (names, Vec::new()) } - ExtensionKind::ChannelRelay => (std::collections::HashSet::new(), Vec::new()), + ExtensionKind::ChannelRelay => { + let relay_fields = vec![crate::tools::wasm::ToolFieldSetupSchema { + name: "relay_url".to_string(), + prompt: "Channel-relay service URL override".to_string(), + optional: true, + setting_path: Some(format!("extensions.{name}.relay_url")), + input_type: crate::tools::wasm::ToolSetupFieldInputType::Text, + restart_required: false, + }]; + (std::collections::HashSet::new(), relay_fields) + } }; let allowed_fields: std::collections::HashSet = @@ -5088,13 +5399,28 @@ impl ExtensionManager { ))); } let trimmed = field_value.trim(); + let field_def = setup_field_defs.get(field_name); + + // Empty value on an optional field with a setting_path: clear the + // stored override so the system reverts to the env/default value. if trimmed.is_empty() { + if let Some(def) = field_def + && def.optional + { + stored_fields.remove(field_name); + if let Some(setting_path) = &def.setting_path { + Self::validate_setup_setting_path(name, setting_path)?; + if let Some(store) = self.store.as_ref() { + let _ = store.delete_setting(&self.user_id, setting_path).await; + } + } + } continue; } stored_fields.insert(field_name.clone(), trimmed.to_string()); - if let Some(field_def) = setup_field_defs.get(field_name) { + if let Some(field_def) = field_def { if field_def.restart_required { restart_required = true; } @@ -7058,6 +7384,39 @@ mod tests { ); } + /// Regression: installed-but-not-authenticated relay must NOT short-circuit + /// `auth_channel_relay()` to "authenticated". Previously, `auth_channel_relay` + /// called `is_relay_channel()` which checked the in-memory + /// `installed_relay_extensions` set; that returned `true` even when no team_id + /// existed in the store, so the OAuth URL was never offered. + #[tokio::test] + async fn test_auth_channel_relay_installed_without_team_id_is_not_authenticated() { + let dir = tempfile::tempdir().expect("temp dir"); + let mgr = make_test_manager(None, dir.path().to_path_buf()); + + // Mark as installed (simulates clicking Install in the UI) + mgr.installed_relay_extensions + .write() + .await + .insert("slack-relay".to_string()); + + // Without a stored team_id, auth should NOT return authenticated. + // It should fail because relay config is missing (no CHANNEL_RELAY_URL), + // but the key assertion is that it does NOT return Ok(authenticated). + let result = mgr.auth_channel_relay("slack-relay", "test").await; + match result { + Ok(ref auth_result) if auth_result.is_authenticated() => { + panic!( + "auth_channel_relay returned authenticated for installed-but-no-team-id relay; \ + expected either an OAuth URL or a config error" + ); + } + _ => { + // Config error (no relay URL) or awaiting_authorization — both are correct + } + } + } + #[tokio::test] async fn test_remove_relay_shuts_down_via_relay_channel_manager() { // Regression: remove() only checked channel_runtime for shutdown, missing diff --git a/src/llm/mod.rs b/src/llm/mod.rs index 308b39836f..d681547d33 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -63,7 +63,8 @@ pub use provider::{ }; pub use reasoning::{ ActionPlan, Reasoning, ReasoningContext, RespondOutput, RespondResult, SILENT_REPLY_TOKEN, - TOOL_INTENT_NUDGE, TokenUsage, ToolSelection, is_silent_reply, llm_signals_tool_intent, + TOOL_INTENT_NUDGE, TRUNCATED_TOOL_CALL_NOTICE, TokenUsage, ToolSelection, is_silent_reply, + llm_signals_tool_intent, }; pub use recording::RecordingLlm; pub use registry::{ProviderDefinition, ProviderProtocol, ProviderRegistry}; diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index cf3692e916..6e078ac7a1 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use crate::llm::error::LlmError; use crate::llm::{ - ChatMessage, CompletionRequest, LlmProvider, Role, ToolCall, ToolCompletionRequest, - ToolDefinition, + ChatMessage, CompletionRequest, FinishReason, LlmProvider, Role, ToolCall, + ToolCompletionRequest, ToolDefinition, }; /// Token the agent returns when it has nothing to say (e.g. in group chats). @@ -23,6 +23,13 @@ You said you would perform an action, but you did not include any tool calls.\n\ Do NOT describe what you intend to do — actually call the tool now.\n\ Use the tool_calls mechanism to invoke the appropriate tool."; +/// Notice injected when the LLM's response was truncated mid-tool-call, +/// causing incomplete parameters. Tells the LLM to try a different approach. +pub const TRUNCATED_TOOL_CALL_NOTICE: &str = "\ +Your previous response was truncated while generating tool call parameters. \ +The tool calls were discarded. Please try a different approach — \ +summarize or transform the data instead of echoing it verbatim in a tool call."; + /// Seed value used as the second argument to `generate_tool_call_id` when /// recovering tool calls from malformed LLM text responses. This must differ /// from the `0` seed used in `rig_adapter::normalized_tool_call_id` to avoid @@ -194,6 +201,8 @@ pub struct ReasoningContext { pub metadata: std::collections::HashMap, /// When true, force a text-only response (ignore available tools). /// Used by the agentic loop to guarantee termination near the iteration limit. + /// Sticky: once set, never cleared within a loop invocation. Callers must + /// create a fresh `ReasoningContext` per `run_agentic_loop()` call. pub force_text: bool, /// Pre-built system prompt. When set, `respond_with_tools` uses this directly /// instead of calling `build_system_prompt_with_tools`. Allows callers to build @@ -349,6 +358,7 @@ pub enum RespondResult { pub struct RespondOutput { pub result: RespondResult, pub usage: TokenUsage, + pub finish_reason: FinishReason, } /// Reasoning engine for the agent. @@ -530,6 +540,17 @@ impl Reasoning { let response = self.llm.complete_with_tools(request).await?; + // If the response was truncated, tool call parameters are likely incomplete. + // Return empty so the caller can fall through to respond_with_tools() which + // has a larger output token budget. + if response.finish_reason == FinishReason::Length { + tracing::warn!( + "select_tools response truncated (finish_reason=Length), \ + discarding potentially incomplete tool selections" + ); + return Ok(vec![]); + } + let shared_reasoning = response .content .map(|c| { @@ -722,6 +743,7 @@ Respond in JSON format: content: narrative, }, usage, + finish_reason: response.finish_reason, }); } @@ -749,6 +771,7 @@ Respond in JSON format: }, }, usage, + finish_reason: response.finish_reason, }); } @@ -774,6 +797,7 @@ Respond in JSON format: Ok(RespondOutput { result: RespondResult::Text(final_text), usage, + finish_reason: response.finish_reason, }) } else { // No tools, use simple completion @@ -805,6 +829,7 @@ Respond in JSON format: cache_read_input_tokens: response.cache_read_input_tokens, cache_creation_input_tokens: response.cache_creation_input_tokens, }, + finish_reason: response.finish_reason, }) } } @@ -1345,6 +1370,49 @@ fn is_inside_code(pos: usize, regions: &[CodeRegion]) -> bool { regions.iter().any(|r| pos >= r.start && pos < r.end) } +/// Check whether a byte range overlaps any code region. +fn overlaps_code_region(start: usize, end: usize, regions: &[CodeRegion]) -> bool { + regions.iter().any(|r| start < r.end && end > r.start) +} + +/// Return the byte bounds of the line containing `pos`, excluding the trailing newline. +fn line_bounds(text: &str, pos: usize) -> (usize, usize) { + let start = text[..pos].rfind('\n').map_or(0, |idx| idx + 1); + let end = text[pos..].find('\n').map_or(text.len(), |idx| pos + idx); + (start, end) +} + +/// Only recover XML-style tool calls when they are isolated content outside +/// markdown code and quote contexts. This avoids converting code examples or +/// quoted snippets into executable tool calls. +fn is_recoverable_tool_call_segment( + text: &str, + start: usize, + end: usize, + code_regions: &[CodeRegion], +) -> bool { + if overlaps_code_region(start, end, code_regions) { + return false; + } + + let (first_line_start, first_line_end) = line_bounds(text, start); + let first_line = &text[first_line_start..first_line_end]; + + if first_line.trim_start().starts_with('>') { + return false; + } + + let (_, last_line_end) = line_bounds(text, end.saturating_sub(1)); + let first_line_prefix = &text[first_line_start..start]; + let last_line_suffix = &text[end..last_line_end]; + + if !first_line_prefix.trim().is_empty() || !last_line_suffix.trim().is_empty() { + return false; + } + + true +} + /// Clean up LLM response by stripping model-internal tags and reasoning patterns. /// /// Some models (GLM-4.7, etc.) emit XML-tagged internal state like @@ -1364,6 +1432,7 @@ fn recover_tool_calls_from_content( ) -> Vec { let tool_names: std::collections::HashSet<&str> = available_tools.iter().map(|t| t.name.as_str()).collect(); + let code_regions = find_code_regions(content); let mut calls = Vec::new(); for (open, close) in &[ @@ -1372,15 +1441,23 @@ fn recover_tool_calls_from_content( ("", ""), ("<|function_call|>", "<|/function_call|>"), ] { - let mut remaining = content; - while let Some(start) = remaining.find(open) { + let mut search_from = 0; + while let Some(offset) = content[search_from..].find(open) { + let start = search_from + offset; let inner_start = start + open.len(); - let after = &remaining[inner_start..]; - let Some(end) = after.find(close) else { + let after = &content[inner_start..]; + let Some(end_offset) = after.find(close) else { break; }; - let inner = after[..end].trim(); - remaining = &after[end + close.len()..]; + let end = inner_start + end_offset; + let segment_end = end + close.len(); + search_from = segment_end; + + if !is_recoverable_tool_call_segment(content, start, segment_end, &code_regions) { + continue; + } + + let inner = content[inner_start..end].trim(); if inner.is_empty() { continue; @@ -2313,6 +2390,40 @@ That's my plan."#; assert_eq!(calls[0].name, "tool_list"); } + #[test] + fn test_recover_tool_call_in_fenced_code_block_ignored() { + let tools = make_tools(&["tool_list"]); + let content = "Here is the XML format:\n\n```xml\ntool_list\n```"; + let calls = recover_tool_calls_from_content(content, &tools); + assert!(calls.is_empty()); + } + + #[test] + fn test_recover_tool_call_in_inline_code_ignored() { + let tools = make_tools(&["tool_list"]); + let content = "Use `tool_list` to illustrate the syntax."; + let calls = recover_tool_calls_from_content(content, &tools); + assert!(calls.is_empty()); + } + + #[test] + fn test_recover_tool_call_in_blockquote_ignored() { + let tools = make_tools(&["tool_list"]); + let content = "The page replied:\n> tool_list"; + let calls = recover_tool_calls_from_content(content, &tools); + assert!(calls.is_empty()); + } + + #[test] + fn test_recover_multiline_json_tool_call_on_own_line() { + let tools = make_tools(&["memory_search"]); + let content = "Let me check.\n\n\n{\"name\": \"memory_search\", \"arguments\": {\"query\": \"test\"}}\n\n\nDone."; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "memory_search"); + assert_eq!(calls[0].arguments, serde_json::json!({"query": "test"})); + } + // ---- System prompt building tests (issue #565) ---- fn make_test_reasoning() -> Reasoning { @@ -3229,4 +3340,85 @@ That's my plan."#; let cleaned = clean_response(&pre_truncated); assert!(cleaned.trim().is_empty()); } + + // ---- select_tools truncation guard ---- + + /// Mock provider that returns tool calls with a configurable finish_reason. + struct TruncatingLlm { + finish_reason: crate::llm::FinishReason, + } + + #[async_trait::async_trait] + impl crate::llm::LlmProvider for TruncatingLlm { + fn model_name(&self) -> &str { + "truncating-stub" + } + fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) { + (rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO) + } + async fn complete( + &self, + _request: crate::llm::CompletionRequest, + ) -> Result { + unimplemented!() + } + async fn complete_with_tools( + &self, + _request: crate::llm::ToolCompletionRequest, + ) -> Result { + Ok(crate::llm::ToolCompletionResponse { + content: Some("I'll write the report.".to_string()), + tool_calls: vec![ToolCall { + id: "call_1".to_string(), + name: "memory_write".to_string(), + arguments: serde_json::json!({}), + reasoning: None, + }], + input_tokens: 5000, + output_tokens: 1024, + finish_reason: self.finish_reason, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }) + } + } + + #[tokio::test] + async fn test_select_tools_returns_empty_on_truncation() { + let llm = Arc::new(TruncatingLlm { + finish_reason: FinishReason::Length, + }); + let reasoning = Reasoning::new(llm); + let mut ctx = ReasoningContext::new().with_message(ChatMessage::user("Write a report")); + ctx.available_tools.push(ToolDefinition { + name: "memory_write".to_string(), + description: "Write to memory".to_string(), + parameters: serde_json::json!({"type": "object"}), + }); + + let selections = reasoning.select_tools(&ctx).await.unwrap(); + assert!( + selections.is_empty(), + "Truncated tool selections should be discarded (got {} selections)", + selections.len() + ); + } + + #[tokio::test] + async fn test_select_tools_returns_selections_when_not_truncated() { + let llm = Arc::new(TruncatingLlm { + finish_reason: FinishReason::ToolUse, + }); + let reasoning = Reasoning::new(llm); + let mut ctx = ReasoningContext::new().with_message(ChatMessage::user("Write a report")); + ctx.available_tools.push(ToolDefinition { + name: "memory_write".to_string(), + description: "Write to memory".to_string(), + parameters: serde_json::json!({"type": "object"}), + }); + + let selections = reasoning.select_tools(&ctx).await.unwrap(); + assert_eq!(selections.len(), 1); + assert_eq!(selections[0].tool_name, "memory_write"); + } } diff --git a/src/worker/job.rs b/src/worker/job.rs index 671b8864a4..f74d4ec8c6 100644 --- a/src/worker/job.rs +++ b/src/worker/job.rs @@ -1158,6 +1158,7 @@ impl<'a> JobDelegate<'a> { Ok(crate::llm::RespondOutput { result: RespondResult::Text(String::new()), usage: crate::llm::TokenUsage::default(), + finish_reason: crate::llm::FinishReason::Stop, }) } } @@ -1283,6 +1284,7 @@ impl<'a> LoopDelegate for JobDelegate<'a> { content: reasoning_text, }, usage: crate::llm::TokenUsage::default(), + finish_reason: crate::llm::FinishReason::ToolUse, }); } Ok(_) => {} // empty selections, fall through