Compare commits

..
Author SHA1 Message Date
Firat Sertgoz 2cce2e3155 chore(ci): retrigger regression gate
[skip-regression-check]
2026-03-28 20:44:19 +03:00
Henry Park 7223ce8c9f fix: pin staging ci jobs to a single tested sha 2026-03-24 16:29:49 -07:00
77 changed files with 652 additions and 2360 deletions
+9
View File
@@ -1,6 +1,11 @@
name: E2E Tests
on:
workflow_call:
inputs:
ref:
description: Commit SHA or ref to test
required: false
type: string
schedule:
- cron: "0 6 * * 1" # Weekly Monday 6 AM UTC
workflow_dispatch:
@@ -19,6 +24,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- uses: dtolnay/rust-toolchain@stable
@@ -59,6 +66,8 @@ jobs:
files: "tests/e2e/scenarios/test_owner_scope.py tests/e2e/scenarios/test_routine_event_batch.py"
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- name: Download binary
uses: actions/download-artifact@v4
+7 -3
View File
@@ -62,7 +62,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: staging
ref: ${{ github.sha }}
fetch-depth: 0
fetch-tags: true
@@ -117,6 +117,8 @@ jobs:
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/test.yml
with:
ref: ${{ needs.check-changes.outputs.current_head }}
# ── Run E2E browser tests ────────────────────────────────────────
e2e:
@@ -124,6 +126,8 @@ jobs:
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/e2e.yml
with:
ref: ${{ needs.check-changes.outputs.current_head }}
# ── Create promotion PR (triggers claude-review.yml on the PR) ──
create-promotion-pr:
@@ -137,7 +141,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
ref: staging
ref: ${{ needs.check-changes.outputs.current_head }}
fetch-depth: 0
- name: Generate GitHub App token
@@ -163,7 +167,7 @@ jobs:
PROMOTION_BASE: ${{ needs.resolve-promotion-base.outputs.promotion_base }}
run: |
git fetch origin "${PROMOTION_BASE}"
AHEAD=$(git rev-list --count "origin/${PROMOTION_BASE}..origin/staging")
AHEAD=$(git rev-list --count "origin/${PROMOTION_BASE}..HEAD")
echo "commits_ahead=${AHEAD}" >> "$GITHUB_OUTPUT"
if [ "$AHEAD" -eq 0 ]; then
echo "Staging is not ahead of ${PROMOTION_BASE}. Nothing to promote."
+25 -19
View File
@@ -1,6 +1,11 @@
name: Run Tests
on:
workflow_call:
inputs:
ref:
description: Commit SHA or ref to test
required: false
type: string
pull_request:
branches:
- main
@@ -12,7 +17,6 @@ jobs:
tests:
name: Tests (${{ matrix.name }})
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
@@ -29,6 +33,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
@@ -41,17 +47,16 @@ jobs:
- name: Build WASM channels (for integration tests)
run: ./scripts/build-wasm-extensions.sh --channels
- name: Run Tests
run: |
timeout --signal=INT --kill-after=30s 40m \
cargo test ${{ matrix.flags }} -- --nocapture
run: cargo test ${{ matrix.flags }} -- --nocapture
heavy-integration-tests:
name: Heavy Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
@@ -62,13 +67,9 @@ jobs:
- name: Build Telegram WASM channel
run: cargo build --manifest-path channels-src/telegram/Cargo.toml --target wasm32-wasip2 --release
- name: Run thread scheduling integration tests
run: |
timeout --signal=INT --kill-after=30s 15m \
cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
run: cargo test --no-default-features --features libsql,integration --test e2e_thread_scheduling -- --nocapture
- name: Run Telegram thread-scope regression test
run: |
timeout --signal=INT --kill-after=30s 10m \
cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
run: cargo test --features integration --test telegram_auth_integration test_private_messages_use_chat_id_as_thread_scope -- --exact
telegram-tests:
name: Telegram Channel Tests
@@ -76,17 +77,16 @@ jobs:
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run Telegram Channel Tests
run: |
timeout --signal=INT --kill-after=30s 10m \
cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
run: cargo test --manifest-path channels-src/telegram/Cargo.toml -- --nocapture
windows-build:
name: Windows Build (${{ matrix.name }})
@@ -107,6 +107,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
@@ -121,10 +123,11 @@ jobs:
github.event_name != 'pull_request' ||
github.base_ref != 'staging'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
@@ -137,9 +140,7 @@ jobs:
- name: Build all WASM extensions against current WIT
run: ./scripts/build-wasm-extensions.sh
- name: Instantiation test (host linker compatibility)
run: |
timeout --signal=INT --kill-after=30s 20m \
cargo test --all-features wit_compat -- --nocapture
run: cargo test --all-features wit_compat -- --nocapture
bench-compile:
name: Benchmark Compilation
@@ -147,6 +148,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
@@ -164,6 +167,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
- name: Build Docker image
run: docker build -t ironclaw-test:ci .
@@ -175,6 +180,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.sha }}
fetch-depth: 0
- name: Check version bumps for changed extensions
env:
-132
View File
@@ -7,138 +7,6 @@ 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
Generated
+5 -14
View File
@@ -2323,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -3390,7 +3390,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.22.0"
version = "0.19.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -3428,7 +3428,6 @@ dependencies = [
"hyper-util",
"iana-time-zone",
"insta",
"ironclaw_common",
"ironclaw_safety",
"json5",
"libsql",
@@ -3486,17 +3485,9 @@ dependencies = [
"zip",
]
[[package]]
name = "ironclaw_common"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "ironclaw_safety"
version = "0.2.0"
version = "0.1.0"
dependencies = [
"aho-corasick",
"regex",
@@ -5481,7 +5472,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -6388,7 +6379,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
+3 -6
View File
@@ -1,5 +1,5 @@
[workspace]
members = [".", "crates/ironclaw_common", "crates/ironclaw_safety"]
members = [".", "crates/ironclaw_safety"]
exclude = [
"channels-src/discord",
"channels-src/telegram",
@@ -20,7 +20,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.22.0"
version = "0.19.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
@@ -100,11 +100,8 @@ tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
# Cron scheduling for routines
cron = "0.13"
# Shared types
ironclaw_common = { path = "crates/ironclaw_common", version = "0.1.0" }
# Safety/sanitization
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.2.0" }
ironclaw_safety = { path = "crates/ironclaw_safety", version = "0.1.0" }
regex = "1"
aho-corasick = "1"
-17
View File
@@ -1,17 +0,0 @@
[package]
name = "ironclaw_common"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
description = "Shared types and utilities for the IronClaw workspace"
authors = ["NEAR AI <[email protected]>"]
license = "MIT OR Apache-2.0"
homepage = "https://github.com/nearai/ironclaw"
repository = "https://github.com/nearai/ironclaw"
[package.metadata.dist]
dist = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
-393
View File
@@ -1,393 +0,0 @@
//! Application-wide event types.
//!
//! `AppEvent` is the real-time event protocol used across the entire
//! application. The web gateway serialises these to SSE / WebSocket
//! frames, but other subsystems (agent loop, orchestrator, extensions)
//! produce and consume them too.
use serde::{Deserialize, Serialize};
/// A single tool decision in a reasoning update (SSE DTO).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDecisionDto {
pub tool_name: String,
pub rationale: String,
}
impl ToolDecisionDto {
/// Parse a list of tool decisions from a JSON array value.
pub fn from_json_array(value: &serde_json::Value) -> Vec<Self> {
value
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|d| {
Some(Self {
tool_name: d.get("tool_name")?.as_str()?.to_string(),
rationale: d.get("rationale")?.as_str()?.to_string(),
})
})
.collect()
})
.unwrap_or_default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AppEvent {
#[serde(rename = "response")]
Response { content: String, thread_id: String },
#[serde(rename = "thinking")]
Thinking {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_started")]
ToolStarted {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_completed")]
ToolCompleted {
name: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_result")]
ToolResult {
name: String,
preview: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "stream_chunk")]
StreamChunk {
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "status")]
Status {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "job_started")]
JobStarted {
job_id: String,
title: String,
browse_url: String,
},
#[serde(rename = "approval_needed")]
ApprovalNeeded {
request_id: String,
tool_name: String,
description: String,
parameters: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
/// Whether the "always" auto-approve option should be shown.
allow_always: bool,
},
#[serde(rename = "auth_required")]
AuthRequired {
extension_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
setup_url: Option<String>,
},
#[serde(rename = "auth_completed")]
AuthCompleted {
extension_name: String,
success: bool,
message: String,
},
#[serde(rename = "error")]
Error {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "heartbeat")]
Heartbeat,
// Sandbox job streaming events (worker + Claude Code bridge)
#[serde(rename = "job_message")]
JobMessage {
job_id: String,
role: String,
content: String,
},
#[serde(rename = "job_tool_use")]
JobToolUse {
job_id: String,
tool_name: String,
input: serde_json::Value,
},
#[serde(rename = "job_tool_result")]
JobToolResult {
job_id: String,
tool_name: String,
output: String,
},
#[serde(rename = "job_status")]
JobStatus { job_id: String, message: String },
#[serde(rename = "job_result")]
JobResult {
job_id: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fallback_deliverable: Option<serde_json::Value>,
},
/// An image was generated by a tool.
#[serde(rename = "image_generated")]
ImageGenerated {
data_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Suggested follow-up messages for the user.
#[serde(rename = "suggestions")]
Suggestions {
suggestions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Per-turn token usage and cost summary.
#[serde(rename = "turn_cost")]
TurnCost {
input_tokens: u64,
output_tokens: u64,
cost_usd: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Extension activation status change (WASM channels).
#[serde(rename = "extension_status")]
ExtensionStatus {
extension_name: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
},
/// Agent reasoning update (why it chose specific tools).
#[serde(rename = "reasoning_update")]
ReasoningUpdate {
narrative: String,
decisions: Vec<ToolDecisionDto>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Reasoning update for a sandbox job.
#[serde(rename = "job_reasoning")]
JobReasoning {
job_id: String,
narrative: String,
decisions: Vec<ToolDecisionDto>,
},
}
impl AppEvent {
/// The wire-format event type string (matches the `#[serde(rename)]` value).
pub fn event_type(&self) -> &'static str {
match self {
Self::Response { .. } => "response",
Self::Thinking { .. } => "thinking",
Self::ToolStarted { .. } => "tool_started",
Self::ToolCompleted { .. } => "tool_completed",
Self::ToolResult { .. } => "tool_result",
Self::StreamChunk { .. } => "stream_chunk",
Self::Status { .. } => "status",
Self::JobStarted { .. } => "job_started",
Self::ApprovalNeeded { .. } => "approval_needed",
Self::AuthRequired { .. } => "auth_required",
Self::AuthCompleted { .. } => "auth_completed",
Self::Error { .. } => "error",
Self::Heartbeat => "heartbeat",
Self::JobMessage { .. } => "job_message",
Self::JobToolUse { .. } => "job_tool_use",
Self::JobToolResult { .. } => "job_tool_result",
Self::JobStatus { .. } => "job_status",
Self::JobResult { .. } => "job_result",
Self::ImageGenerated { .. } => "image_generated",
Self::Suggestions { .. } => "suggestions",
Self::TurnCost { .. } => "turn_cost",
Self::ExtensionStatus { .. } => "extension_status",
Self::ReasoningUpdate { .. } => "reasoning_update",
Self::JobReasoning { .. } => "job_reasoning",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Verify that `event_type()` returns the same string as the serde
/// `"type"` field for every variant. This catches drift between the
/// `#[serde(rename)]` attributes and the manual match arms.
#[test]
fn event_type_matches_serde_type_field() {
let variants: Vec<AppEvent> = vec![
AppEvent::Response {
content: String::new(),
thread_id: String::new(),
},
AppEvent::Thinking {
message: String::new(),
thread_id: None,
},
AppEvent::ToolStarted {
name: String::new(),
thread_id: None,
},
AppEvent::ToolCompleted {
name: String::new(),
success: true,
error: None,
parameters: None,
thread_id: None,
},
AppEvent::ToolResult {
name: String::new(),
preview: String::new(),
thread_id: None,
},
AppEvent::StreamChunk {
content: String::new(),
thread_id: None,
},
AppEvent::Status {
message: String::new(),
thread_id: None,
},
AppEvent::JobStarted {
job_id: String::new(),
title: String::new(),
browse_url: String::new(),
},
AppEvent::ApprovalNeeded {
request_id: String::new(),
tool_name: String::new(),
description: String::new(),
parameters: String::new(),
thread_id: None,
allow_always: false,
},
AppEvent::AuthRequired {
extension_name: String::new(),
instructions: None,
auth_url: None,
setup_url: None,
},
AppEvent::AuthCompleted {
extension_name: String::new(),
success: true,
message: String::new(),
},
AppEvent::Error {
message: String::new(),
thread_id: None,
},
AppEvent::Heartbeat,
AppEvent::JobMessage {
job_id: String::new(),
role: String::new(),
content: String::new(),
},
AppEvent::JobToolUse {
job_id: String::new(),
tool_name: String::new(),
input: serde_json::Value::Null,
},
AppEvent::JobToolResult {
job_id: String::new(),
tool_name: String::new(),
output: String::new(),
},
AppEvent::JobStatus {
job_id: String::new(),
message: String::new(),
},
AppEvent::JobResult {
job_id: String::new(),
status: String::new(),
session_id: None,
fallback_deliverable: None,
},
AppEvent::ImageGenerated {
data_url: String::new(),
path: None,
thread_id: None,
},
AppEvent::Suggestions {
suggestions: vec![],
thread_id: None,
},
AppEvent::TurnCost {
input_tokens: 0,
output_tokens: 0,
cost_usd: String::new(),
thread_id: None,
},
AppEvent::ExtensionStatus {
extension_name: String::new(),
status: String::new(),
message: None,
},
AppEvent::ReasoningUpdate {
narrative: String::new(),
decisions: vec![],
thread_id: None,
},
AppEvent::JobReasoning {
job_id: String::new(),
narrative: String::new(),
decisions: vec![],
},
];
for variant in &variants {
let json: serde_json::Value = serde_json::to_value(variant).unwrap();
let serde_type = json["type"].as_str().unwrap();
assert_eq!(
variant.event_type(),
serde_type,
"event_type() mismatch for variant: {:?}",
variant
);
}
}
#[test]
fn round_trip_deserialize() {
let original = AppEvent::Response {
content: "hello".to_string(),
thread_id: "t1".to_string(),
};
let json = serde_json::to_string(&original).unwrap();
let deserialized: AppEvent = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.event_type(), "response");
}
}
-7
View File
@@ -1,7 +0,0 @@
//! Shared types and utilities for the IronClaw workspace.
mod event;
mod util;
pub use event::{AppEvent, ToolDecisionDto};
pub use util::truncate_preview;
-100
View File
@@ -1,100 +0,0 @@
//! Shared utility functions.
/// Truncate a string to at most `max_bytes` bytes at a char boundary, appending "...".
///
/// If the input is wrapped in `<tool_output ...>...</tool_output>` and truncation
/// removes the closing tag, the tag is re-appended so downstream XML parsers
/// never see an unclosed element.
pub fn truncate_preview(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
// Walk backwards from max_bytes to find a valid char boundary
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let mut result = format!("{}...", &s[..end]);
// Re-close <tool_output> if truncation cut through the closing tag.
if s.starts_with("<tool_output") && !result.ends_with("</tool_output>") {
result.push_str("\n</tool_output>");
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_preview_short_string() {
assert_eq!(truncate_preview("hello", 10), "hello");
}
#[test]
fn test_truncate_preview_exact_boundary() {
assert_eq!(truncate_preview("hello", 5), "hello");
}
#[test]
fn test_truncate_preview_truncates_ascii() {
assert_eq!(truncate_preview("hello world", 5), "hello...");
}
#[test]
fn test_truncate_preview_empty_string() {
assert_eq!(truncate_preview("", 10), "");
}
#[test]
fn test_truncate_preview_multibyte_char_boundary() {
let s = "a\u{20AC}b";
let result = truncate_preview(s, 3);
assert_eq!(result, "a...");
}
#[test]
fn test_truncate_preview_emoji() {
let s = "hi\u{1F980}";
let result = truncate_preview(s, 4);
assert_eq!(result, "hi...");
}
#[test]
fn test_truncate_preview_cjk() {
let s = "\u{4F60}\u{597D}\u{4E16}\u{754C}";
let result = truncate_preview(s, 7);
assert_eq!(result, "\u{4F60}\u{597D}...");
}
#[test]
fn test_truncate_preview_zero_max_bytes() {
assert_eq!(truncate_preview("hello", 0), "...");
}
#[test]
fn test_truncate_preview_closes_tool_output_tag() {
let s = "<tool_output name=\"search\">\nSome very long content here\n</tool_output>";
let result = truncate_preview(s, 60);
assert!(result.ends_with("</tool_output>"));
assert!(result.contains("..."));
}
#[test]
fn test_truncate_preview_no_extra_close_when_intact() {
let s = "<tool_output name=\"echo\">\nshort\n</tool_output>";
let result = truncate_preview(s, 500);
assert_eq!(result, s);
assert_eq!(result.matches("</tool_output>").count(), 1);
}
#[test]
fn test_truncate_preview_non_xml_unaffected() {
let s = "Just a plain long string that gets truncated";
let result = truncate_preview(s, 10);
assert_eq!(result, "Just a pla...");
assert!(!result.contains("</tool_output>"));
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ironclaw_safety"
version = "0.2.0"
version = "0.1.0"
edition = "2024"
rust-version = "1.92"
description = "Prompt injection defense, input validation, secret leak detection, and safety policy enforcement"
@@ -8,6 +8,7 @@ authors = ["NEAR AI <[email protected]>"]
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
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "feishu",
"display_name": "Feishu / Lark Channel",
"kind": "channel",
"version": "0.1.3",
"version": "0.1.1",
"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": "52def36121a93cf0b06dd6d594dc9528745fae56ded7d632998700cc595be762",
"url": "https://github.com/nearai/ironclaw/releases/download/v0.21.0/channel-feishu-0.1.2-wasm32-wasip2.tar.gz"
"sha256": "5fca74022264d1c8e78a0853766276f7ffa3cf0d8065b2f51ca10985acad4714",
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-feishu-0.1.1-wasm32-wasip2.tar.gz"
}
},
"auth_summary": {
+2 -2
View File
@@ -18,8 +18,8 @@
},
"artifacts": {
"wasm32-wasip2": {
"url": "https://github.com/nearai/ironclaw/releases/download/v0.20.0/channel-telegram-0.2.5-wasm32-wasip2.tar.gz",
"sha256": "1ef20a538f55b379e049356e4d6758006251846bc3365ceaa1c87eba8379a329"
"url": "https://github.com/nearai/ironclaw/releases/download/v0.19.0/channel-telegram-0.2.4-wasm32-wasip2.tar.gz",
"sha256": "a7cb300ec1c946831cfceaa95c1dc8f30d0f42a3924f3cb5de8098821573f4b8"
}
},
"auth_summary": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "github",
"display_name": "GitHub",
"kind": "tool",
"version": "0.2.2",
"version": "0.2.1",
"wit_version": "0.3.0",
"description": "GitHub integration for issues, PRs, repos, and code search",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gmail",
"display_name": "Gmail",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Read, send, and manage Gmail messages and threads",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "google-calendar",
"display_name": "Google Calendar",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Create, read, update, and delete Google Calendar events",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "google-docs",
"display_name": "Google Docs",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Create and edit Google Docs documents",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "google-drive",
"display_name": "Google Drive",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Upload, download, search, and manage Google Drive files and folders",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "google-sheets",
"display_name": "Google Sheets",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Read and write Google Sheets spreadsheet data",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "google-slides",
"display_name": "Google Slides",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Create and edit Google Slides presentations",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "llm-context",
"display_name": "LLM Context",
"kind": "tool",
"version": "0.1.1",
"version": "0.1.0",
"wit_version": "0.3.0",
"description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "slack-tool",
"display_name": "Slack Tool",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Your agent uses Slack to post and read messages in your workspace",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "telegram-mtproto",
"display_name": "Telegram Tool",
"kind": "tool",
"version": "0.2.1",
"version": "0.2.0",
"wit_version": "0.3.0",
"description": "Your agent uses your Telegram account to read and send messages",
"keywords": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "web-search",
"display_name": "Web Search",
"kind": "tool",
"version": "0.2.2",
"version": "0.2.1",
"wit_version": "0.3.0",
"description": "Search the web using Brave Search API",
"keywords": [
+5
View File
@@ -1,2 +1,7 @@
[workspace]
git_release_enable = false
[[package]]
name = "ironclaw_safety"
publish = false
release = false
+4 -79
View File
@@ -16,7 +16,6 @@ use crate::agent::context_monitor::ContextMonitor;
use crate::agent::heartbeat::spawn_heartbeat;
use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker};
use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair};
use crate::agent::session::ThreadState;
use crate::agent::session_manager::SessionManager;
use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult};
use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, Router, Scheduler, SchedulerDeps};
@@ -85,15 +84,6 @@ fn resolve_owner_scope_notification_user(
trimmed_option(explicit_user).or_else(|| trimmed_option(owner_fallback))
}
fn is_single_message_repl(message: &IncomingMessage) -> bool {
message.channel == "repl"
&& message
.metadata
.get("single_message_mode")
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
async fn resolve_channel_notification_user(
extension_manager: Option<&Arc<ExtensionManager>>,
channel: Option<&str>,
@@ -1150,14 +1140,9 @@ impl Agent {
&& let Submission::UserInput { ref content } = submission
&& let Some(engine) = self.routine_engine().await
{
let single_message_repl = is_single_message_repl(message);
// Use post-hook content so that BeforeInbound hooks that rewrite
// input are respected by event trigger matching.
let fired = if single_message_repl {
engine.check_event_triggers_and_wait(message, content).await
} else {
engine.check_event_triggers(message, content).await
};
let fired = engine.check_event_triggers(message, content).await;
if fired > 0 {
tracing::debug!(
channel = %message.channel,
@@ -1165,16 +1150,10 @@ impl Agent {
fired,
"Consumed inbound user message with matching event-triggered routine(s)"
);
return if single_message_repl {
Ok(None)
} else {
Ok(Some(String::new()))
};
return Ok(Some(String::new()));
}
}
let session_for_empty_exit = Arc::clone(&session);
// Process based on submission type
let result = match submission {
Submission::UserInput { content } => {
@@ -1271,28 +1250,6 @@ impl Agent {
command,
message.channel
);
// /reasoning is special-cased here (not in handle_system_command)
// because it needs the session + thread_id to read turn reasoning
// data, which handle_system_command's signature doesn't provide.
if command == "reasoning" {
let result = self
.handle_reasoning_command(&args, &session, thread_id)
.await;
return match result {
SubmissionResult::Response { content } => Ok(Some(content)),
SubmissionResult::Ok { message } => Ok(message),
SubmissionResult::Error { message } => {
Ok(Some(format!("Error: {}", message)))
}
_ => {
if is_single_message_repl(message) {
Ok(None)
} else {
Ok(Some(String::new()))
}
}
};
}
// Authorization checks (including restart channel check) are enforced in handle_system_command
self.handle_system_command(&command, &args, &message.channel)
.await
@@ -1352,26 +1309,7 @@ impl Agent {
Ok(Some(content))
}
}
SubmissionResult::Ok {
message: output_message,
} => {
let should_exit =
if output_message.as_deref() == Some("") && is_single_message_repl(message) {
let sess = session_for_empty_exit.lock().await;
sess.threads
.get(&thread_id)
.map(|thread| thread.state != ThreadState::AwaitingApproval)
.unwrap_or(true)
} else {
false
};
if should_exit {
Ok(None)
} else {
Ok(output_message)
}
}
SubmissionResult::Ok { message } => Ok(message),
SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))),
SubmissionResult::Interrupted => Ok(Some("Interrupted.".into())),
SubmissionResult::NeedApproval { .. } => {
@@ -1387,7 +1325,7 @@ impl Agent {
#[cfg(test)]
mod tests {
use super::{
chat_tool_execution_metadata, is_single_message_repl, resolve_routine_notification_user,
chat_tool_execution_metadata, resolve_routine_notification_user,
should_fallback_routine_notification, truncate_for_preview,
};
use crate::channels::IncomingMessage;
@@ -1549,17 +1487,4 @@ mod tests {
assert!(should_fallback_routine_notification(&error)); // safety: test-only assertion
}
#[test]
fn single_message_repl_detection_requires_repl_channel_and_metadata_flag() {
let repl = IncomingMessage::new("repl", "owner-scope", "hello")
.with_metadata(serde_json::json!({ "single_message_mode": true }));
let gateway = IncomingMessage::new("gateway", "owner-scope", "hello")
.with_metadata(serde_json::json!({ "single_message_mode": true }));
let plain_repl = IncomingMessage::new("repl", "owner-scope", "hello");
assert!(is_single_message_repl(&repl)); // safety: test-only assertion
assert!(!is_single_message_repl(&gateway)); // safety: test-only assertion
assert!(!is_single_message_repl(&plain_repl)); // safety: test-only assertion
}
}
-1
View File
@@ -414,7 +414,6 @@ mod tests {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
};
let delegate = MockDelegate::new(vec![
tool_calls_output(vec![tool_call]),
-89
View File
@@ -465,94 +465,6 @@ impl Agent {
}
}
/// Handle `/reasoning [N|all]` — show reasoning history for the active thread.
pub(super) async fn handle_reasoning_command(
&self,
args: &[String],
session: &Arc<Mutex<Session>>,
thread_id: Uuid,
) -> SubmissionResult {
// Clone the turn data we need, then drop the session lock.
let turns_snapshot: Vec<(
usize,
Option<String>,
Vec<crate::agent::session::TurnToolCall>,
)>;
{
let sess = session.lock().await;
let thread = match sess.threads.get(&thread_id) {
Some(t) => t,
None => return SubmissionResult::error("No active thread."),
};
if thread.turns.is_empty() {
return SubmissionResult::ok_with_message("No turns yet.");
}
// Parse argument: default=last turn, "all"=all turns, N=specific turn (1-based).
let selected: Vec<&crate::agent::session::Turn> = match args.first().map(|s| s.as_str())
{
Some("all") => thread.turns.iter().collect(),
Some(n) => match n.parse::<usize>() {
Ok(0) => return SubmissionResult::error("Turn numbers start at 1."),
Ok(num) if num > thread.turns.len() => {
return SubmissionResult::error(format!(
"Turn {} does not exist (max: {}).",
num,
thread.turns.len()
));
}
Ok(num) => vec![&thread.turns[num - 1]],
Err(_) => return SubmissionResult::error("Usage: /reasoning [N|all]"),
},
None => {
// Default: last turn that has tool calls
match thread.turns.iter().rev().find(|t| !t.tool_calls.is_empty()) {
Some(t) => vec![t],
None => {
return SubmissionResult::ok_with_message("No turns with tool calls.");
}
}
}
};
turns_snapshot = selected
.into_iter()
.map(|t| (t.turn_number, t.narrative.clone(), t.tool_calls.clone()))
.collect();
}
// Session lock is now dropped — format output without holding it.
let mut output = String::new();
for (turn_number, narrative, tool_calls) in &turns_snapshot {
output.push_str(&format!("--- Turn {} ---\n", turn_number + 1));
if let Some(narrative) = narrative {
output.push_str(&format!("Reasoning: {}\n", narrative));
}
if tool_calls.is_empty() {
output.push_str(" (no tool calls)\n");
} else {
for tc in tool_calls {
let status = if tc.error.is_some() {
"error"
} else if tc.result.is_some() {
"ok"
} else {
"pending"
};
output.push_str(&format!(" {} [{}]", tc.name, status));
if let Some(ref rationale) = tc.rationale {
output.push_str(&format!("{}", rationale));
}
output.push('\n');
}
}
output.push('\n');
}
SubmissionResult::response(output.trim_end())
}
/// Handle system commands that bypass thread-state checks entirely.
pub(super) async fn handle_system_command(
&self,
@@ -568,7 +480,6 @@ impl Agent {
" /version Show version info\n",
" /tools List available tools\n",
" /debug Toggle debug mode\n",
" /reasoning [N|all] Show agent reasoning for turns\n",
" /ping Connectivity check\n",
"\n",
"Jobs:\n",
+6 -85
View File
@@ -63,12 +63,7 @@ impl Agent {
);
let system_prompt = if let Some(ws) = self.workspace() {
let scoped_workspace = if ws.user_id() == message.user_id {
Arc::clone(ws)
} else {
Arc::new(ws.scoped_to_user(&message.user_id))
};
match scoped_workspace
match ws
.system_prompt_for_context_tz(is_group_chat, user_tz)
.await
{
@@ -425,19 +420,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
content: Option<String>,
reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, Error> {
// Extract and sanitize the narrative before consuming `content`.
let narrative = content
.as_deref()
.filter(|c| !c.trim().is_empty())
.map(|c| {
let sanitized = self
.agent
.safety()
.sanitize_tool_output("agent_narrative", c);
sanitized.content
})
.filter(|c| !c.trim().is_empty());
// Add the assistant message with tool_calls to context.
// OpenAI protocol requires this before tool-result messages.
reason_ctx
@@ -458,41 +440,6 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
)
.await;
// Build per-tool decisions for the reasoning update.
// Sanitize each rationale through SafetyLayer (parity with JobDelegate).
let decisions: Vec<crate::channels::ToolDecision> = tool_calls
.iter()
.filter_map(|tc| {
tc.reasoning.as_ref().map(|r| {
let sanitized = self
.agent
.safety()
.sanitize_tool_output("tool_rationale", r)
.content;
crate::channels::ToolDecision {
tool_name: tc.name.clone(),
rationale: sanitized,
}
})
})
.collect();
// Emit reasoning update to channels.
if narrative.is_some() || !decisions.is_empty() {
let _ = self
.agent
.channels
.send_status(
&self.message.channel,
StatusUpdate::ReasoningUpdate {
narrative: narrative.clone().unwrap_or_default(),
decisions: decisions.clone(),
},
&self.message.metadata,
)
.await;
}
// Record tool calls in the thread with sensitive params redacted.
{
let mut redacted_args: Vec<serde_json::Value> = Vec::with_capacity(tool_calls.len());
@@ -508,23 +455,8 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
if let Some(thread) = sess.threads.get_mut(&self.thread_id)
&& let Some(turn) = thread.last_turn_mut()
{
// Set turn-level narrative.
if turn.narrative.is_none() {
turn.narrative = narrative;
}
for (tc, safe_args) in tool_calls.iter().zip(redacted_args) {
let sanitized_rationale = tc.reasoning.as_ref().map(|r| {
self.agent
.safety()
.sanitize_tool_output("tool_rationale", r)
.content
});
turn.record_tool_call_with_reasoning(
&tc.name,
safe_args,
sanitized_rationale,
Some(tc.id.clone()),
);
turn.record_tool_call(&tc.name, safe_args);
}
}
}
@@ -794,7 +726,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
if let Some(thread) = sess.threads.get_mut(&self.thread_id)
&& let Some(turn) = thread.last_turn_mut()
{
turn.record_tool_error_for(&tc.id, error_msg.clone());
turn.record_tool_error(error_msg.clone());
}
}
reason_ctx
@@ -920,19 +852,16 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
Err(e) => format!("Tool '{}' failed: {}", tc.name, e),
};
// Record sanitized result in thread (identity-based matching).
// Record sanitized result in thread
{
let mut sess = self.session.lock().await;
if let Some(thread) = sess.threads.get_mut(&self.thread_id)
&& let Some(turn) = thread.last_turn_mut()
{
if is_tool_error {
turn.record_tool_error_for(&tc.id, result_content.clone());
turn.record_tool_error(result_content.clone());
} else {
turn.record_tool_result_for(
&tc.id,
serde_json::json!(result_content),
);
turn.record_tool_result(serde_json::json!(result_content));
}
}
}
@@ -1533,13 +1462,11 @@ mod tests {
id: "call_2".to_string(),
name: "http".to_string(),
arguments: serde_json::json!({"url": "https://example.com"}),
reasoning: None,
},
ToolCall {
id: "call_3".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({"message": "done"}),
reasoning: None,
},
],
user_timezone: None,
@@ -1725,7 +1652,6 @@ mod tests {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({"message": "hi"}),
reasoning: None,
}],
),
ChatMessage::tool_result("call_1", "echo", "hi"),
@@ -1818,13 +1744,11 @@ mod tests {
id: "c1".to_string(),
name: "http".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
},
ToolCall {
id: "c2".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
},
],
),
@@ -1858,7 +1782,6 @@ mod tests {
id: "c1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
}],
),
ChatMessage::tool_result("c1", "echo", "done"),
@@ -1989,7 +1912,6 @@ mod tests {
id: crate::llm::generate_tool_call_id(0, 0),
name: "echo".to_string(),
arguments: serde_json::json!({"message": "looping"}),
reasoning: None,
}],
input_tokens: 0,
output_tokens: 5,
@@ -2143,7 +2065,6 @@ mod tests {
id: crate::llm::generate_tool_call_id(0, 0),
name: "nonexistent_tool".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
}],
input_tokens: 0,
output_tokens: 5,
+24 -24
View File
@@ -21,8 +21,8 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::channels::IncomingMessage;
use crate::channels::web::types::SseEvent;
use crate::context::{ContextManager, JobState};
use ironclaw_common::AppEvent;
/// Route context for forwarding job monitor events back to the user's channel.
#[derive(Debug, Clone)]
@@ -36,15 +36,15 @@ pub struct JobMonitorRoute {
/// injects assistant messages into the agent loop.
///
/// The monitor forwards:
/// - `AppEvent::JobMessage` (assistant role): injected as incoming messages so
/// - `SseEvent::JobMessage` (assistant role): injected as incoming messages so
/// the main agent can read and relay to the user.
/// - `AppEvent::JobResult`: injected as a completion notice, then the task exits.
/// - `SseEvent::JobResult`: injected as a completion notice, then the task exits.
///
/// Tool use/result and status events are intentionally skipped (too noisy for
/// the main agent's context window).
pub fn spawn_job_monitor(
job_id: Uuid,
event_rx: broadcast::Receiver<(Uuid, String, AppEvent)>,
event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
inject_tx: mpsc::Sender<IncomingMessage>,
route: JobMonitorRoute,
) -> JoinHandle<()> {
@@ -56,7 +56,7 @@ pub fn spawn_job_monitor(
/// jobs don't stay `InProgress` forever in the `ContextManager`.
pub fn spawn_job_monitor_with_context(
job_id: Uuid,
mut event_rx: broadcast::Receiver<(Uuid, String, AppEvent)>,
mut event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
inject_tx: mpsc::Sender<IncomingMessage>,
route: JobMonitorRoute,
context_manager: Option<Arc<ContextManager>>,
@@ -74,7 +74,7 @@ pub fn spawn_job_monitor_with_context(
}
match event {
AppEvent::JobMessage { role, content, .. } if role == "assistant" => {
SseEvent::JobMessage { role, content, .. } if role == "assistant" => {
let mut msg = IncomingMessage::new(
route.channel.clone(),
route.user_id.clone(),
@@ -92,7 +92,7 @@ pub fn spawn_job_monitor_with_context(
break;
}
}
AppEvent::JobResult { status, .. } => {
SseEvent::JobResult { status, .. } => {
// Transition in-memory state so the job frees its
// max_jobs slot and query tools show the final state.
if let Some(ref cm) = context_manager {
@@ -162,7 +162,7 @@ pub fn spawn_job_monitor_with_context(
/// inject messages into) but we still need to free the `max_jobs` slot.
pub fn spawn_completion_watcher(
job_id: Uuid,
mut event_rx: broadcast::Receiver<(Uuid, String, AppEvent)>,
mut event_rx: broadcast::Receiver<(Uuid, String, SseEvent)>,
context_manager: Arc<ContextManager>,
) -> JoinHandle<()> {
let short_id = job_id.to_string()[..8].to_string();
@@ -170,7 +170,7 @@ pub fn spawn_completion_watcher(
tokio::spawn(async move {
loop {
match event_rx.recv().await {
Ok((ev_job_id, _user_id, AppEvent::JobResult { status, .. }))
Ok((ev_job_id, _user_id, SseEvent::JobResult { status, .. }))
if ev_job_id == job_id =>
{
let target = if status == "completed" {
@@ -229,7 +229,7 @@ mod tests {
#[tokio::test]
async fn test_monitor_forwards_assistant_messages() {
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
let job_id = Uuid::new_v4();
@@ -240,7 +240,7 @@ mod tests {
.send((
job_id,
"test-user".to_string(),
AppEvent::JobMessage {
SseEvent::JobMessage {
job_id: job_id.to_string(),
role: "assistant".to_string(),
content: "I found a bug".to_string(),
@@ -262,7 +262,7 @@ mod tests {
#[tokio::test]
async fn test_monitor_ignores_other_jobs() {
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
let job_id = Uuid::new_v4();
@@ -274,7 +274,7 @@ mod tests {
.send((
other_job_id,
"test-user".to_string(),
AppEvent::JobMessage {
SseEvent::JobMessage {
job_id: other_job_id.to_string(),
role: "assistant".to_string(),
content: "wrong job".to_string(),
@@ -293,7 +293,7 @@ mod tests {
#[tokio::test]
async fn test_monitor_exits_on_job_result() {
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
let job_id = Uuid::new_v4();
@@ -304,7 +304,7 @@ mod tests {
.send((
job_id,
"test-user".to_string(),
AppEvent::JobResult {
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
@@ -329,7 +329,7 @@ mod tests {
#[tokio::test]
async fn test_monitor_skips_tool_events() {
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
let job_id = Uuid::new_v4();
@@ -340,7 +340,7 @@ mod tests {
.send((
job_id,
"test-user".to_string(),
AppEvent::JobToolUse {
SseEvent::JobToolUse {
job_id: job_id.to_string(),
tool_name: "shell".to_string(),
input: serde_json::json!({"command": "ls"}),
@@ -353,7 +353,7 @@ mod tests {
.send((
job_id,
"test-user".to_string(),
AppEvent::JobMessage {
SseEvent::JobMessage {
job_id: job_id.to_string(),
role: "user".to_string(),
content: "user prompt".to_string(),
@@ -409,7 +409,7 @@ mod tests {
.await
.unwrap();
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
let handle = spawn_job_monitor_with_context(
@@ -425,7 +425,7 @@ mod tests {
.send((
job_id,
"test-user".to_string(),
AppEvent::JobResult {
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
@@ -458,7 +458,7 @@ mod tests {
.await
.unwrap();
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
let handle = spawn_job_monitor_with_context(
@@ -474,7 +474,7 @@ mod tests {
.send((
job_id,
"test-user".to_string(),
AppEvent::JobResult {
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "failed".to_string(),
session_id: None,
@@ -507,14 +507,14 @@ mod tests {
.await
.unwrap();
let (event_tx, _) = broadcast::channel::<(Uuid, String, AppEvent)>(16);
let (event_tx, _) = broadcast::channel::<(Uuid, String, SseEvent)>(16);
let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
event_tx
.send((
job_id,
"test-user".to_string(),
AppEvent::JobResult {
SseEvent::JobResult {
job_id: job_id.to_string(),
status: "completed".to_string(),
session_id: None,
+11 -64
View File
@@ -18,7 +18,6 @@ use std::time::Duration;
use chrono::Utc;
use regex::Regex;
use tokio::sync::{RwLock, mpsc};
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::agent::Scheduler;
@@ -46,11 +45,6 @@ enum EventMatcher {
System { routine: Routine },
}
struct TriggeredRoutine {
routine: Routine,
detail: String,
}
/// Distinguishes why sandbox is unavailable so error messages are accurate.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxReadiness {
@@ -208,44 +202,6 @@ impl RoutineEngine {
/// Check incoming message against event triggers. Returns number of routines fired.
pub async fn check_event_triggers(&self, message: &IncomingMessage, content: &str) -> usize {
let triggered = self.matching_event_triggers(message, content).await;
let fired = triggered.len();
for triggered in triggered {
std::mem::drop(self.spawn_fire(triggered.routine, "event", Some(triggered.detail)));
}
fired
}
/// Fire matching event-triggered routines and wait for them to complete.
///
/// Used by single-message REPL mode so the process does not exit before
/// background event-triggered routines finish.
pub async fn check_event_triggers_and_wait(
&self,
message: &IncomingMessage,
content: &str,
) -> usize {
let triggered = self.matching_event_triggers(message, content).await;
let fired = triggered.len();
let handles: Vec<JoinHandle<()>> = triggered
.into_iter()
.map(|triggered| self.spawn_fire(triggered.routine, "event", Some(triggered.detail)))
.collect();
for handle in handles {
if let Err(e) = handle.await {
tracing::warn!(error = %e, "Event-triggered routine task failed");
}
}
fired
}
async fn matching_event_triggers(
&self,
message: &IncomingMessage,
content: &str,
) -> Vec<TriggeredRoutine> {
let cache = self.event_cache.read().await;
// Early return if there are no message matchers at all.
@@ -253,9 +209,10 @@ impl RoutineEngine {
.iter()
.any(|m| matches!(m, EventMatcher::Message { .. }))
{
return Vec::new();
return 0;
}
let mut triggered = Vec::new();
let mut fired = 0;
// Collect routine IDs for batch query
let routine_ids: Vec<Uuid> = cache
@@ -267,13 +224,13 @@ impl RoutineEngine {
.collect();
if routine_ids.is_empty() {
return Vec::new();
return 0;
}
// Single batch query instead of N queries
let concurrent_counts = match self.batch_concurrent_counts(&routine_ids).await {
Some(counts) => counts,
None => return Vec::new(),
None => return 0,
};
for matcher in cache.iter() {
@@ -328,13 +285,11 @@ impl RoutineEngine {
}
let detail = truncate(content, 200);
triggered.push(TriggeredRoutine {
routine: routine.clone(),
detail,
});
self.spawn_fire(routine.clone(), "event", Some(detail));
fired += 1;
}
triggered
fired
}
/// Emit a structured event to system-event routines.
@@ -890,12 +845,7 @@ impl RoutineEngine {
}
/// Spawn a fire in a background task.
fn spawn_fire(
&self,
routine: Routine,
trigger_type: &str,
trigger_detail: Option<String>,
) -> JoinHandle<()> {
fn spawn_fire(&self, routine: Routine, trigger_type: &str, trigger_detail: Option<String>) {
let run = RoutineRun {
id: Uuid::new_v4(),
routine_id: routine.id,
@@ -932,7 +882,7 @@ impl RoutineEngine {
return;
}
execute_routine(engine, routine, run).await;
})
});
}
fn check_cooldown(&self, routine: &Routine) -> bool {
@@ -1591,10 +1541,7 @@ async fn execute_lightweight_with_tools(
let force_text = iteration >= max_iterations;
if force_text {
// Final iteration: no tools, just get text response.
// Claude 4.6 rejects assistant prefill; NEAR AI rejects any non-user-ending
// conversation. Ensure the last message is user-role.
crate::util::ensure_ends_with_user_message(&mut messages);
// Final iteration: no tools, just get text response
let request = CompletionRequest::new(messages)
.with_max_tokens(effective_max_tokens)
.with_temperature(0.3);
+2 -193
View File
@@ -16,8 +16,8 @@ use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::channels::web::util::truncate_preview;
use crate::llm::{ChatMessage, ToolCall, generate_tool_call_id};
use ironclaw_common::truncate_preview;
/// A session containing one or more threads.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -449,7 +449,6 @@ impl Thread {
id: call_id.clone(),
name: tc.name.clone(),
arguments: tc.parameters.clone(),
reasoning: None,
})
.collect();
@@ -523,12 +522,7 @@ impl Thread {
&& let Some(ref tcs) = assistant_msg.tool_calls
{
for tc in tcs {
turn.record_tool_call_with_reasoning(
&tc.name,
tc.arguments.clone(),
tc.reasoning.clone(),
Some(tc.id.clone()),
);
turn.record_tool_call(&tc.name, tc.arguments.clone());
}
}
@@ -608,10 +602,6 @@ pub struct Turn {
pub completed_at: Option<DateTime<Utc>>,
/// Error message (if failed).
pub error: Option<String>,
/// Agent's reasoning narrative for this turn.
/// Cleaned via `clean_response` and sanitized through `SafetyLayer` before storage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narrative: Option<String>,
/// Transient image content parts for multimodal LLM input.
/// Not serialized — images are only needed for the current LLM call.
/// The text description in `user_input` persists for compaction/context.
@@ -631,7 +621,6 @@ impl Turn {
started_at: Utc::now(),
completed_at: None,
error: None,
narrative: None,
image_content_parts: Vec::new(),
}
}
@@ -667,26 +656,6 @@ impl Turn {
parameters: params,
result: None,
error: None,
rationale: None,
tool_call_id: None,
});
}
/// Record a tool call with reasoning context.
pub fn record_tool_call_with_reasoning(
&mut self,
name: impl Into<String>,
params: serde_json::Value,
rationale: Option<String>,
tool_call_id: Option<String>,
) {
self.tool_calls.push(TurnToolCall {
name: name.into(),
parameters: params,
result: None,
error: None,
rationale,
tool_call_id,
});
}
@@ -703,60 +672,6 @@ impl Turn {
call.error = Some(error.into());
}
}
/// Record a tool result by tool_call_id, with fallback to first pending call.
pub fn record_tool_result_for(&mut self, tool_call_id: &str, result: serde_json::Value) {
if let Some(call) = self
.tool_calls
.iter_mut()
.find(|c| c.tool_call_id.as_deref() == Some(tool_call_id))
{
call.result = Some(result);
} else if let Some(call) = self
.tool_calls
.iter_mut()
.find(|c| c.result.is_none() && c.error.is_none())
{
tracing::debug!(
tool_call_id = %tool_call_id,
fallback_tool = %call.name,
"tool_call_id not found, falling back to first pending call"
);
call.result = Some(result);
} else {
tracing::warn!(
tool_call_id = %tool_call_id,
"Tool result dropped: no matching or pending tool call"
);
}
}
/// Record a tool error by tool_call_id, with fallback to first pending call.
pub fn record_tool_error_for(&mut self, tool_call_id: &str, error: impl Into<String>) {
if let Some(call) = self
.tool_calls
.iter_mut()
.find(|c| c.tool_call_id.as_deref() == Some(tool_call_id))
{
call.error = Some(error.into());
} else if let Some(call) = self
.tool_calls
.iter_mut()
.find(|c| c.result.is_none() && c.error.is_none())
{
tracing::debug!(
tool_call_id = %tool_call_id,
fallback_tool = %call.name,
"tool_call_id not found, falling back to first pending call"
);
call.error = Some(error.into());
} else {
tracing::warn!(
tool_call_id = %tool_call_id,
"Tool error dropped: no matching or pending tool call"
);
}
}
}
/// Record of a tool call made during a turn.
@@ -770,12 +685,6 @@ pub struct TurnToolCall {
pub result: Option<serde_json::Value>,
/// Error from the tool (if failed).
pub error: Option<String>,
/// Agent's reasoning for choosing this tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
/// The tool_call_id from the LLM, for identity-based result matching.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[cfg(test)]
@@ -1400,7 +1309,6 @@ mod tests {
id: "call_0".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}),
reasoning: None,
};
let messages = vec![
ChatMessage::user("Find test"),
@@ -1431,7 +1339,6 @@ mod tests {
id: "call_0".to_string(),
name: "http".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
};
let messages = vec![
ChatMessage::user("Fetch URL"),
@@ -1497,13 +1404,11 @@ mod tests {
id: "call_a".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "data"}),
reasoning: None,
};
let tc2 = ToolCall {
id: "call_b".to_string(),
name: "write".to_string(),
arguments: serde_json::json!({"path": "out.txt"}),
reasoning: None,
};
let messages = vec![
ChatMessage::user("Find and save"),
@@ -1715,100 +1620,4 @@ mod tests {
let merged = thread.drain_pending_messages().unwrap();
assert_eq!(merged, "failed batch\nnew msg");
}
#[test]
fn test_record_tool_result_for_by_id() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call_with_reasoning(
"tool_a",
serde_json::json!({}),
None,
Some("id_a".into()),
);
turn.record_tool_call_with_reasoning(
"tool_b",
serde_json::json!({}),
None,
Some("id_b".into()),
);
// Record result for second tool by ID
turn.record_tool_result_for("id_b", serde_json::json!("result_b"));
assert!(turn.tool_calls[0].result.is_none());
assert_eq!(
turn.tool_calls[1].result.as_ref().unwrap(),
&serde_json::json!("result_b")
);
}
#[test]
fn test_record_tool_error_for_by_id() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call_with_reasoning(
"tool_a",
serde_json::json!({}),
None,
Some("id_a".into()),
);
turn.record_tool_call_with_reasoning(
"tool_b",
serde_json::json!({}),
None,
Some("id_b".into()),
);
turn.record_tool_error_for("id_a", "failed");
assert_eq!(turn.tool_calls[0].error.as_deref(), Some("failed"));
assert!(turn.tool_calls[1].error.is_none());
}
#[test]
fn test_record_tool_result_for_fallback_to_pending() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call_with_reasoning(
"tool_a",
serde_json::json!({}),
None,
Some("id_a".into()),
);
turn.record_tool_call_with_reasoning(
"tool_b",
serde_json::json!({}),
None,
Some("id_b".into()),
);
// First tool already has a result
turn.tool_calls[0].result = Some(serde_json::json!("done"));
// Unknown ID should fall back to first pending (tool_b)
turn.record_tool_result_for("unknown_id", serde_json::json!("fallback"));
assert_eq!(
turn.tool_calls[0].result.as_ref().unwrap(),
&serde_json::json!("done")
);
assert_eq!(
turn.tool_calls[1].result.as_ref().unwrap(),
&serde_json::json!("fallback")
);
}
#[test]
fn test_record_tool_result_for_no_pending_is_noop() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call_with_reasoning(
"tool_a",
serde_json::json!({}),
None,
Some("id_a".into()),
);
turn.tool_calls[0].result = Some(serde_json::json!("done"));
// No pending calls, unknown ID — should be a no-op
turn.record_tool_result_for("unknown_id", serde_json::json!("lost"));
assert_eq!(
turn.tool_calls[0].result.as_ref().unwrap(),
&serde_json::json!("done")
);
}
}
-11
View File
@@ -92,17 +92,6 @@ impl SubmissionParser {
args: vec![],
};
}
if lower == "/reasoning" || lower.starts_with("/reasoning ") {
let args: Vec<String> = trimmed
.split_whitespace()
.skip(1)
.map(|s| s.to_string())
.collect();
return Submission::SystemCommand {
command: "reasoning".to_string(),
args,
};
}
if lower == "/restart" {
tracing::debug!("[SubmissionParser::parse] Recognized /restart command");
return Submission::SystemCommand {
+12 -59
View File
@@ -16,12 +16,12 @@ use crate::agent::dispatcher::{
};
use crate::agent::session::{MAX_PENDING_MESSAGES, PendingApproval, Session, ThreadState};
use crate::agent::submission::SubmissionResult;
use crate::channels::web::util::truncate_preview;
use crate::channels::{IncomingMessage, StatusUpdate};
use crate::context::JobContext;
use crate::error::Error;
use crate::llm::{ChatMessage, ToolCall};
use crate::tools::redact_params;
use ironclaw_common::truncate_preview;
const FORGED_THREAD_ID_ERROR: &str = "Invalid or unauthorized thread ID.";
@@ -513,10 +513,10 @@ impl Agent {
};
thread.complete_turn(&response);
let (turn_number, tool_calls, narrative) = thread
let (turn_number, tool_calls) = thread
.turns
.last()
.map(|t| (t.turn_number, t.tool_calls.clone(), t.narrative.clone()))
.map(|t| (t.turn_number, t.tool_calls.clone()))
.unwrap_or_default();
let _ = self
.channels
@@ -534,7 +534,6 @@ impl Agent {
&message.user_id,
turn_number,
&tool_calls,
narrative.as_deref(),
)
.await;
self.persist_assistant_response(
@@ -726,9 +725,7 @@ impl Agent {
///
/// Stored between the user and assistant messages so that
/// `build_turns_from_db_messages` can reconstruct the tool call history.
/// Content is a JSON object: `{ "calls": [...], "narrative": "..." }`.
/// The `calls` array contains tool call summaries with optional `rationale`
/// and `tool_call_id` fields. Legacy rows may be plain JSON arrays.
/// Content is a JSON array of tool call summaries.
pub(super) async fn persist_tool_calls(
&self,
thread_id: Uuid,
@@ -736,7 +733,6 @@ impl Agent {
user_id: &str,
turn_number: usize,
tool_calls: &[crate::agent::session::TurnToolCall],
narrative: Option<&str>,
) {
if tool_calls.is_empty() {
return;
@@ -771,30 +767,11 @@ impl Agent {
if let Some(ref error) = tc.error {
obj["error"] = serde_json::Value::String(truncate_preview(error, 200));
}
if let Some(ref rationale) = tc.rationale {
obj["rationale"] = serde_json::Value::String(truncate_preview(rationale, 500));
}
if let Some(ref tool_call_id) = tc.tool_call_id {
obj["tool_call_id"] =
serde_json::Value::String(truncate_preview(tool_call_id, 128));
}
obj
})
.collect();
// Wrap in an object with optional narrative so it can be reconstructed.
// safety: no byte-index slicing here; comment describes JSON shape
let wrapper = if let Some(n) = narrative {
serde_json::json!({
"narrative": truncate_preview(n, 1000),
"calls": summaries,
})
} else {
serde_json::json!({
"calls": summaries,
})
};
let content = match serde_json::to_string(&wrapper) {
let content = match serde_json::to_string(&summaries) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Failed to serialize tool calls: {}", e);
@@ -1127,12 +1104,9 @@ impl Agent {
&& let Some(turn) = thread.last_turn_mut()
{
if is_tool_error {
turn.record_tool_error_for(&pending.tool_call_id, result_content.clone());
turn.record_tool_error(result_content.clone());
} else {
turn.record_tool_result_for(
&pending.tool_call_id,
serde_json::json!(result_content),
);
turn.record_tool_result(serde_json::json!(result_content));
}
}
}
@@ -1384,12 +1358,9 @@ impl Agent {
&& let Some(turn) = thread.last_turn_mut()
{
if is_deferred_error {
turn.record_tool_error_for(&tc.id, deferred_content.clone());
turn.record_tool_error(deferred_content.clone());
} else {
turn.record_tool_result_for(
&tc.id,
serde_json::json!(deferred_content),
);
turn.record_tool_result(serde_json::json!(deferred_content));
}
}
}
@@ -1488,10 +1459,10 @@ impl Agent {
let (response, suggestions) =
crate::agent::dispatcher::extract_suggestions(&response);
thread.complete_turn(&response);
let (turn_number, tool_calls, narrative) = thread
let (turn_number, tool_calls) = thread
.turns
.last()
.map(|t| (t.turn_number, t.tool_calls.clone(), t.narrative.clone()))
.map(|t| (t.turn_number, t.tool_calls.clone()))
.unwrap_or_default();
// User message already persisted at turn start; save tool calls then assistant response
self.persist_tool_calls(
@@ -1500,7 +1471,6 @@ impl Agent {
&message.user_id,
turn_number,
&tool_calls,
narrative.as_deref(),
)
.await;
self.persist_assistant_response(
@@ -1846,20 +1816,7 @@ fn rebuild_chat_messages_from_db(
"assistant" => result.push(ChatMessage::assistant(&msg.content)),
"tool_calls" => {
// Try to parse the enriched JSON and rebuild tool messages.
// Supports two formats:
// - Old: plain JSON array of tool call summaries
// - New: wrapped object { "calls": [...], "narrative": "..." }
let calls: Vec<serde_json::Value> =
match serde_json::from_str::<serde_json::Value>(&msg.content) {
Ok(serde_json::Value::Array(arr)) => arr,
Ok(serde_json::Value::Object(obj)) => obj
.get("calls")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default(),
_ => Vec::new(),
};
{
if let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(&msg.content) {
if calls.is_empty() {
continue;
}
@@ -1882,10 +1839,6 @@ fn rebuild_chat_messages_from_db(
.get("parameters")
.cloned()
.unwrap_or(serde_json::json!({})),
reasoning: c
.get("rationale")
.and_then(|v| v.as_str())
.map(String::from),
})
.collect();
-16
View File
@@ -265,15 +265,6 @@ impl OutgoingResponse {
}
}
/// A single tool decision within a reasoning update.
#[derive(Debug, Clone)]
pub struct ToolDecision {
/// Tool name.
pub tool_name: String,
/// Agent's reasoning for choosing this tool.
pub rationale: String,
}
/// Status update types for showing agent activity.
#[derive(Debug, Clone)]
pub enum StatusUpdate {
@@ -342,13 +333,6 @@ pub enum StatusUpdate {
},
/// Suggested follow-up messages for the user.
Suggestions { suggestions: Vec<String> },
/// Agent reasoning update (why it chose specific tools).
ReasoningUpdate {
/// Human-readable summary of the agent's decision.
narrative: String,
/// Per-tool decisions.
decisions: Vec<ToolDecision>,
},
/// Per-turn token usage and cost summary (shown as subtle metadata).
TurnCost {
input_tokens: u64,
+1 -1
View File
@@ -39,7 +39,7 @@ mod webhook_server;
pub use channel::{
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse, StatusUpdate, ToolDecision, routing_target_from_metadata,
MessageStream, OutgoingResponse, StatusUpdate, routing_target_from_metadata,
};
pub use http::{HttpChannel, HttpChannelState};
pub use manager::ChannelManager;
+9 -65
View File
@@ -75,7 +75,6 @@ const SLASH_COMMANDS: &[&str] = &[
"/suggest",
"/thread",
"/resume",
"/reasoning",
];
/// Rustyline helper for slash-command tab completion.
@@ -431,18 +430,6 @@ impl ReplChannel {
let _ = execute!(stderr, terminal::Clear(terminal::ClearType::FromCursorDown));
}
}
async fn finish_single_message_turn(&self) {
if self.single_message.is_none() {
return;
}
let tx = self.msg_tx.lock().ok().and_then(|mut guard| guard.take());
if let Some(tx) = tx {
let msg = IncomingMessage::new("repl", &self.user_id, "/quit");
let _ = tx.send(msg).await;
}
}
}
impl Default for ReplChannel {
@@ -492,9 +479,7 @@ impl Channel for ReplChannel {
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (tx, rx) = mpsc::channel(32);
// Approval prompts inject responses back through this sender.
// In single-message mode we keep it until the turn finishes, then
// drop it after enqueuing /quit so the receiver stream can close.
// Store tx so send_status can inject approval responses directly
if let Ok(mut guard) = self.msg_tx.lock() {
*guard = Some(tx.clone());
}
@@ -510,10 +495,11 @@ impl Channel for ReplChannel {
// Single message mode: send it and return
if let Some(msg) = single_message {
let incoming = IncomingMessage::new("repl", &user_id, &msg)
.with_metadata(serde_json::json!({ "single_message_mode": true }))
.with_timezone(&sys_tz);
let incoming = IncomingMessage::new("repl", &user_id, &msg).with_timezone(&sys_tz);
let _ = tx.blocking_send(incoming);
// Ensure the agent exits after handling exactly one turn in -m mode,
// even when other channels (gateway/http) are enabled.
let _ = tx.blocking_send(IncomingMessage::new("repl", &user_id, "/quit"));
return;
}
@@ -676,7 +662,6 @@ impl Channel for ReplChannel {
println!();
println!();
self.stdin_locked.store(false, Ordering::Relaxed);
self.finish_single_message_turn().await;
return Ok(());
}
@@ -695,7 +680,6 @@ impl Channel for ReplChannel {
println!();
// Unlock stdin so readline can resume
self.stdin_locked.store(false, Ordering::Relaxed);
self.finish_single_message_turn().await;
Ok(())
}
@@ -795,7 +779,6 @@ impl Channel for ReplChannel {
let msg_tx = Arc::clone(&self.msg_tx);
let user_id = self.user_id.clone();
let lock_flag = Arc::clone(&self.stdin_locked);
let single_message_mode = self.single_message.is_some();
tokio::task::spawn_blocking(move || {
let action = run_approval_selector(allow_always).unwrap_or("n");
// Unlock stdin so readline can resume after approval
@@ -804,12 +787,7 @@ impl Channel for ReplChannel {
return;
};
if let Some(tx) = guard.as_ref() {
let msg = if single_message_mode {
IncomingMessage::new("repl", &user_id, action)
.with_metadata(serde_json::json!({ "single_message_mode": true }))
} else {
IncomingMessage::new("repl", &user_id, action)
};
let msg = IncomingMessage::new("repl", &user_id, action);
let _ = tx.blocking_send(msg);
}
});
@@ -863,19 +841,6 @@ impl Channel for ReplChannel {
StatusUpdate::Suggestions { .. } => {
// Suggestions are only rendered by the web gateway
}
StatusUpdate::ReasoningUpdate {
narrative,
decisions,
} => {
if !narrative.is_empty() {
let display = truncate_for_preview(&narrative, CLI_STATUS_MAX);
eprintln!(" \x1b[94m\u{25B6} {display}\x1b[0m");
}
for d in &decisions {
let display = truncate_for_preview(&d.rationale, CLI_STATUS_MAX);
eprintln!(" \x1b[90m\u{2192} {}: {display}\x1b[0m", d.tool_name);
}
}
StatusUpdate::TurnCost { .. } => {
// Cost display is handled by the TUI channel
}
@@ -910,7 +875,6 @@ impl Channel for ReplChannel {
#[cfg(test)]
mod tests {
use futures::StreamExt;
use tokio::time::{Duration, timeout};
use super::*;
@@ -919,36 +883,16 @@ mod tests {
let repl = ReplChannel::with_message("hi".to_string());
let mut stream = repl.start().await.expect("repl start should succeed");
let first = timeout(Duration::from_secs(1), stream.next())
.await
.expect("timed out waiting for first message")
.expect("first message missing");
let first = stream.next().await.expect("first message missing");
assert_eq!(first.channel, "repl");
assert_eq!(first.content, "hi");
assert!(
timeout(Duration::from_millis(100), stream.next())
.await
.is_err(),
"single-message mode should wait for the turn to finish before quitting"
);
repl.respond(&first, OutgoingResponse::text("done"))
.await
.expect("respond should succeed");
let second = timeout(Duration::from_secs(1), stream.next())
.await
.expect("timed out waiting for quit message")
.expect("quit message missing");
let second = stream.next().await.expect("quit message missing");
assert_eq!(second.channel, "repl");
assert_eq!(second.content, "/quit");
assert!(
timeout(Duration::from_secs(1), stream.next())
.await
.expect("timed out waiting for stream to close")
.is_none(),
stream.next().await.is_none(),
"stream should end after /quit"
);
}
-14
View File
@@ -3061,20 +3061,6 @@ fn status_to_wit(
},
// Suggestions and turn cost are web-gateway-only; skip for WASM channels
StatusUpdate::Suggestions { .. } | StatusUpdate::TurnCost { .. } => return None,
StatusUpdate::ReasoningUpdate {
narrative,
decisions,
} => {
let mut msg = narrative.clone();
for d in decisions {
msg.push_str(&format!("\n{}: {}", d.tool_name, d.rationale));
}
wit_channel::StatusUpdate {
status: wit_channel::StatusType::Status,
message: msg,
metadata_json,
}
}
})
}
+3 -5
View File
@@ -175,7 +175,7 @@ pub async fn chat_auth_token_handler(
if result.verification.is_some() {
state.sse.broadcast_for_user(
&user.user_id,
AppEvent::AuthRequired {
SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(result.message),
auth_url: None,
@@ -187,7 +187,7 @@ pub async fn chat_auth_token_handler(
state.sse.broadcast_for_user(
&user.user_id,
AppEvent::AuthCompleted {
SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message,
@@ -202,7 +202,7 @@ pub async fn chat_auth_token_handler(
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast_for_user(
&user.user_id,
AppEvent::AuthRequired {
SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
@@ -398,10 +398,8 @@ pub async fn chat_history_handler(
truncate_preview(&s, 500)
}),
error: tc.error.clone(),
rationale: tc.rationale.clone(),
})
.collect(),
narrative: t.narrative.clone(),
})
.collect();
+16 -30
View File
@@ -58,7 +58,7 @@ use self::log_layer::{LogBroadcaster, LogLevelHandle};
use self::auth::MultiAuthState;
use self::server::GatewayState;
use self::sse::SseManager;
use self::types::AppEvent;
use self::types::SseEvent;
/// Web gateway channel implementing the Channel trait.
pub struct GatewayChannel {
@@ -386,7 +386,7 @@ impl Channel for GatewayChannel {
self.state.sse.broadcast_for_user(
&msg.user_id,
AppEvent::Response {
SseEvent::Response {
content: response.content,
thread_id,
},
@@ -405,11 +405,11 @@ impl Channel for GatewayChannel {
.and_then(|v| v.as_str())
.map(String::from);
let event = match status {
StatusUpdate::Thinking(msg) => AppEvent::Thinking {
StatusUpdate::Thinking(msg) => SseEvent::Thinking {
message: msg,
thread_id: thread_id.clone(),
},
StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted {
StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted {
name,
thread_id: thread_id.clone(),
},
@@ -418,23 +418,23 @@ impl Channel for GatewayChannel {
success,
error,
parameters,
} => AppEvent::ToolCompleted {
} => SseEvent::ToolCompleted {
name,
success,
error,
parameters,
thread_id: thread_id.clone(),
},
StatusUpdate::ToolResult { name, preview } => AppEvent::ToolResult {
StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult {
name,
preview,
thread_id: thread_id.clone(),
},
StatusUpdate::StreamChunk(content) => AppEvent::StreamChunk {
StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk {
content,
thread_id: thread_id.clone(),
},
StatusUpdate::Status(msg) => AppEvent::Status {
StatusUpdate::Status(msg) => SseEvent::Status {
message: msg,
thread_id: thread_id.clone(),
},
@@ -442,7 +442,7 @@ impl Channel for GatewayChannel {
job_id,
title,
browse_url,
} => AppEvent::JobStarted {
} => SseEvent::JobStarted {
job_id,
title,
browse_url,
@@ -453,7 +453,7 @@ impl Channel for GatewayChannel {
description,
parameters,
allow_always,
} => AppEvent::ApprovalNeeded {
} => SseEvent::ApprovalNeeded {
request_id,
tool_name,
description,
@@ -467,7 +467,7 @@ impl Channel for GatewayChannel {
instructions,
auth_url,
setup_url,
} => AppEvent::AuthRequired {
} => SseEvent::AuthRequired {
extension_name,
instructions,
auth_url,
@@ -477,39 +477,25 @@ impl Channel for GatewayChannel {
extension_name,
success,
message,
} => AppEvent::AuthCompleted {
} => SseEvent::AuthCompleted {
extension_name,
success,
message,
},
StatusUpdate::ImageGenerated { data_url, path } => AppEvent::ImageGenerated {
StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated {
data_url,
path,
thread_id: thread_id.clone(),
},
StatusUpdate::Suggestions { suggestions } => AppEvent::Suggestions {
StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions {
suggestions,
thread_id: thread_id.clone(),
},
StatusUpdate::ReasoningUpdate {
narrative,
decisions,
} => AppEvent::ReasoningUpdate {
narrative,
decisions: decisions
.into_iter()
.map(|d| crate::channels::web::types::ToolDecisionDto {
tool_name: d.tool_name,
rationale: d.rationale,
})
.collect(),
thread_id,
},
StatusUpdate::TurnCost {
input_tokens,
output_tokens,
cost_usd,
} => AppEvent::TurnCost {
} => SseEvent::TurnCost {
input_tokens,
output_tokens,
cost_usd,
@@ -545,7 +531,7 @@ impl Channel for GatewayChannel {
};
self.state.sse.broadcast_for_user(
user_id,
AppEvent::Response {
SseEvent::Response {
content: response.content,
thread_id,
},
-2
View File
@@ -231,7 +231,6 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result<Vec<ChatMessage>,
name: tc.function.name.clone(),
arguments: serde_json::from_str(&tc.function.arguments)
.unwrap_or(serde_json::Value::Object(Default::default())),
reasoning: None,
})
.collect();
Ok(ChatMessage::assistant_with_tool_calls(
@@ -955,7 +954,6 @@ mod tests {
id: "call_abc".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "rust"}),
reasoning: None,
}];
let converted = convert_tool_calls_to_openai(&calls);
+12 -14
View File
@@ -813,7 +813,7 @@ async fn oauth_callback_handler(
if let Some(ref sse) = flow.sse_manager {
sse.broadcast_for_user(
&flow.user_id,
AppEvent::AuthCompleted {
SseEvent::AuthCompleted {
extension_name: flow.extension_name.clone(),
success: false,
message: "OAuth flow expired. Please try again.".to_string(),
@@ -951,11 +951,11 @@ async fn oauth_callback_handler(
message
};
// Broadcast event to notify the web UI
// Broadcast SSE event to notify the web UI
if let Some(ref sse) = flow.sse_manager {
sse.broadcast_for_user(
&flow.user_id,
AppEvent::AuthCompleted {
SseEvent::AuthCompleted {
extension_name: flow.extension_name,
success,
message: final_message.clone(),
@@ -1197,8 +1197,8 @@ async fn slack_relay_oauth_callback_handler(
}
};
// Broadcast event to notify the web UI
state.sse.broadcast(AppEvent::AuthCompleted {
// Broadcast SSE event to notify the web UI
state.sse.broadcast(SseEvent::AuthCompleted {
extension_name: DEFAULT_RELAY_NAME.to_string(),
success,
message: message.clone(),
@@ -1471,7 +1471,7 @@ async fn chat_auth_token_handler(
if result.verification.is_some() {
state.sse.broadcast_for_user(
&user.user_id,
AppEvent::AuthRequired {
SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(result.message),
auth_url: None,
@@ -1484,7 +1484,7 @@ async fn chat_auth_token_handler(
state.sse.broadcast_for_user(
&user.user_id,
AppEvent::AuthCompleted {
SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: true,
message: result.message,
@@ -1493,7 +1493,7 @@ async fn chat_auth_token_handler(
} else {
state.sse.broadcast_for_user(
&user.user_id,
AppEvent::AuthCompleted {
SseEvent::AuthCompleted {
extension_name: req.extension_name.clone(),
success: false,
message: result.message,
@@ -1509,7 +1509,7 @@ async fn chat_auth_token_handler(
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast_for_user(
&user.user_id,
AppEvent::AuthRequired {
SseEvent::AuthRequired {
extension_name: req.extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
@@ -1725,10 +1725,8 @@ async fn chat_history_handler(
truncate_preview(&s, 500)
}),
error: tc.error.clone(),
rationale: tc.rationale.clone(),
})
.collect(),
narrative: t.narrative.clone(),
})
.collect();
@@ -2479,7 +2477,7 @@ async fn extensions_setup_submit_handler(
// auth card or setup modal that was triggered by tool_auth/tool_activate.
state.sse.broadcast_for_user(
&user.user_id,
AppEvent::AuthCompleted {
SseEvent::AuthCompleted {
extension_name: name.clone(),
success: result.activated,
message: resp.message.clone(),
@@ -3171,7 +3169,7 @@ mod tests {
Ok(Ok(scoped))
if matches!(
scoped.event,
crate::channels::web::types::AppEvent::AuthRequired { .. }
crate::channels::web::types::SseEvent::AuthRequired { .. }
) =>
{
panic!("verification responses should not emit auth_required SSE events")
@@ -3453,7 +3451,7 @@ mod tests {
assert_eq!(resp.status(), StatusCode::OK);
match receiver.recv().await.expect("auth_completed event").event {
crate::channels::web::types::AppEvent::AuthCompleted {
crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success,
message,
+42 -19
View File
@@ -11,7 +11,7 @@ use tokio::sync::broadcast;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
use crate::channels::web::types::AppEvent;
use crate::channels::web::types::SseEvent;
/// Maximum number of concurrent SSE/WebSocket connections.
/// Prevents resource exhaustion from connection flooding.
@@ -25,7 +25,7 @@ const MAX_CONNECTIONS: u64 = 100;
#[derive(Debug, Clone)]
pub(crate) struct ScopedEvent {
pub(crate) user_id: Option<String>,
pub(crate) event: AppEvent,
pub(crate) event: SseEvent,
}
/// Manages SSE broadcast to all connected browser tabs.
@@ -75,7 +75,7 @@ impl SseManager {
}
/// Broadcast an event to all connected clients (global/unscoped).
pub fn broadcast(&self, event: AppEvent) {
pub fn broadcast(&self, event: SseEvent) {
let _ = self.tx.send(ScopedEvent {
user_id: None,
event,
@@ -86,7 +86,7 @@ impl SseManager {
///
/// Only subscribers for this user_id (or unscoped subscribers) will
/// receive the event.
pub fn broadcast_for_user(&self, user_id: &str, event: AppEvent) {
pub fn broadcast_for_user(&self, user_id: &str, event: SseEvent) {
let _ = self.tx.send(ScopedEvent {
user_id: Some(user_id.to_string()),
event,
@@ -108,7 +108,7 @@ impl SseManager {
pub fn subscribe_raw(
&self,
user_id: Option<String>,
) -> Option<impl Stream<Item = AppEvent> + Send + 'static + use<>> {
) -> Option<impl Stream<Item = SseEvent> + Send + 'static + use<>> {
// Atomically increment only if below the limit. This prevents
// concurrent callers from overshooting max_connections.
let counter = Arc::clone(&self.connection_count);
@@ -186,7 +186,30 @@ impl SseManager {
return None;
}
};
let event_type = event.event_type();
let event_type = match &event {
SseEvent::Response { .. } => "response",
SseEvent::Thinking { .. } => "thinking",
SseEvent::ToolStarted { .. } => "tool_started",
SseEvent::ToolCompleted { .. } => "tool_completed",
SseEvent::ToolResult { .. } => "tool_result",
SseEvent::StreamChunk { .. } => "stream_chunk",
SseEvent::Status { .. } => "status",
SseEvent::ApprovalNeeded { .. } => "approval_needed",
SseEvent::AuthRequired { .. } => "auth_required",
SseEvent::AuthCompleted { .. } => "auth_completed",
SseEvent::Error { .. } => "error",
SseEvent::JobStarted { .. } => "job_started",
SseEvent::JobMessage { .. } => "job_message",
SseEvent::JobToolUse { .. } => "job_tool_use",
SseEvent::JobToolResult { .. } => "job_tool_result",
SseEvent::JobStatus { .. } => "job_status",
SseEvent::JobResult { .. } => "job_result",
SseEvent::Heartbeat => "heartbeat",
SseEvent::ImageGenerated { .. } => "image_generated",
SseEvent::Suggestions { .. } => "suggestions",
SseEvent::TurnCost { .. } => "turn_cost",
SseEvent::ExtensionStatus { .. } => "extension_status",
};
Some(Ok(Event::default().event(event_type).data(data)))
});
@@ -249,7 +272,7 @@ mod tests {
fn test_broadcast_without_receivers() {
let manager = SseManager::new();
// Should not panic even with no receivers
manager.broadcast(AppEvent::Heartbeat);
manager.broadcast(SseEvent::Heartbeat);
}
#[tokio::test]
@@ -257,14 +280,14 @@ mod tests {
let manager = SseManager::new();
let mut stream = Box::pin(manager.subscribe_raw(None).expect("should subscribe"));
manager.broadcast(AppEvent::Status {
manager.broadcast(SseEvent::Status {
message: "test".to_string(),
thread_id: None,
});
let event = stream.next().await.unwrap();
match event {
AppEvent::Status { message, .. } => assert_eq!(message, "test"),
SseEvent::Status { message, .. } => assert_eq!(message, "test"),
_ => panic!("unexpected event type"),
}
}
@@ -276,14 +299,14 @@ mod tests {
assert_eq!(manager.connection_count(), 1);
manager.broadcast(AppEvent::Thinking {
manager.broadcast(SseEvent::Thinking {
message: "working".to_string(),
thread_id: None,
});
let event = stream.next().await.unwrap();
match event {
AppEvent::Thinking { message, .. } => assert_eq!(message, "working"),
SseEvent::Thinking { message, .. } => assert_eq!(message, "working"),
_ => panic!("Expected Thinking event"),
}
}
@@ -306,12 +329,12 @@ mod tests {
let mut s2 = Box::pin(manager.subscribe_raw(None).expect("should subscribe"));
assert_eq!(manager.connection_count(), 2);
manager.broadcast(AppEvent::Heartbeat);
manager.broadcast(SseEvent::Heartbeat);
let e1 = s1.next().await.unwrap();
let e2 = s2.next().await.unwrap();
assert!(matches!(e1, AppEvent::Heartbeat));
assert!(matches!(e2, AppEvent::Heartbeat));
assert!(matches!(e1, SseEvent::Heartbeat));
assert!(matches!(e2, SseEvent::Heartbeat));
drop(s1);
assert_eq!(manager.connection_count(), 1);
@@ -350,25 +373,25 @@ mod tests {
// Send event scoped to alice
manager.broadcast_for_user(
"alice",
AppEvent::Status {
SseEvent::Status {
message: "alice only".to_string(),
thread_id: None,
},
);
// Send global event
manager.broadcast(AppEvent::Heartbeat);
manager.broadcast(SseEvent::Heartbeat);
// Alice gets her scoped event
let e = alice.next().await.unwrap();
assert!(matches!(e, AppEvent::Status { .. }));
assert!(matches!(e, SseEvent::Status { .. }));
// Alice also gets the global heartbeat
let e = alice.next().await.unwrap();
assert!(matches!(e, AppEvent::Heartbeat));
assert!(matches!(e, SseEvent::Heartbeat));
// Bob only gets the global heartbeat (alice's event was filtered)
let e = bob.next().await.unwrap(); // safety: test-only
assert!(matches!(e, AppEvent::Heartbeat)); // safety: test assertion
assert!(matches!(e, SseEvent::Heartbeat)); // safety: test assertion
}
}
+206 -33
View File
@@ -63,9 +63,6 @@ pub struct TurnInfo {
pub started_at: String,
pub completed_at: Option<String>,
pub tool_calls: Vec<ToolCallInfo>,
/// Agent's reasoning narrative for this turn.
#[serde(skip_serializing_if = "Option::is_none")]
pub narrative: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -77,9 +74,6 @@ pub struct ToolCallInfo {
pub result_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Agent's reasoning for choosing this tool.
#[serde(skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -120,9 +114,165 @@ pub struct ApprovalRequest {
pub thread_id: Option<String>,
}
// --- App Event (re-exported from ironclaw_common) ---
// --- SSE Event Types ---
pub use ironclaw_common::{AppEvent, ToolDecisionDto};
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum SseEvent {
#[serde(rename = "response")]
Response { content: String, thread_id: String },
#[serde(rename = "thinking")]
Thinking {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_started")]
ToolStarted {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_completed")]
ToolCompleted {
name: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "tool_result")]
ToolResult {
name: String,
preview: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "stream_chunk")]
StreamChunk {
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "status")]
Status {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "job_started")]
JobStarted {
job_id: String,
title: String,
browse_url: String,
},
#[serde(rename = "approval_needed")]
ApprovalNeeded {
request_id: String,
tool_name: String,
description: String,
parameters: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
/// Whether the "always" auto-approve option should be shown.
allow_always: bool,
},
#[serde(rename = "auth_required")]
AuthRequired {
extension_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
auth_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
setup_url: Option<String>,
},
#[serde(rename = "auth_completed")]
AuthCompleted {
extension_name: String,
success: bool,
message: String,
},
#[serde(rename = "error")]
Error {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
#[serde(rename = "heartbeat")]
Heartbeat,
// Sandbox job streaming events (worker + Claude Code bridge)
#[serde(rename = "job_message")]
JobMessage {
job_id: String,
role: String,
content: String,
},
#[serde(rename = "job_tool_use")]
JobToolUse {
job_id: String,
tool_name: String,
input: serde_json::Value,
},
#[serde(rename = "job_tool_result")]
JobToolResult {
job_id: String,
tool_name: String,
output: String,
},
#[serde(rename = "job_status")]
JobStatus { job_id: String, message: String },
#[serde(rename = "job_result")]
JobResult {
job_id: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fallback_deliverable: Option<serde_json::Value>,
},
/// An image was generated by a tool.
#[serde(rename = "image_generated")]
ImageGenerated {
data_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Suggested follow-up messages for the user.
#[serde(rename = "suggestions")]
Suggestions {
suggestions: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Per-turn token usage and cost summary.
#[serde(rename = "turn_cost")]
TurnCost {
input_tokens: u64,
output_tokens: u64,
cost_usd: String,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Extension activation status change (WASM channels).
#[serde(rename = "extension_status")]
ExtensionStatus {
extension_name: String,
status: String,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
},
}
// --- Memory ---
@@ -634,9 +784,32 @@ pub enum WsServerMessage {
}
impl WsServerMessage {
/// Create a WsServerMessage from an AppEvent.
pub fn from_app_event(event: &AppEvent) -> Self {
let event_type = event.event_type();
/// Create a WsServerMessage from an SseEvent.
pub fn from_sse_event(event: &SseEvent) -> Self {
let event_type = match event {
SseEvent::Response { .. } => "response",
SseEvent::Thinking { .. } => "thinking",
SseEvent::ToolStarted { .. } => "tool_started",
SseEvent::ToolCompleted { .. } => "tool_completed",
SseEvent::ToolResult { .. } => "tool_result",
SseEvent::StreamChunk { .. } => "stream_chunk",
SseEvent::Status { .. } => "status",
SseEvent::JobStarted { .. } => "job_started",
SseEvent::ApprovalNeeded { .. } => "approval_needed",
SseEvent::AuthRequired { .. } => "auth_required",
SseEvent::AuthCompleted { .. } => "auth_completed",
SseEvent::Error { .. } => "error",
SseEvent::Heartbeat => "heartbeat",
SseEvent::JobMessage { .. } => "job_message",
SseEvent::JobToolUse { .. } => "job_tool_use",
SseEvent::JobToolResult { .. } => "job_tool_result",
SseEvent::JobStatus { .. } => "job_status",
SseEvent::JobResult { .. } => "job_result",
SseEvent::ImageGenerated { .. } => "image_generated",
SseEvent::Suggestions { .. } => "suggestions",
SseEvent::TurnCost { .. } => "turn_cost",
SseEvent::ExtensionStatus { .. } => "extension_status",
};
let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null);
WsServerMessage::Event {
event_type: event_type.to_string(),
@@ -928,12 +1101,12 @@ mod tests {
}
#[test]
fn test_ws_server_from_app_event_response() {
let event = AppEvent::Response {
fn test_ws_server_from_sse_response() {
let sse = SseEvent::Response {
content: "hello".to_string(),
thread_id: "t1".to_string(),
};
let ws = WsServerMessage::from_app_event(&event);
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, data } => {
assert_eq!(event_type, "response");
@@ -945,12 +1118,12 @@ mod tests {
}
#[test]
fn test_ws_server_from_app_event_thinking() {
let event = AppEvent::Thinking {
fn test_ws_server_from_sse_thinking() {
let sse = SseEvent::Thinking {
message: "reasoning...".to_string(),
thread_id: None,
};
let ws = WsServerMessage::from_app_event(&event);
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, data } => {
assert_eq!(event_type, "thinking");
@@ -961,8 +1134,8 @@ mod tests {
}
#[test]
fn test_ws_server_from_app_event_approval_needed() {
let event = AppEvent::ApprovalNeeded {
fn test_ws_server_from_sse_approval_needed() {
let sse = SseEvent::ApprovalNeeded {
request_id: "r1".to_string(),
tool_name: "shell".to_string(),
description: "Run ls".to_string(),
@@ -970,7 +1143,7 @@ mod tests {
thread_id: Some("t1".to_string()),
allow_always: true,
};
let ws = WsServerMessage::from_app_event(&event);
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, data } => {
assert_eq!(event_type, "approval_needed");
@@ -982,9 +1155,9 @@ mod tests {
}
#[test]
fn test_ws_server_from_app_event_heartbeat() {
let event = AppEvent::Heartbeat;
let ws = WsServerMessage::from_app_event(&event);
fn test_ws_server_from_sse_heartbeat() {
let sse = SseEvent::Heartbeat;
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, .. } => {
assert_eq!(event_type, "heartbeat");
@@ -1024,8 +1197,8 @@ mod tests {
}
#[test]
fn test_app_event_auth_required_serialize() {
let event = AppEvent::AuthRequired {
fn test_sse_auth_required_serialize() {
let event = SseEvent::AuthRequired {
extension_name: "notion".to_string(),
instructions: Some("Get your token from...".to_string()),
auth_url: None,
@@ -1041,8 +1214,8 @@ mod tests {
}
#[test]
fn test_app_event_auth_completed_serialize() {
let event = AppEvent::AuthCompleted {
fn test_sse_auth_completed_serialize() {
let event = SseEvent::AuthCompleted {
extension_name: "notion".to_string(),
success: true,
message: "notion authenticated (3 tools loaded)".to_string(),
@@ -1055,14 +1228,14 @@ mod tests {
}
#[test]
fn test_ws_server_from_app_event_auth_required() {
let event = AppEvent::AuthRequired {
fn test_ws_server_from_sse_auth_required() {
let sse = SseEvent::AuthRequired {
extension_name: "openai".to_string(),
instructions: Some("Enter API key".to_string()),
auth_url: None,
setup_url: None,
};
let ws = WsServerMessage::from_app_event(&event);
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, data } => {
assert_eq!(event_type, "auth_required");
@@ -1073,13 +1246,13 @@ mod tests {
}
#[test]
fn test_ws_server_from_app_event_auth_completed() {
let event = AppEvent::AuthCompleted {
fn test_ws_server_from_sse_auth_completed() {
let sse = SseEvent::AuthCompleted {
extension_name: "slack".to_string(),
success: false,
message: "Invalid token".to_string(),
};
let ws = WsServerMessage::from_app_event(&event);
let ws = WsServerMessage::from_sse_event(&sse);
match ws {
WsServerMessage::Event { event_type, data } => {
assert_eq!(event_type, "auth_completed");
+115 -86
View File
@@ -2,21 +2,28 @@
use crate::channels::web::types::{ToolCallInfo, TurnInfo};
pub use ironclaw_common::truncate_preview;
/// Truncate a string to at most `max_bytes` bytes at a char boundary, appending "...".
///
/// If the input is wrapped in `<tool_output …>…</tool_output>` and truncation
/// removes the closing tag, the tag is re-appended so downstream XML parsers
/// never see an unclosed element.
pub fn truncate_preview(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
// Walk backwards from max_bytes to find a valid char boundary
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let mut result = format!("{}...", &s[..end]);
/// Parse tool call summary JSON objects into `ToolCallInfo` structs.
fn parse_tool_call_infos(calls: &[serde_json::Value]) -> Vec<ToolCallInfo> {
calls
.iter()
.map(|c| ToolCallInfo {
name: c["name"].as_str().unwrap_or("unknown").to_string(),
has_result: c.get("result_preview").is_some_and(|v| !v.is_null()),
has_error: c.get("error").is_some_and(|v| !v.is_null()),
result_preview: c["result_preview"].as_str().map(String::from),
error: c["error"].as_str().map(String::from),
rationale: c["rationale"].as_str().map(String::from),
})
.collect()
// Re-close <tool_output> if truncation cut through the closing tag.
if s.starts_with("<tool_output") && !result.ends_with("</tool_output>") {
result.push_str("\n</tool_output>");
}
result
}
/// Build TurnInfo pairs from flat DB messages (user/tool_calls/assistant triples).
@@ -42,7 +49,6 @@ pub fn build_turns_from_db_messages(
started_at: msg.created_at.to_rfc3339(),
completed_at: None,
tool_calls: Vec::new(),
narrative: None,
};
// Check if next message is a tool_calls record
@@ -50,28 +56,18 @@ pub fn build_turns_from_db_messages(
&& next.role == "tool_calls"
{
let tc_msg = iter.next().expect("peeked");
// Parse tool_calls JSON — supports two formats:
// safety: no byte-index slicing; comment describes JSON shape
match serde_json::from_str::<serde_json::Value>(&tc_msg.content) {
Ok(serde_json::Value::Array(calls)) => {
// Old format: plain array
turn.tool_calls = parse_tool_call_infos(&calls);
}
Ok(serde_json::Value::Object(obj)) => {
// New wrapped format with narrative
turn.narrative = obj
.get("narrative")
.and_then(|v| v.as_str())
.map(String::from);
if let Some(serde_json::Value::Array(calls)) = obj.get("calls") {
turn.tool_calls = parse_tool_call_infos(calls);
}
}
Ok(_) => {
tracing::warn!(
message_id = %tc_msg.id,
"Unexpected tool_calls JSON shape in DB, skipping"
);
match serde_json::from_str::<Vec<serde_json::Value>>(&tc_msg.content) {
Ok(calls) => {
turn.tool_calls = calls
.iter()
.map(|c| ToolCallInfo {
name: c["name"].as_str().unwrap_or("unknown").to_string(),
has_result: c.get("result_preview").is_some(),
has_error: c.get("error").is_some(),
result_preview: c["result_preview"].as_str().map(String::from),
error: c["error"].as_str().map(String::from),
})
.collect();
}
Err(e) => {
tracing::warn!(
@@ -109,7 +105,6 @@ pub fn build_turns_from_db_messages(
started_at: msg.created_at.to_rfc3339(),
completed_at: Some(msg.created_at.to_rfc3339()),
tool_calls: Vec::new(),
narrative: None,
});
turn_number += 1;
}
@@ -123,6 +118,88 @@ mod tests {
use super::*;
use uuid::Uuid;
// ---- truncate_preview tests ----
#[test]
fn test_truncate_preview_short_string() {
assert_eq!(truncate_preview("hello", 10), "hello");
}
#[test]
fn test_truncate_preview_exact_boundary() {
assert_eq!(truncate_preview("hello", 5), "hello");
}
#[test]
fn test_truncate_preview_truncates_ascii() {
assert_eq!(truncate_preview("hello world", 5), "hello...");
}
#[test]
fn test_truncate_preview_empty_string() {
assert_eq!(truncate_preview("", 10), "");
}
#[test]
fn test_truncate_preview_multibyte_char_boundary() {
// '€' is 3 bytes (E2 82 AC). "a€b" = [61, E2, 82, AC, 62] = 5 bytes
// Truncating at max_bytes=3 should not split the euro sign.
let s = "a€b";
let result = truncate_preview(s, 3);
// max_bytes=3 lands mid-€, so it walks back to byte 1 ("a")
assert_eq!(result, "a...");
}
#[test]
fn test_truncate_preview_emoji() {
// '🦀' is 4 bytes. "hi🦀" = 6 bytes
let s = "hi🦀";
let result = truncate_preview(s, 4);
// max_bytes=4 lands mid-🦀, walks back to byte 2 ("hi")
assert_eq!(result, "hi...");
}
#[test]
fn test_truncate_preview_cjk() {
// CJK characters are 3 bytes each. "你好世界" = 12 bytes
let s = "你好世界";
let result = truncate_preview(s, 7);
// max_bytes=7 lands mid-character (byte 7 is inside 世), walks back to 6 ("你好")
assert_eq!(result, "你好...");
}
#[test]
fn test_truncate_preview_zero_max_bytes() {
assert_eq!(truncate_preview("hello", 0), "...");
}
#[test]
fn test_truncate_preview_closes_tool_output_tag() {
let s = "<tool_output name=\"search\">\nSome very long content here\n</tool_output>";
// Truncate so it cuts before the closing tag
let result = truncate_preview(s, 60);
assert!(result.ends_with("</tool_output>"));
assert!(result.contains("..."));
}
#[test]
fn test_truncate_preview_no_extra_close_when_intact() {
let s = "<tool_output name=\"echo\">\nshort\n</tool_output>";
// The string is short enough not to be truncated
let result = truncate_preview(s, 500);
assert_eq!(result, s);
// Should not have a duplicate closing tag
assert_eq!(result.matches("</tool_output>").count(), 1);
}
#[test]
fn test_truncate_preview_non_xml_unaffected() {
let s = "Just a plain long string that gets truncated";
let result = truncate_preview(s, 10);
assert_eq!(result, "Just a pla...");
assert!(!result.contains("</tool_output>"));
}
// ---- build_turns_from_db_messages tests ----
fn make_msg(role: &str, content: &str, offset_ms: i64) -> crate::history::ConversationMessage {
@@ -228,52 +305,4 @@ mod tests {
assert!(turns[0].tool_calls.is_empty());
assert_eq!(turns[0].state, "Completed");
}
#[test]
fn test_build_turns_with_wrapped_tool_calls_format() {
let tc_json = serde_json::json!({
"narrative": "Searching memory for context before proceeding.",
"calls": [
{"name": "memory_search", "result_preview": "found 3 items", "rationale": "consult prior context"},
{"name": "shell", "error": "permission denied"}
]
});
let messages = vec![
make_msg("user", "Find info", 0),
make_msg("tool_calls", &tc_json.to_string(), 500),
make_msg("assistant", "Here's what I found", 1000),
];
let turns = build_turns_from_db_messages(&messages);
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0].narrative.as_deref(),
Some("Searching memory for context before proceeding.")
);
assert_eq!(turns[0].tool_calls.len(), 2);
assert_eq!(turns[0].tool_calls[0].name, "memory_search");
assert_eq!(
turns[0].tool_calls[0].rationale.as_deref(),
Some("consult prior context")
);
assert!(turns[0].tool_calls[0].has_result);
assert_eq!(turns[0].tool_calls[1].name, "shell");
assert!(turns[0].tool_calls[1].has_error);
assert_eq!(turns[0].response.as_deref(), Some("Here's what I found"));
}
#[test]
fn test_build_turns_wrapped_format_without_narrative() {
let tc_json = serde_json::json!({
"calls": [{"name": "echo", "result_preview": "hello"}]
});
let messages = vec![
make_msg("user", "Say hi", 0),
make_msg("tool_calls", &tc_json.to_string(), 500),
make_msg("assistant", "Done", 1000),
];
let turns = build_turns_from_db_messages(&messages);
assert_eq!(turns.len(), 1);
assert!(turns[0].narrative.is_none());
assert_eq!(turns[0].tool_calls.len(), 1);
}
}
+4 -4
View File
@@ -97,7 +97,7 @@ pub async fn handle_ws_connection(
let msg = tokio::select! {
event = event_stream.next() => {
match event {
Some(app_event) => WsServerMessage::from_app_event(&app_event),
Some(sse_event) => WsServerMessage::from_sse_event(&sse_event),
None => break, // Broadcast channel closed
}
}
@@ -275,7 +275,7 @@ async fn handle_client_message(
if result.verification.is_some() {
state.sse.broadcast_for_user(
user_id,
crate::channels::web::types::AppEvent::AuthRequired {
crate::channels::web::types::SseEvent::AuthRequired {
extension_name: extension_name.clone(),
instructions: Some(result.message),
auth_url: None,
@@ -286,7 +286,7 @@ async fn handle_client_message(
crate::channels::web::server::clear_auth_mode(state, user_id).await;
state.sse.broadcast_for_user(
user_id,
crate::channels::web::types::AppEvent::AuthCompleted {
crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success: true,
message: result.message,
@@ -299,7 +299,7 @@ async fn handle_client_message(
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast_for_user(
user_id,
crate::channels::web::types::AppEvent::AuthRequired {
crate::channels::web::types::SseEvent::AuthRequired {
extension_name: extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
+16 -270
View File
@@ -2,7 +2,6 @@
//!
//! Commands for installing, listing, removing, and authenticating WASM tools.
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -80,10 +79,6 @@ pub enum ToolCommand {
/// Directory to look for tool (default: ~/.ironclaw/tools/)
#[arg(short, long)]
dir: Option<PathBuf>,
/// User ID for checking credential status (default: "default")
#[arg(short, long, default_value = "default")]
user: String,
},
/// Configure authentication for a tool
@@ -129,11 +124,7 @@ pub async fn run_tool_command(cmd: ToolCommand) -> anyhow::Result<()> {
} => install_tool(path, name, capabilities, target, release, skip_build, force).await,
ToolCommand::List { dir, verbose } => list_tools(dir, verbose).await,
ToolCommand::Remove { name, dir } => remove_tool(name, dir).await,
ToolCommand::Info {
name_or_path,
dir,
user,
} => show_tool_info(name_or_path, dir, user).await,
ToolCommand::Info { name_or_path, dir } => show_tool_info(name_or_path, dir).await,
ToolCommand::Auth { name, dir, user } => auth_tool(name, dir, user).await,
ToolCommand::Setup { name, dir, user } => setup_tool(name, dir, user).await,
}
@@ -397,11 +388,7 @@ async fn remove_tool(name: String, dir: Option<PathBuf>) -> anyhow::Result<()> {
}
/// Show information about a tool.
async fn show_tool_info(
name_or_path: String,
dir: Option<PathBuf>,
user_id: String,
) -> anyhow::Result<()> {
async fn show_tool_info(name_or_path: String, dir: Option<PathBuf>) -> anyhow::Result<()> {
let wasm_path = if name_or_path.ends_with(".wasm") {
PathBuf::from(&name_or_path)
} else {
@@ -436,37 +423,7 @@ async fn show_tool_info(
println!("\nCapabilities ({}):", caps_path.display());
let content = fs::read_to_string(&caps_path).await?;
match CapabilitiesFile::from_json(&content) {
Ok(caps) => {
// Lazily init secrets store only when auth secrets need checking.
let has_auth = caps.auth.is_some()
|| caps
.setup
.as_ref()
.is_some_and(|s| !s.required_secrets.is_empty())
|| caps
.http
.as_ref()
.is_some_and(|h| !h.credentials.is_empty());
let secrets_store = if has_auth {
match init_secrets_store().await {
Ok(store) => Some(store),
Err(e) => {
eprintln!(" Warning: could not init secrets store: {}", e);
None
}
}
} else {
None
};
print_capabilities_detail(
&caps,
secrets_store
.as_ref()
.map(|s| s.as_ref() as &(dyn SecretsStore + Send + Sync)),
&user_id,
)
.await;
}
Ok(caps) => print_capabilities_detail(&caps),
Err(e) => println!(" Error parsing: {}", e),
}
} else {
@@ -519,89 +476,8 @@ fn print_capabilities_summary(caps: &CapabilitiesFile) {
}
}
/// Per-secret info collected from all auth-related capability sections.
struct AuthSecretInfo {
secret_name: String,
/// Human-readable label (from auth.display_name or setup prompt).
description: Option<String>,
/// Injection location (from http.credentials).
location: Option<String>,
}
/// Collected auth secrets and the set of secret names they cover.
struct CollectedAuthSecrets {
secrets: Vec<AuthSecretInfo>,
/// Secret names present in `secrets`, for filtering the Secrets capability section.
seen_names: HashSet<String>,
}
/// Collect and deduplicate auth secrets from all auth-related capability sections.
///
/// Priority for the description label: auth.display_name > setup.required_secrets.prompt.
/// Injection location is merged from http.credentials.
fn collect_auth_secrets(caps: &CapabilitiesFile) -> CollectedAuthSecrets {
let mut secrets: Vec<AuthSecretInfo> = Vec::new();
let mut seen: HashMap<String, usize> = HashMap::new();
// auth.display_name is the best label — seed first.
if let Some(ref auth) = caps.auth {
let index = secrets.len();
seen.insert(auth.secret_name.clone(), index);
secrets.push(AuthSecretInfo {
secret_name: auth.secret_name.clone(),
description: auth.display_name.clone(),
location: None,
});
}
// setup.required_secrets.prompt is second-best label.
if let Some(ref setup) = caps.setup {
for secret in &setup.required_secrets {
if !seen.contains_key(&secret.name) {
let index = secrets.len();
seen.insert(secret.name.clone(), index);
secrets.push(AuthSecretInfo {
secret_name: secret.name.clone(),
description: Some(secret.prompt.clone()),
location: None,
});
}
}
}
// Merge injection location from http.credentials.
if let Some(ref http) = caps.http {
for cred in http.credentials.values() {
let loc = format!("{:?}", cred.location);
if let Some(&index) = seen.get(&cred.secret_name) {
secrets[index].location = Some(loc);
} else {
let index = secrets.len();
seen.insert(cred.secret_name.clone(), index);
secrets.push(AuthSecretInfo {
secret_name: cred.secret_name.clone(),
description: None,
location: Some(loc),
});
}
}
}
let seen_names = seen.into_keys().collect();
CollectedAuthSecrets {
secrets,
seen_names,
}
}
/// Print detailed capabilities.
async fn print_capabilities_detail(
caps: &CapabilitiesFile,
secrets_store: Option<&(dyn SecretsStore + Send + Sync)>,
user_id: &str,
) {
let mut collected = collect_auth_secrets(caps);
fn print_capabilities_detail(caps: &CapabilitiesFile) {
if let Some(ref http) = caps.http {
println!(" HTTP:");
for endpoint in &http.allowlist {
@@ -614,6 +490,13 @@ async fn print_capabilities_detail(
println!(" {} {} {}", methods, endpoint.host, path);
}
if !http.credentials.is_empty() {
println!(" Credentials:");
for (key, cred) in &http.credentials {
println!(" {}: {} -> {:?}", key, cred.secret_name, cred.location);
}
}
if let Some(ref rate) = http.rate_limit {
println!(
" Rate limit: {}/min, {}/hour",
@@ -622,24 +505,12 @@ async fn print_capabilities_detail(
}
}
// Filter secrets already covered by the auth section (always rendered when non-empty).
if let Some(ref secrets) = caps.secrets
&& !secrets.allowed_names.is_empty()
{
let extra: Vec<_> = if collected.secrets.is_empty() {
secrets.allowed_names.iter().collect()
} else {
secrets
.allowed_names
.iter()
.filter(|name| !collected.seen_names.contains(name.as_str()))
.collect()
};
if !extra.is_empty() {
println!(" Secrets (existence check only):");
for name in extra {
println!(" {}", name);
}
println!(" Secrets (existence check only):");
for name in &secrets.allowed_names {
println!(" {}", name);
}
}
@@ -660,38 +531,6 @@ async fn print_capabilities_detail(
println!(" {}", prefix);
}
}
// Consolidated auth status — sorted by secret name for deterministic output.
if !collected.secrets.is_empty() {
collected
.secrets
.sort_by(|a, b| a.secret_name.cmp(&b.secret_name));
println!(" Auth:");
for info in &collected.secrets {
let (icon, label) = match secrets_store {
Some(store) => match store.exists(user_id, &info.secret_name).await {
Ok(true) => ("\u{2713}", "configured"),
Ok(false) => ("\u{2717}", "missing"),
Err(e) => {
eprintln!(
" Warning: failed to check secret `{}`: {}",
info.secret_name, e
);
("?", "unknown")
}
},
None => ("?", "unknown"),
};
let mut parts = info.secret_name.clone();
if let Some(ref desc) = info.description {
parts = format!("{} ({})", parts, desc);
}
if let Some(ref loc) = info.location {
parts = format!("{} -> {}", parts, loc);
}
println!(" {} {} {}", parts, icon, label);
}
}
}
/// Validate a tool name to prevent path traversal.
@@ -838,7 +677,8 @@ async fn combine_provider_scopes(
secret_name: &str,
base_oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> crate::tools::wasm::OAuthConfigSchema {
let mut all_scopes: HashSet<String> = base_oauth.scopes.iter().cloned().collect();
let mut all_scopes: std::collections::HashSet<String> =
base_oauth.scopes.iter().cloned().collect();
if let Ok(mut entries) = tokio::fs::read_dir(tools_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
@@ -1287,8 +1127,6 @@ async fn setup_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyh
#[cfg(test)]
mod tests {
use super::*;
use crate::secrets::{CreateSecretParams, SecretsStore};
use crate::testing::credentials::test_secrets_store;
#[test]
fn test_format_size() {
@@ -1305,96 +1143,4 @@ mod tests {
assert!(dir.to_string_lossy().contains(".ironclaw"));
assert!(dir.to_string_lossy().contains("tools"));
}
/// Verify that auth secrets are deduplicated across auth, setup, and http.credentials,
/// and that credential status is checked against the secrets store.
#[tokio::test]
async fn test_auth_secret_dedup_and_status() {
let caps = CapabilitiesFile::from_json(
r#"{
"auth": {
"secret_name": "gh_token",
"display_name": "GitHub"
},
"setup": {
"required_secrets": [
{ "name": "gh_token", "prompt": "GitHub PAT" },
{ "name": "extra_key", "prompt": "Extra API Key" }
]
},
"http": {
"allowlist": [{ "host": "api.github.com" }],
"credentials": {
"github": {
"secret_name": "gh_token",
"location": { "type": "bearer" },
"host_patterns": ["api.github.com"]
}
}
},
"secrets": {
"allowed_names": ["gh_token", "gh_*"]
}
}"#,
)
.unwrap();
let collected = collect_auth_secrets(&caps);
// gh_token should appear once (from auth), with location merged from credentials.
// extra_key should appear once (from setup).
assert_eq!(collected.secrets.len(), 2);
let gh = collected
.secrets
.iter()
.find(|s| s.secret_name == "gh_token")
.unwrap();
assert_eq!(gh.description.as_deref(), Some("GitHub"));
assert!(
gh.location.is_some(),
"location should be merged from http.credentials"
);
let extra = collected
.secrets
.iter()
.find(|s| s.secret_name == "extra_key")
.unwrap();
assert_eq!(extra.description.as_deref(), Some("Extra API Key"));
assert!(extra.location.is_none());
// Secrets section should filter gh_token (in seen_names) but keep gh_* (wildcard).
let secrets = caps.secrets.as_ref().unwrap();
let extra_secrets: Vec<_> = secrets
.allowed_names
.iter()
.filter(|name| !collected.seen_names.contains(name.as_str()))
.collect();
assert_eq!(extra_secrets, vec!["gh_*"]);
// Verify store check: missing secret -> exists returns false.
let store = test_secrets_store();
assert!(!store.exists("default", "gh_token").await.unwrap());
// Store gh_token and verify it's found.
store
.create(
"default",
CreateSecretParams::new("gh_token", "ghp_test123"),
)
.await
.unwrap();
assert!(store.exists("default", "gh_token").await.unwrap());
// extra_key still missing.
assert!(!store.exists("default", "extra_key").await.unwrap());
}
/// No auth sections → collect_auth_secrets returns empty.
#[test]
fn test_collect_auth_secrets_empty_caps() {
let caps = CapabilitiesFile::default();
let collected = collect_auth_secrets(&caps);
assert!(collected.secrets.is_empty());
assert!(collected.seen_names.is_empty());
}
}
+3 -3
View File
@@ -1118,7 +1118,7 @@ impl ExtensionManager {
/// Broadcast an extension status change to the web UI via SSE.
async fn broadcast_extension_status(&self, name: &str, status: &str, message: Option<&str>) {
if let Some(ref sse) = *self.sse_manager.read().await {
sse.broadcast(ironclaw_common::AppEvent::ExtensionStatus {
sse.broadcast(crate::channels::web::types::SseEvent::ExtensionStatus {
extension_name: name.to_string(),
status: status.to_string(),
message: message.map(|m| m.to_string()),
@@ -3288,7 +3288,7 @@ impl ExtensionManager {
}
.await;
// Broadcast auth result event
// Broadcast SSE event
let (success, message) = match result {
Ok(()) => (true, format!("{} authenticated successfully", display_name)),
Err(ref e) => (
@@ -3314,7 +3314,7 @@ impl ExtensionManager {
}
if let Some(ref sse) = sse_manager {
sse.broadcast(ironclaw_common::AppEvent::AuthCompleted {
sse.broadcast(crate::channels::web::types::SseEvent::AuthCompleted {
extension_name: ext_name,
success,
message,
-2
View File
@@ -575,7 +575,6 @@ fn extract_response_content(response: &AnthropicResponse) -> (Option<String>, Ve
id: id.clone(),
name: name.clone(),
arguments: input.clone(),
reasoning: None,
});
}
}
@@ -624,7 +623,6 @@ mod tests {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}),
reasoning: None,
}];
let messages = vec![
ChatMessage::user("Search for test"),
-7
View File
@@ -522,7 +522,6 @@ fn extract_content_blocks(
id: tu.tool_use_id().to_string(),
name: tu.name().to_string(),
arguments: document_to_json(tu.input()),
reasoning: None,
});
}
// Ignore reasoning, citations, images, etc.
@@ -760,13 +759,11 @@ mod tests {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({"text": "hi"}),
reasoning: None,
};
let tc2 = crate::llm::provider::ToolCall {
id: "call_2".to_string(),
name: "time".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
};
let messages = vec![
@@ -805,7 +802,6 @@ mod tests {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
reasoning: None,
};
let messages = vec![
@@ -829,7 +825,6 @@ mod tests {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
};
let messages = vec![
@@ -994,13 +989,11 @@ mod tests {
id: "call_abc".to_string(),
name: "get_weather".to_string(),
arguments: serde_json::json!({"city": "NYC"}),
reasoning: None,
};
let tc2 = crate::llm::provider::ToolCall {
id: "call_def".to_string(),
name: "get_time".to_string(),
arguments: serde_json::json!({"tz": "EST"}),
reasoning: None,
};
let messages = vec![
-2
View File
@@ -732,7 +732,6 @@ impl LlmProvider for CodexChatGptProvider {
id: tc.call_id,
name: tc.name,
arguments: args,
reasoning: None,
}
})
.collect();
@@ -826,7 +825,6 @@ mod tests {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: json!({"query": "rust"}),
reasoning: None,
};
let msg = ChatMessage::assistant_with_tool_calls(Some("thinking...".into()), vec![tc]);
let items = CodexChatGptProvider::message_to_input_items(&msg);
-1
View File
@@ -1898,7 +1898,6 @@ impl GeminiOauthProvider {
id,
name,
arguments: args,
reasoning: None,
});
}
}
-2
View File
@@ -596,7 +596,6 @@ fn extract_choice_content(choice: &OpenAiChoice) -> (Option<String>, Vec<ToolCal
name: tc.function.name.clone(),
arguments: serde_json::from_str(&tc.function.arguments)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
reasoning: None,
})
.collect()
})
@@ -629,7 +628,6 @@ mod tests {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}),
reasoning: None,
}];
let messages = vec![
ChatMessage::user("Search"),
+2 -75
View File
@@ -463,15 +463,8 @@ impl LlmProvider for NearAiChatProvider {
let model = req.model.unwrap_or_else(|| self.active_model_name());
let mut raw_messages = req.messages;
crate::llm::provider::sanitize_tool_messages(&mut raw_messages);
let raw: Vec<ChatCompletionMessage> = raw_messages.into_iter().map(|m| m.into()).collect();
// NEAR AI rejects `role:"tool"` messages even on text-only completion paths.
// Apply the same flattening used by complete_with_tools().
let messages = if self.flatten_tool_messages {
flatten_tool_messages(raw)
} else {
raw
};
let messages: Vec<ChatCompletionMessage> =
raw_messages.into_iter().map(|m| m.into()).collect();
let request = ChatCompletionRequest {
model,
@@ -587,7 +580,6 @@ impl LlmProvider for NearAiChatProvider {
id: tc.id,
name: tc.function.name,
arguments,
reasoning: None,
}
})
.collect();
@@ -1181,13 +1173,11 @@ mod tests {
id: "call_1".to_string(),
name: "list_issues".to_string(),
arguments: serde_json::json!({"owner": "foo", "repo": "bar"}),
reasoning: None,
},
ToolCall {
id: "call_2".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
reasoning: None,
},
];
@@ -1220,7 +1210,6 @@ mod tests {
id: "call_1".to_string(),
name: "test".to_string(),
arguments: serde_json::json!({"key": "value"}),
reasoning: None,
};
let msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]);
let chat_msg: ChatCompletionMessage = msg.into();
@@ -1464,7 +1453,6 @@ mod tests {
id: tc.id,
name: tc.function.name,
arguments,
reasoning: None,
}
})
.collect();
@@ -1514,7 +1502,6 @@ mod tests {
id: tc.id,
name: tc.function.name,
arguments,
reasoning: None,
}
})
.collect();
@@ -2137,7 +2124,6 @@ mod tests {
id: "call_1".to_string(),
name: "test".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
}],
);
let chat_msg: ChatCompletionMessage = msg.into();
@@ -2207,65 +2193,6 @@ mod tests {
assert_eq!(deserialized.function.arguments, r#"{"city":"London"}"#);
}
// -- flatten_tool_messages in complete() path ----------------------------
#[test]
fn test_flatten_applied_on_text_only_path() {
// Verify that flatten_tool_messages converts tool-role messages to user
// messages (mirrors the complete_with_tools path).
let messages = vec![
ChatCompletionMessage {
role: "user".to_string(),
content: Some(MessageContent::Text("run it".to_string())),
tool_call_id: None,
name: None,
tool_calls: None,
},
ChatCompletionMessage {
role: "tool".to_string(),
content: Some(MessageContent::Text("ok".to_string())),
tool_call_id: Some("call_1".to_string()),
name: Some("run_cmd".to_string()),
tool_calls: None,
},
];
let flattened = flatten_tool_messages(messages);
assert_eq!(flattened.len(), 2);
assert_eq!(flattened[1].role, "user");
let text = flattened[1]
.content
.as_ref()
.and_then(|c| c.as_text())
.unwrap();
assert!(text.contains("run_cmd"), "should reference tool name");
assert!(text.contains("ok"), "should include tool result");
}
#[test]
fn test_no_flatten_when_no_tool_messages() {
// When there are no tool-role messages, flatten_tool_messages is a no-op.
let messages = vec![
ChatCompletionMessage {
role: "user".to_string(),
content: Some(MessageContent::Text("hi".to_string())),
tool_call_id: None,
name: None,
tool_calls: None,
},
ChatCompletionMessage {
role: "assistant".to_string(),
content: Some(MessageContent::Text("hello".to_string())),
tool_call_id: None,
name: None,
tool_calls: None,
},
];
let result = flatten_tool_messages(messages);
// No tool messages → unchanged roles
assert_eq!(result[0].role, "user");
assert_eq!(result[1].role, "assistant");
}
// -- api_url edge cases ---------------------------------------------------
#[test]
-5
View File
@@ -625,7 +625,6 @@ fn parse_sse_response(body: &str) -> Result<ParsedResponse, LlmError> {
id: state.call_id,
name: state.name,
arguments,
reasoning: None,
});
} else {
// Fallback: extract directly from the item
@@ -651,7 +650,6 @@ fn parse_sse_response(body: &str) -> Result<ParsedResponse, LlmError> {
id: call_id,
name,
arguments,
reasoning: None,
});
}
}
@@ -729,7 +727,6 @@ fn parse_sse_response(body: &str) -> Result<ParsedResponse, LlmError> {
id: state.call_id,
name: state.name,
arguments,
reasoning: None,
});
}
}
@@ -825,13 +822,11 @@ mod tests {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
reasoning: None,
},
ToolCall {
id: "call_2".to_string(),
name: "read".to_string(),
arguments: serde_json::json!({"path": "/tmp"}),
reasoning: None,
},
];
let msg =
-8
View File
@@ -231,10 +231,6 @@ pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
/// Optional reasoning for why this tool was chosen — supplied by the provider
/// or derived from the shared response content as a fallback.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
}
/// Generate a tool-call ID that satisfies all providers.
@@ -641,7 +637,6 @@ mod tests {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
};
let mut messages = vec![
ChatMessage::user("hello"),
@@ -685,7 +680,6 @@ mod tests {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: serde_json::json!({}),
reasoning: None,
};
let mut messages = vec![
ChatMessage::user("test"),
@@ -711,13 +705,11 @@ mod tests {
id: "call_sel_1".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}),
reasoning: None,
};
let tc2 = ToolCall {
id: "call_sel_2".to_string(),
name: "http".to_string(),
arguments: serde_json::json!({"url": "https://example.com"}),
reasoning: None,
};
let mut messages = vec![
ChatMessage::system("You are a helpful assistant."),
+12 -85
View File
@@ -525,35 +525,17 @@ impl Reasoning {
let response = self.llm.complete_with_tools(request).await?;
let shared_reasoning = response
.content
.map(|c| {
let pre_truncated = truncate_at_tool_tags(&c);
clean_response(&pre_truncated)
})
.unwrap_or_default();
let reasoning = response.content.unwrap_or_default();
let selections: Vec<ToolSelection> = response
.tool_calls
.into_iter()
.map(|tool_call| {
// Prefer per-tool reasoning if the provider supplied it,
// otherwise fall back to the shared response content.
let rationale = tool_call
.reasoning
.map(|r| {
let pre_truncated = truncate_at_tool_tags(&r);
clean_response(&pre_truncated)
})
.filter(|r| !r.trim().is_empty())
.unwrap_or_else(|| shared_reasoning.clone());
ToolSelection {
tool_name: tool_call.name,
parameters: tool_call.arguments,
reasoning: rationale,
alternatives: vec![],
tool_call_id: tool_call.id,
}
.map(|tool_call| ToolSelection {
tool_name: tool_call.name,
parameters: tool_call.arguments,
reasoning: reasoning.clone(),
alternatives: vec![],
tool_call_id: tool_call.id,
})
.collect();
@@ -682,36 +664,13 @@ Respond in JSON format:
// If there were tool calls, return them for execution
if !response.tool_calls.is_empty() {
let narrative = response.content.map(|c| {
let pre_truncated = truncate_at_tool_tags(&c);
clean_response(&pre_truncated)
});
// Populate per-tool reasoning from the shared narrative when the
// provider did not supply per-tool rationale.
let tool_calls: Vec<ToolCall> = response
.tool_calls
.into_iter()
.map(|mut tc| {
if tc.reasoning.as_ref().is_none_or(|r| r.trim().is_empty()) {
tc.reasoning = narrative.as_ref().filter(|n| !n.is_empty()).cloned();
} else {
// Clean provider-supplied per-tool reasoning the same way
// we clean the shared narrative (strip thinking/tool tags).
tc.reasoning = tc
.reasoning
.map(|r| {
let pre_truncated = truncate_at_tool_tags(&r);
clean_response(&pre_truncated)
})
.filter(|r| !r.trim().is_empty());
}
tc
})
.collect();
return Ok(RespondOutput {
result: RespondResult::ToolCalls {
tool_calls,
content: narrative,
tool_calls: response.tool_calls,
content: response.content.map(|c| {
let pre_truncated = truncate_at_tool_tags(&c);
clean_response(&pre_truncated)
}),
},
usage,
});
@@ -1391,7 +1350,6 @@ fn recover_tool_calls_from_content(
),
name: name.to_string(),
arguments,
reasoning: None,
});
continue;
}
@@ -1406,7 +1364,6 @@ fn recover_tool_calls_from_content(
),
name: name.to_string(),
arguments: serde_json::Value::Object(Default::default()),
reasoning: None,
});
}
}
@@ -1444,7 +1401,6 @@ fn recover_tool_calls_from_content(
),
name: name.to_string(),
arguments,
reasoning: None,
});
remaining = &args_start[bracket_end + 1..];
continue;
@@ -1456,7 +1412,6 @@ fn recover_tool_calls_from_content(
id: super::provider::generate_tool_call_id(calls.len(), RECOVERED_TOOL_CALL_SEED),
name: name.to_string(),
arguments: serde_json::Value::Object(Default::default()),
reasoning: None,
});
remaining = after_name;
}
@@ -3190,32 +3145,4 @@ That's my plan."#;
"Text <function_call>{}</function_call> middle "
);
}
/// Verify that reasoning normalization strips thinking tags and tool tags
/// from per-tool reasoning, matching the cleaning applied to shared reasoning.
#[test]
fn test_reasoning_normalization_strips_thinking_tags() {
let raw = "<thinking>Let me consider...</thinking>Search memory for prior context";
let pre_truncated = truncate_at_tool_tags(raw);
let cleaned = clean_response(&pre_truncated);
assert!(!cleaned.contains("<thinking>"));
assert!(cleaned.contains("Search memory"));
}
#[test]
fn test_reasoning_normalization_strips_tool_tags() {
let raw = "Calling search <tool_call>{\"name\": \"search\"}";
let pre_truncated = truncate_at_tool_tags(raw);
let cleaned = clean_response(&pre_truncated);
assert!(!cleaned.contains("<tool_call>"));
assert!(cleaned.contains("Calling search"));
}
#[test]
fn test_reasoning_normalization_empty_after_cleaning() {
let raw = "<thinking>internal only</thinking>";
let pre_truncated = truncate_at_tool_tags(raw);
let cleaned = clean_response(&pre_truncated);
assert!(cleaned.trim().is_empty());
}
}
-7
View File
@@ -490,7 +490,6 @@ fn extract_response(
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: tc.function.arguments.clone(),
reasoning: None,
});
}
// Reasoning and Image variants are not mapped to IronClaw types
@@ -881,7 +880,6 @@ mod tests {
id: "Xt7mK9pQ2".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
reasoning: None,
};
let msg = ChatMessage::assistant_with_tool_calls(Some("thinking".to_string()), vec![tc]);
let messages = vec![msg];
@@ -999,7 +997,6 @@ mod tests {
id: "".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
reasoning: None,
};
let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])];
let (_preamble, history) = convert_messages(&messages);
@@ -1031,7 +1028,6 @@ mod tests {
id: " ".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
reasoning: None,
};
let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])];
let (_preamble, history) = convert_messages(&messages);
@@ -1065,7 +1061,6 @@ mod tests {
id: "".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}),
reasoning: None,
};
let assistant_msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]);
let tool_result_msg = ChatMessage {
@@ -1385,13 +1380,11 @@ mod tests {
id: "call_a".to_string(),
name: "search".to_string(),
arguments: serde_json::json!({"q": "rust"}),
reasoning: None,
};
let tc2 = IronToolCall {
id: "call_b".to_string(),
name: "fetch".to_string(),
arguments: serde_json::json!({"url": "https://example.com"}),
reasoning: None,
};
let assistant = ChatMessage::assistant_with_tool_calls(None, vec![tc1, tc2]);
let result_a = ChatMessage::tool_result("call_a", "search", "search results");
+14 -29
View File
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, broadcast};
use uuid::Uuid;
use crate::channels::web::types::ToolDecisionDto;
use crate::channels::web::types::SseEvent;
use crate::db::Database;
use crate::llm::{CompletionRequest, LlmProvider, ToolCompletionRequest};
use crate::orchestrator::auth::{TokenStore, worker_auth_middleware};
@@ -25,7 +25,6 @@ use crate::worker::api::{
CompletionReport, CredentialResponse, JobDescription, ProxyCompletionRequest,
ProxyCompletionResponse, ProxyToolCompletionRequest, ProxyToolCompletionResponse, StatusUpdate,
};
use ironclaw_common::AppEvent;
/// A follow-up prompt queued for a Claude Code bridge.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -42,7 +41,7 @@ pub struct OrchestratorState {
pub token_store: TokenStore,
/// Broadcast channel for job events (consumed by the web gateway SSE).
/// Tuple: (job_id, user_id, event).
pub job_event_tx: Option<broadcast::Sender<(Uuid, String, AppEvent)>>,
pub job_event_tx: Option<broadcast::Sender<(Uuid, String, SseEvent)>>,
/// Buffered follow-up prompts for sandbox jobs, keyed by job_id.
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<PendingPrompt>>>>,
/// Database handle for persisting job events.
@@ -278,10 +277,10 @@ async fn job_event_handler(
});
}
// Convert to app event and broadcast
// Convert to SSE event and broadcast
let job_id_str = job_id.to_string();
let app_event = match payload.event_type.as_str() {
"message" => AppEvent::JobMessage {
let sse_event = match payload.event_type.as_str() {
"message" => SseEvent::JobMessage {
job_id: job_id_str,
role: payload
.data
@@ -296,7 +295,7 @@ async fn job_event_handler(
.unwrap_or("")
.to_string(),
},
"tool_use" => AppEvent::JobToolUse {
"tool_use" => SseEvent::JobToolUse {
job_id: job_id_str,
tool_name: payload
.data
@@ -310,7 +309,7 @@ async fn job_event_handler(
.cloned()
.unwrap_or(serde_json::Value::Null),
},
"tool_result" => AppEvent::JobToolResult {
"tool_result" => SseEvent::JobToolResult {
job_id: job_id_str,
tool_name: payload
.data
@@ -325,7 +324,7 @@ async fn job_event_handler(
.unwrap_or("")
.to_string(),
},
"result" => AppEvent::JobResult {
"result" => SseEvent::JobResult {
job_id: job_id_str,
status: payload
.data
@@ -345,21 +344,7 @@ async fn job_event_handler(
// gain context/memory tracking capabilities.
fallback_deliverable: payload.data.get("fallback_deliverable").cloned(),
},
"reasoning" => {
let narrative = payload
.data
.get("narrative")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let decisions = ToolDecisionDto::from_json_array(&payload.data["decisions"]);
AppEvent::JobReasoning {
job_id: job_id_str,
narrative,
decisions,
}
}
_ => AppEvent::JobStatus {
_ => SseEvent::JobStatus {
job_id: job_id_str,
message: payload
.data
@@ -405,9 +390,9 @@ async fn job_event_handler(
};
if user_id.is_empty() {
let _ = tx.send((job_id, String::new(), app_event));
let _ = tx.send((job_id, String::new(), sse_event));
} else {
let _ = tx.send((job_id, user_id, app_event));
let _ = tx.send((job_id, user_id, sse_event));
}
}
@@ -832,7 +817,7 @@ mod tests {
// No store configured, so user_id falls back to empty string.
assert_eq!(recv_uid, "");
match event {
AppEvent::JobMessage {
SseEvent::JobMessage {
job_id: jid,
role,
content,
@@ -887,7 +872,7 @@ mod tests {
let (_recv_id, _recv_uid, event) = rx.recv().await.unwrap();
match event {
AppEvent::JobToolUse { tool_name, .. } => {
SseEvent::JobToolUse { tool_name, .. } => {
assert_eq!(tool_name, "shell");
}
other => panic!("Expected JobToolUse, got {:?}", other),
@@ -933,7 +918,7 @@ mod tests {
let (_recv_id, _recv_uid, event) = rx.recv().await.unwrap();
// Unknown event types fall through to JobStatus
assert!(matches!(event, AppEvent::JobStatus { .. }));
assert!(matches!(event, SseEvent::JobStatus { .. }));
}
// -- Status update test --
+2 -2
View File
@@ -46,10 +46,10 @@ use std::sync::Arc;
use tokio::sync::{Mutex, broadcast};
use uuid::Uuid;
use crate::channels::web::types::SseEvent;
use crate::db::Database;
use crate::llm::LlmProvider;
use crate::secrets::SecretsStore;
use ironclaw_common::AppEvent;
/// Resolve the orchestrator port from the `ORCHESTRATOR_PORT` environment
/// variable, falling back to 50051.
@@ -63,7 +63,7 @@ fn resolve_orchestrator_port() -> u16 {
/// Result of orchestrator setup, containing all handles needed by the agent.
pub struct OrchestratorSetup {
pub container_job_manager: Option<Arc<ContainerJobManager>>,
pub job_event_tx: Option<broadcast::Sender<(Uuid, String, AppEvent)>>,
pub job_event_tx: Option<broadcast::Sender<(Uuid, String, SseEvent)>>,
pub prompt_queue: Arc<Mutex<HashMap<Uuid, VecDeque<api::PendingPrompt>>>>,
pub docker_status: crate::sandbox::DockerStatus,
}
+3 -3
View File
@@ -17,6 +17,7 @@ use uuid::Uuid;
use crate::bootstrap::ironclaw_base_dir;
use crate::channels::IncomingMessage;
use crate::channels::web::types::SseEvent;
use crate::context::{ContextManager, JobContext, JobState};
use crate::db::Database;
use crate::history::SandboxJobRecord;
@@ -24,7 +25,6 @@ use crate::orchestrator::auth::CredentialGrant;
use crate::orchestrator::job_manager::{ContainerJobManager, JobMode};
use crate::secrets::SecretsStore;
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
use ironclaw_common::AppEvent;
/// Lazy scheduler reference, filled after Agent::new creates the Scheduler.
///
@@ -85,7 +85,7 @@ pub struct CreateJobTool {
job_manager: Option<Arc<ContainerJobManager>>,
store: Option<Arc<dyn Database>>,
/// Broadcast sender for job events (used to subscribe a monitor).
event_tx: Option<tokio::sync::broadcast::Sender<(Uuid, String, AppEvent)>>,
event_tx: Option<tokio::sync::broadcast::Sender<(Uuid, String, SseEvent)>>,
/// Injection channel for pushing messages into the agent loop.
inject_tx: Option<tokio::sync::mpsc::Sender<IncomingMessage>>,
/// Encrypted secrets store for validating credential grants.
@@ -120,7 +120,7 @@ impl CreateJobTool {
/// monitor that forwards Claude Code output to the main agent loop.
pub fn with_monitor_deps(
mut self,
event_tx: tokio::sync::broadcast::Sender<(Uuid, String, AppEvent)>,
event_tx: tokio::sync::broadcast::Sender<(Uuid, String, SseEvent)>,
inject_tx: tokio::sync::mpsc::Sender<IncomingMessage>,
) -> Self {
self.event_tx = Some(event_tx);
+1 -15
View File
@@ -915,7 +915,7 @@ fn parse_routine_create_request(
fn build_routine_trigger(trigger: &NormalizedTriggerRequest) -> Trigger {
match trigger {
NormalizedTriggerRequest::Cron { schedule, timezone } => Trigger::Cron {
schedule: normalize_cron_expression(schedule),
schedule: schedule.clone(),
timezone: timezone.clone(),
},
NormalizedTriggerRequest::Manual => Trigger::Manual,
@@ -1836,20 +1836,6 @@ mod tests {
assert_eq!(parsed.cooldown_secs, 30);
}
#[test]
fn build_routine_trigger_normalizes_cron_schedule() {
let trigger = build_routine_trigger(&NormalizedTriggerRequest::Cron {
schedule: "0 0 9 * * MON-FRI".to_string(),
timezone: Some("UTC".to_string()),
});
assert!(matches!(
trigger,
Trigger::Cron { schedule, timezone }
if schedule == "0 0 9 * * MON-FRI *" && timezone.as_deref() == Some("UTC")
));
}
#[test]
fn parses_grouped_message_event_with_tools() {
let params = serde_json::json!({
+5 -1
View File
@@ -383,7 +383,11 @@ impl ToolRegistry {
job_manager: Option<Arc<ContainerJobManager>>,
store: Option<Arc<dyn Database>>,
job_event_tx: Option<
tokio::sync::broadcast::Sender<(uuid::Uuid, String, ironclaw_common::AppEvent)>,
tokio::sync::broadcast::Sender<(
uuid::Uuid,
String,
crate::channels::web::types::SseEvent,
)>,
>,
inject_tx: Option<tokio::sync::mpsc::Sender<crate::channels::IncomingMessage>>,
prompt_queue: Option<PromptQueue>,
+1 -51
View File
@@ -1,7 +1,5 @@
//! Shared utility functions used across the codebase.
use crate::llm::{ChatMessage, Role};
/// Find the largest valid UTF-8 char boundary at or before `pos`.
///
/// Polyfill for `str::floor_char_boundary` (nightly-only). Use when
@@ -18,17 +16,6 @@ pub fn floor_char_boundary(s: &str, pos: usize) -> usize {
i
}
/// Ensure the last message in `messages` is a user-role message.
///
/// NEAR AI rejects conversations that don't end with a user message;
/// Claude 4.6 rejects assistant prefill. Call this before any LLM
/// completion request to satisfy both requirements.
pub fn ensure_ends_with_user_message(messages: &mut Vec<ChatMessage>) {
if !matches!(messages.last(), Some(m) if m.role == Role::User) {
messages.push(ChatMessage::user("Continue."));
}
}
/// Check if an LLM response explicitly signals that a job/task is complete.
///
/// Uses phrase-level matching to avoid false positives from bare words like
@@ -85,8 +72,7 @@ pub fn llm_signals_completion(response: &str) -> bool {
#[cfg(test)]
mod tests {
use crate::llm::ChatMessage;
use crate::util::{ensure_ends_with_user_message, floor_char_boundary, llm_signals_completion};
use crate::util::{floor_char_boundary, llm_signals_completion};
// ── floor_char_boundary ──
@@ -117,42 +103,6 @@ mod tests {
assert_eq!(floor_char_boundary("", 5), 0);
}
// ── ensure_ends_with_user_message ──
#[test]
fn ensure_user_message_injects_when_empty() {
let mut msgs: Vec<ChatMessage> = vec![];
ensure_ends_with_user_message(&mut msgs);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, crate::llm::Role::User);
}
#[test]
fn ensure_user_message_injects_after_assistant() {
let mut msgs = vec![ChatMessage::user("hi"), ChatMessage::assistant("hello")];
ensure_ends_with_user_message(&mut msgs);
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[2].role, crate::llm::Role::User);
}
#[test]
fn ensure_user_message_injects_after_tool_result() {
let mut msgs = vec![
ChatMessage::user("run tool"),
ChatMessage::tool_result("call_1", "my_tool", "result"),
];
ensure_ends_with_user_message(&mut msgs);
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[2].role, crate::llm::Role::User);
}
#[test]
fn ensure_user_message_no_op_when_already_user() {
let mut msgs = vec![ChatMessage::user("hello")];
ensure_ends_with_user_message(&mut msgs);
assert_eq!(msgs.len(), 1);
}
// ── llm_signals_completion ──
#[test]
+1 -5
View File
@@ -151,7 +151,7 @@ Job: {}
Description: {}
You have tools for shell commands, file operations, and code editing.
Work independently to complete this job. When finished, your final message MUST include the phrase "The job is complete" to signal termination."#,
Work independently to complete this job. Report when done."#,
job.title, job.description
)));
@@ -373,10 +373,6 @@ impl LoopDelegate for ContainerDelegate {
// Poll for follow-up prompts from the user
self.poll_and_inject_prompt(reason_ctx).await;
// Claude 4.6 rejects assistant prefill; NEAR AI rejects any non-user-ending
// conversation. Ensure the last message is user-role before calling the LLM.
crate::util::ensure_ends_with_user_message(&mut reason_ctx.messages);
// Refresh tools (in case WASM tools were built)
reason_ctx.available_tools = self.tools.tool_definitions().await;
+8 -79
View File
@@ -18,7 +18,7 @@ use crate::agent::agentic_loop::{
};
use crate::agent::scheduler::WorkerMessage;
use crate::agent::task::TaskOutput;
use crate::channels::web::types::ToolDecisionDto;
use crate::channels::web::types::SseEvent;
use crate::context::{ContextManager, JobState};
use crate::db::Database;
use crate::error::Error;
@@ -33,7 +33,6 @@ use crate::tools::rate_limiter::RateLimitResult;
use crate::tools::{
ApprovalContext, ToolRegistry, autonomous_unavailable_error, prepare_tool_params, redact_params,
};
use ironclaw_common::AppEvent;
/// Shared dependencies for worker execution.
///
@@ -49,7 +48,7 @@ pub struct WorkerDeps {
pub hooks: Arc<HookRegistry>,
pub timeout: Duration,
pub use_planning: bool,
/// Broadcast sender for live job event streaming to the web gateway.
/// SSE manager for live job event streaming to the web gateway.
pub sse_tx: Option<Arc<crate::channels::web::sse::SseManager>>,
/// Approval context for tool execution. When `None`, all non-`Never` tools are
/// blocked (legacy behavior). When `Some`, the context determines which tools
@@ -142,7 +141,7 @@ impl Worker {
if let Some(ref sse) = self.deps.sse_tx {
let job_id_str = job_id.to_string();
let event = match event_type {
"message" => Some(AppEvent::JobMessage {
"message" => Some(SseEvent::JobMessage {
job_id: job_id_str,
role: data
.get("role")
@@ -155,7 +154,7 @@ impl Worker {
.unwrap_or("")
.to_string(),
}),
"tool_use" => Some(AppEvent::JobToolUse {
"tool_use" => Some(SseEvent::JobToolUse {
job_id: job_id_str,
tool_name: data
.get("tool_name")
@@ -167,7 +166,7 @@ impl Worker {
.cloned()
.unwrap_or(serde_json::Value::Null),
}),
"tool_result" => Some(AppEvent::JobToolResult {
"tool_result" => Some(SseEvent::JobToolResult {
job_id: job_id_str,
tool_name: data
.get("tool_name")
@@ -180,7 +179,7 @@ impl Worker {
.unwrap_or("")
.to_string(),
}),
"status" => Some(AppEvent::JobStatus {
"status" => Some(SseEvent::JobStatus {
job_id: job_id_str,
message: data
.get("message")
@@ -188,7 +187,7 @@ impl Worker {
.unwrap_or("")
.to_string(),
}),
"result" => Some(AppEvent::JobResult {
"result" => Some(SseEvent::JobResult {
job_id: job_id_str,
status: data
.get("status")
@@ -201,19 +200,6 @@ impl Worker {
.map(|s| s.to_string()),
fallback_deliverable: data.get("fallback_deliverable").cloned(),
}),
"reasoning" => {
let narrative = data
.get("narrative")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let decisions = ToolDecisionDto::from_json_array(&data["decisions"]);
Some(AppEvent::JobReasoning {
job_id: job_id_str,
narrative,
decisions,
})
}
_ => None,
};
if let Some(event) = event {
@@ -911,11 +897,6 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
id: selection.tool_call_id.clone(),
name: selection.tool_name.clone(),
arguments: selection.parameters.clone(),
reasoning: if action.reasoning.is_empty() {
None
} else {
Some(action.reasoning.clone())
},
}],
));
@@ -1251,11 +1232,6 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
) -> Option<LoopOutcome> {
// Refresh tool definitions so newly built tools become visible
reason_ctx.available_tools = self.worker.tools().tool_definitions().await;
// Claude 4.6 rejects assistant prefill; NEAR AI rejects any non-user-ending
// conversation. Ensure the last message is user-role before calling the LLM.
crate::util::ensure_ends_with_user_message(&mut reason_ctx.messages);
None
}
@@ -1376,48 +1352,6 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
);
}
// Emit reasoning event if any tool calls carry reasoning.
// Sanitize narrative and per-tool rationale through SafetyLayer
// (parity with ChatDelegate in dispatcher.rs).
let sanitized_narrative = content
.as_deref()
.filter(|c| !c.trim().is_empty())
.map(|c| {
self.worker
.deps
.safety
.sanitize_tool_output("job_narrative", c)
.content
})
.filter(|c| !c.trim().is_empty())
.unwrap_or_default();
let decisions: Vec<serde_json::Value> = tool_calls
.iter()
.filter_map(|tc| {
tc.reasoning.as_ref().map(|r| {
let sanitized = self
.worker
.deps
.safety
.sanitize_tool_output("tool_rationale", r)
.content;
serde_json::json!({
"tool_name": tc.name,
"rationale": sanitized,
})
})
})
.collect();
if !decisions.is_empty() {
self.worker.log_event(
"reasoning",
serde_json::json!({
"narrative": sanitized_narrative,
"decisions": decisions,
}),
);
}
// Add assistant message with tool_calls (OpenAI protocol)
reason_ctx
.messages
@@ -1432,7 +1366,7 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
.map(|tc| ToolSelection {
tool_name: tc.name.clone(),
parameters: tc.arguments.clone(),
reasoning: tc.reasoning.clone().unwrap_or_default(),
reasoning: String::new(),
alternatives: vec![],
tool_call_id: tc.id.clone(),
})
@@ -1485,11 +1419,6 @@ fn selections_to_tool_calls(selections: &[ToolSelection]) -> Vec<ToolCall> {
id: s.tool_call_id.clone(),
name: s.tool_name.clone(),
arguments: s.parameters.clone(),
reasoning: if s.reasoning.is_empty() {
None
} else {
Some(s.reasoning.clone())
},
})
.collect()
}
-55
View File
@@ -149,7 +149,6 @@ fn reject_if_injected(path: &str, content: &str) -> Result<(), WorkspaceError> {
///
/// Allows Workspace to work with either a PostgreSQL `Repository` (the original
/// path) or any `Database` trait implementation (e.g. libSQL backend).
#[derive(Clone)]
enum WorkspaceStorage {
/// PostgreSQL-backed repository (uses connection pool directly).
#[cfg(feature = "postgres")]
@@ -577,60 +576,6 @@ impl Workspace {
self
}
/// Clone the workspace configuration for a different primary user scope.
///
/// This preserves search config, embeddings, shared read scopes, memory
/// layers, and privacy classifier while switching the primary read/write
/// scope to `user_id`.
pub fn scoped_to_user(&self, user_id: impl Into<String>) -> Self {
let user_id = user_id.into();
let mut memory_layers = self.memory_layers.clone();
for layer in &mut memory_layers {
if layer.sensitivity == crate::workspace::layer::LayerSensitivity::Private
&& layer.scope == self.user_id
{
layer.scope = user_id.clone();
}
}
let mut read_user_ids = vec![user_id.clone()];
for scope in &self.read_user_ids {
if scope != &self.user_id && !read_user_ids.contains(scope) {
read_user_ids.push(scope.clone());
}
}
for scope in crate::workspace::layer::MemoryLayer::read_scopes(&memory_layers) {
if !read_user_ids.contains(&scope) {
read_user_ids.push(scope);
}
}
let preserve_flags = user_id == self.user_id;
Self {
user_id,
read_user_ids,
agent_id: self.agent_id,
storage: self.storage.clone(),
embeddings: self.embeddings.clone(),
bootstrap_pending: std::sync::atomic::AtomicBool::new(if preserve_flags {
self.bootstrap_pending
.load(std::sync::atomic::Ordering::Acquire)
} else {
false
}),
bootstrap_completed: std::sync::atomic::AtomicBool::new(if preserve_flags {
self.bootstrap_completed
.load(std::sync::atomic::Ordering::Acquire)
} else {
false
}),
search_defaults: self.search_defaults.clone(),
memory_layers,
privacy_classifier: self.privacy_classifier.clone(),
}
}
/// Get the user ID (primary scope for writes).
pub fn user_id(&self) -> &str {
&self.user_id
-1
View File
@@ -15,7 +15,6 @@ use crate::workspace::document::{MemoryChunk, MemoryDocument, WorkspaceEntry};
use crate::workspace::search::{RankedResult, SearchConfig, SearchResult, fuse_results};
/// Database repository for workspace operations.
#[derive(Clone)]
pub struct Repository {
pool: Pool,
}
@@ -253,6 +253,6 @@ async def test_telegram_hot_activation_transitions_installed_to_active(page):
assert await card.locator(SEL["ext_pairing_label"]).count() == 0
assert captured_setup_payloads == [
{"secrets": {"telegram_bot_token": "123456789:ABCdefGhI"}, "fields": {}},
{"secrets": {}, "fields": {}},
{"secrets": {"telegram_bot_token": "123456789:ABCdefGhI"}},
{"secrets": {}},
]
+2 -3
View File
@@ -587,7 +587,6 @@ mod advanced {
async fn mcp_extension_lifecycle() {
use crate::support::mock_mcp_server::{MockToolResponse, start_mock_mcp_server};
use ironclaw::extensions::{AuthHint, ExtensionKind, ExtensionSource, RegistryEntry};
const TEST_USER_ID: &str = "test-user";
// 1. Start mock MCP server with pre-configured tool responses.
let mock_server = start_mock_mcp_server(vec![
@@ -655,14 +654,14 @@ mod advanced {
ext_mgr
.secrets()
.create(
TEST_USER_ID,
"default",
ironclaw::secrets::CreateSecretParams::new(secret_name, "mock-access-token")
.with_provider("mcp:mock-notion".to_string()),
)
.await
.expect("failed to inject test token");
let activate_result = ext_mgr.activate("mock-notion", TEST_USER_ID).await;
let activate_result = ext_mgr.activate("mock-notion", "default").await;
assert!(
activate_result.is_ok(),
"activation failed: {:?}",
+1 -1
View File
@@ -439,7 +439,7 @@ mod tests {
match &routine.trigger {
Trigger::Cron { schedule, timezone } => {
assert_eq!(schedule, "0 0 9 * * MON-FRI *");
assert_eq!(schedule, "0 0 9 * * MON-FRI");
assert_eq!(timezone.as_deref(), Some("UTC"));
}
other => panic!("expected cron trigger, got {other:?}"),
+1 -3
View File
@@ -12,7 +12,6 @@ mod tests {
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::LlmTrace;
use ironclaw::workspace::Workspace;
// -----------------------------------------------------------------------
// Test 1: write_chunk_search
@@ -269,7 +268,6 @@ mod tests {
#[tokio::test]
async fn identity_in_system_prompt() {
const TEST_USER_ID: &str = "test-user";
let trace = LlmTrace::from_file(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/llm_traces/workspace/identity_prompt.json"
@@ -282,7 +280,7 @@ mod tests {
.await;
// Seed an IDENTITY.md so the system prompt has real content to inject.
let ws = Workspace::new_with_db(TEST_USER_ID, rig.database().clone());
let ws = rig.workspace().expect("workspace must be available");
ws.write(
"IDENTITY.md",
"I am TestBot, a helpful testing assistant created for E2E verification.",
+23 -23
View File
@@ -307,7 +307,7 @@ fn per_user_rate_limiter_single_user_mode() {
#[tokio::test]
async fn sse_scoped_event_only_delivered_to_target_user() {
use ironclaw_common::AppEvent;
use ironclaw::channels::web::types::SseEvent;
use tokio_stream::StreamExt;
let manager = SseManager::new();
@@ -325,34 +325,34 @@ async fn sse_scoped_event_only_delivered_to_target_user() {
// Send event scoped to alice
manager.broadcast_for_user(
ALICE_USER_ID,
AppEvent::Status {
SseEvent::Status {
message: "alice's event".to_string(),
thread_id: None,
},
);
// Send global heartbeat (both should get it)
manager.broadcast(AppEvent::Heartbeat);
manager.broadcast(SseEvent::Heartbeat);
// Alice gets her scoped event first
let e = alice_stream.next().await.unwrap();
match &e {
AppEvent::Status { message, .. } => assert_eq!(message, "alice's event"),
SseEvent::Status { message, .. } => assert_eq!(message, "alice's event"),
_ => panic!("Expected Status, got {:?}", e),
}
// Alice also gets heartbeat
let e = alice_stream.next().await.unwrap();
assert!(matches!(e, AppEvent::Heartbeat));
assert!(matches!(e, SseEvent::Heartbeat));
// Bob only gets the heartbeat (alice's event was filtered)
let e = bob_stream.next().await.unwrap();
assert!(matches!(e, AppEvent::Heartbeat));
assert!(matches!(e, SseEvent::Heartbeat));
}
#[tokio::test]
async fn sse_global_event_delivered_to_all_users() {
use ironclaw_common::AppEvent;
use ironclaw::channels::web::types::SseEvent;
use tokio_stream::StreamExt;
let manager = SseManager::new();
@@ -367,7 +367,7 @@ async fn sse_global_event_delivered_to_all_users() {
.expect("subscribe"),
);
manager.broadcast(AppEvent::Status {
manager.broadcast(SseEvent::Status {
message: "global announcement".to_string(),
thread_id: None,
});
@@ -375,7 +375,7 @@ async fn sse_global_event_delivered_to_all_users() {
let ea = alice.next().await.unwrap();
let eb = bob.next().await.unwrap();
match (&ea, &eb) {
(AppEvent::Status { message: a, .. }, AppEvent::Status { message: b, .. }) => {
(SseEvent::Status { message: a, .. }, SseEvent::Status { message: b, .. }) => {
assert_eq!(a, "global announcement");
assert_eq!(b, "global announcement");
}
@@ -385,7 +385,7 @@ async fn sse_global_event_delivered_to_all_users() {
#[tokio::test]
async fn sse_user_b_event_not_visible_to_user_a() {
use ironclaw_common::AppEvent;
use ironclaw::channels::web::types::SseEvent;
use tokio_stream::StreamExt;
let manager = SseManager::new();
@@ -398,19 +398,19 @@ async fn sse_user_b_event_not_visible_to_user_a() {
// Send event for bob only
manager.broadcast_for_user(
BOB_USER_ID,
AppEvent::Response {
SseEvent::Response {
content: "bob's secret".to_string(),
thread_id: "t1".to_string(),
},
);
// Send heartbeat so alice has something to receive
manager.broadcast(AppEvent::Heartbeat);
manager.broadcast(SseEvent::Heartbeat);
// Alice should only get heartbeat, not bob's response
let e = alice.next().await.unwrap();
assert!(
matches!(e, AppEvent::Heartbeat),
matches!(e, SseEvent::Heartbeat),
"Expected Heartbeat, got {:?}",
e
);
@@ -418,7 +418,7 @@ async fn sse_user_b_event_not_visible_to_user_a() {
#[tokio::test]
async fn sse_unscoped_subscriber_receives_all_events() {
use ironclaw_common::AppEvent;
use ironclaw::channels::web::types::SseEvent;
use tokio_stream::StreamExt;
let manager = SseManager::new();
@@ -427,19 +427,19 @@ async fn sse_unscoped_subscriber_receives_all_events() {
manager.broadcast_for_user(
ALICE_USER_ID,
AppEvent::Status {
SseEvent::Status {
message: "alice only".to_string(),
thread_id: None,
},
);
manager.broadcast_for_user(
BOB_USER_ID,
AppEvent::Status {
SseEvent::Status {
message: "bob only".to_string(),
thread_id: None,
},
);
manager.broadcast(AppEvent::Heartbeat);
manager.broadcast(SseEvent::Heartbeat);
// Unscoped subscriber gets ALL three events
let e1 = stream.next().await.unwrap();
@@ -447,14 +447,14 @@ async fn sse_unscoped_subscriber_receives_all_events() {
let e3 = stream.next().await.unwrap();
match &e1 {
AppEvent::Status { message, .. } => assert_eq!(message, "alice only"),
SseEvent::Status { message, .. } => assert_eq!(message, "alice only"),
_ => panic!("Expected alice's Status"),
}
match &e2 {
AppEvent::Status { message, .. } => assert_eq!(message, "bob only"),
SseEvent::Status { message, .. } => assert_eq!(message, "bob only"),
_ => panic!("Expected bob's Status"),
}
assert!(matches!(e3, AppEvent::Heartbeat));
assert!(matches!(e3, SseEvent::Heartbeat));
}
// ===========================================================================
@@ -881,7 +881,7 @@ async fn full_server_jobs_endpoint_rejected_without_auth() {
#[tokio::test]
async fn full_server_ws_multi_user_event_isolation() {
use futures::StreamExt;
use ironclaw_common::AppEvent;
use ironclaw::channels::web::types::SseEvent;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
@@ -914,14 +914,14 @@ async fn full_server_ws_multi_user_event_isolation() {
// Broadcast an event scoped to Alice only
state.sse.broadcast_for_user(
ALICE_USER_ID,
AppEvent::Status {
SseEvent::Status {
message: "alice-only-event".to_string(),
thread_id: None,
},
);
// Broadcast a global heartbeat so Bob has something to receive
state.sse.broadcast(AppEvent::Heartbeat);
state.sse.broadcast(SseEvent::Heartbeat);
// Alice should get her scoped event
let alice_msg = tokio::time::timeout(Duration::from_secs(2), alice_ws.next())
+7 -7
View File
@@ -1,10 +1,10 @@
//! Regression tests for multi-tenant system prompts.
//! Tests proving that multi-tenant system prompts are broken.
//!
//! The agent must build the conversational system prompt from a workspace
//! scoped to the incoming message's user, not from the shared owner-scope
//! workspace created at startup. Otherwise per-user identity files
//! (IDENTITY.md, SOUL.md, USER.md) become invisible and different users can
//! see the same owner-scoped prompt.
//! Bug: In multi-tenant mode, the agent loop uses `self.workspace()` which
//! returns a single shared workspace (user_id="default"). Identity files
//! (IDENTITY.md, SOUL.md, USER.md) seeded under per-user IDs ("alice",
//! "bob") are invisible to this workspace, so the system prompt is
//! empty/wrong.
//!
//! These tests:
//! 1. Seed identity files for two users (alice, bob) in the database
@@ -13,7 +13,7 @@
//! correct user's identity
//! 4. Verify user A's identity doesn't leak into user B's prompt
//!
//! These tests ensure each user's identity is isolated correctly.
//! All tests are expected to FAIL until the bug is fixed.
#[cfg(feature = "libsql")]
mod support;
-1
View File
@@ -94,7 +94,6 @@ impl LlmProvider for MockLlmProvider {
id: "call_mock_001".to_string(),
name: tool.name.clone(),
arguments: serde_json::json!({"test": true}),
reasoning: None,
}],
input_tokens: 15,
output_tokens: 8,
-1
View File
@@ -566,7 +566,6 @@ impl LlmProvider for TraceLlm {
id: tc.id,
name: tc.name,
arguments: tc.arguments,
reasoning: None,
})
.collect();
Ok(ToolCompletionResponse {
+9 -9
View File
@@ -5,7 +5,7 @@
//! - WebSocket upgrade with auth
//! - Ping/pong
//! - Client message → agent msg_tx
//! - Broadcast AppEvent → WebSocket client
//! - Broadcast SSE event → WebSocket client
//! - Connection tracking (counter increment/decrement)
//! - Gateway status endpoint
@@ -22,8 +22,8 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use ironclaw::channels::IncomingMessage;
use ironclaw::channels::web::server::{GatewayState, start_server};
use ironclaw::channels::web::sse::SseManager;
use ironclaw::channels::web::types::SseEvent;
use ironclaw::channels::web::ws::WsConnectionTracker;
use ironclaw_common::AppEvent;
const AUTH_TOKEN: &str = "test-token-12345";
const TIMEOUT: Duration = Duration::from_secs(5);
@@ -164,8 +164,8 @@ async fn test_ws_broadcast_event_received() {
// Give the connection a moment to fully establish
tokio::time::sleep(Duration::from_millis(50)).await;
// Broadcast an event (simulates agent sending a response)
state.sse.broadcast(AppEvent::Response {
// Broadcast an SSE event (simulates agent sending a response)
state.sse.broadcast(SseEvent::Response {
content: "agent says hi".to_string(),
thread_id: "t1".to_string(),
});
@@ -186,7 +186,7 @@ async fn test_ws_thinking_event() {
let mut ws = connect_ws(addr).await;
tokio::time::sleep(Duration::from_millis(50)).await;
state.sse.broadcast(AppEvent::Thinking {
state.sse.broadcast(SseEvent::Thinking {
message: "analyzing...".to_string(),
thread_id: None,
});
@@ -311,22 +311,22 @@ async fn test_ws_multiple_events_in_sequence() {
tokio::time::sleep(Duration::from_millis(50)).await;
// Broadcast multiple events rapidly
state.sse.broadcast(AppEvent::Thinking {
state.sse.broadcast(SseEvent::Thinking {
message: "step 1".to_string(),
thread_id: None,
});
state.sse.broadcast(AppEvent::ToolStarted {
state.sse.broadcast(SseEvent::ToolStarted {
name: "shell".to_string(),
thread_id: None,
});
state.sse.broadcast(AppEvent::ToolCompleted {
state.sse.broadcast(SseEvent::ToolCompleted {
name: "shell".to_string(),
success: true,
error: None,
parameters: None,
thread_id: None,
});
state.sse.broadcast(AppEvent::Response {
state.sse.broadcast(SseEvent::Response {
content: "done".to_string(),
thread_id: "t1".to_string(),
});