diff --git a/.env.example b/.env.example index 4ed81838..876d8e99 100644 --- a/.env.example +++ b/.env.example @@ -6,21 +6,19 @@ DATABASE_POOL_SIZE=10 # LLM_BACKEND=nearai # default # Possible values: nearai, ollama, openai_compatible, openai, anthropic, tinfoil -# === NEAR AI Chat (Responses API, session token auth) === -# Default mode. Uses browser OAuth (GitHub/Google) on first run. -# Session token stored in ~/.ironclaw/session.json automatically. -# For hosting providers: set NEARAI_SESSION_TOKEN env var directly. +# === NEAR AI (Chat Completions API) === +# Two auth modes: +# 1. Session token (default): Uses browser OAuth (GitHub/Google) on first run. +# Session token stored in ~/.ironclaw/session.json automatically. +# Base URL defaults to https://private.near.ai +# 2. API key: Set NEARAI_API_KEY to use API key auth from cloud.near.ai. +# Base URL defaults to https://cloud-api.near.ai NEARAI_MODEL=zai-org/GLM-5-FP8 NEARAI_BASE_URL=https://private.near.ai NEARAI_AUTH_URL=https://private.near.ai # NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this # NEARAI_SESSION_PATH=~/.ironclaw/session.json # optional, default shown - -# === NEAR AI Cloud (Chat Completions API, API key auth) === -# Auto-selected when NEARAI_API_KEY is set. Get a key from cloud.near.ai. -# NEARAI_API_KEY=... -# NEARAI_BASE_URL=https://cloud-api.near.ai # default for cloud mode -# NEARAI_API_MODE=chat_completions # auto-detected from API key +# NEARAI_API_KEY=... # API key from cloud.near.ai # Local LLM Providers (Ollama, LM Studio, vLLM, LiteLLM) diff --git a/CLAUDE.md b/CLAUDE.md index d77565ed..38e96deb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,8 +120,7 @@ src/ ├── llm/ # LLM integration (multi-provider) │ ├── mod.rs # Provider factory, LlmBackend enum │ ├── provider.rs # LlmProvider trait, message types -│ ├── nearai.rs # NEAR AI Responses API provider -│ ├── nearai_chat.rs # NEAR AI Chat Completions fallback +│ ├── nearai_chat.rs # NEAR AI Chat Completions provider (session token + API key auth) │ ├── reasoning.rs # Planning, tool selection, evaluation │ ├── session.rs # Session token management with auto-renewal │ ├── circuit_breaker.rs # Circuit breaker for provider failures @@ -339,13 +338,12 @@ LIBSQL_PATH=~/.ironclaw/ironclaw.db # libSQL local path (default) # LIBSQL_AUTH_TOKEN=xxx # Required with LIBSQL_URL # NEAR AI (when LLM_BACKEND=nearai, the default) -# Two modes: "NEAR AI Chat" (session token) or "NEAR AI Cloud" (API key) -# NEAR AI Chat (Responses API, default): -NEARAI_SESSION_TOKEN=sess_... # session token for chat-api +# Two auth modes: session token (default) or API key +# Session token auth (default): uses browser OAuth on first run +NEARAI_SESSION_TOKEN=sess_... # hosting providers: set this NEARAI_BASE_URL=https://private.near.ai -# NEAR AI Cloud (Chat Completions API, auto-selected when API key is set): +# API key auth: set NEARAI_API_KEY, base URL defaults to cloud-api.near.ai # NEARAI_API_KEY=... # API key from cloud.near.ai -# NEARAI_BASE_URL=https://cloud-api.near.ai NEARAI_MODEL=claude-3-5-sonnet-20241022 # Agent settings @@ -408,11 +406,9 @@ TINFOIL_MODEL=kimi-k2-5 # Default model IronClaw supports multiple LLM backends via the `LLM_BACKEND` env var: `nearai` (default), `openai`, `anthropic`, `ollama`, `openai_compatible`, and `tinfoil`. -**NEAR AI Chat** -- Uses the NEAR AI Responses API (`https://private.near.ai/v1/responses`). Authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google). Supports response chaining (delta-only follow-up messages) for efficient multi-turn conversations. This is the default mode when no `NEARAI_API_KEY` is set. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment. Configure with `NEARAI_BASE_URL` (default: `https://private.near.ai`). +**NEAR AI** -- Uses the Chat Completions API with dual auth support. Session token auth (default): authenticates with session tokens (`sess_xxx`) obtained via browser OAuth (GitHub/Google), base URL defaults to `https://private.near.ai`. API key auth: set `NEARAI_API_KEY` (from `cloud.near.ai`), base URL defaults to `https://cloud-api.near.ai`. Both modes use the same Chat Completions endpoint. Tool messages are flattened to plain text for compatibility. Set `NEARAI_SESSION_TOKEN` env var for hosting providers that inject tokens via environment. -**NEAR AI Cloud** -- Uses the OpenAI-compatible Chat Completions API (`https://cloud-api.near.ai/v1/chat/completions`). Authenticates with API keys from `cloud.near.ai`. Auto-selected when `NEARAI_API_KEY` is set (or explicitly via `NEARAI_API_MODE=chat_completions`). Tool messages are flattened to plain text for compatibility. Configure with `NEARAI_API_KEY` and `NEARAI_BASE_URL` (default: `https://cloud-api.near.ai`). - -**Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API only (not the Responses API, so tool calls are adapted to chat format). Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`). +**Tinfoil** -- Private inference via `https://inference.tinfoil.sh/v1`. Runs models inside hardware-attested TEEs so neither Tinfoil nor the cloud provider can see prompts or responses. Uses the OpenAI-compatible Chat Completions API. Configure with `TINFOIL_API_KEY` and `TINFOIL_MODEL` (default: `kimi-k2-5`). ## Database diff --git a/benchmarks/src/instrumented_llm.rs b/benchmarks/src/instrumented_llm.rs index 165261a8..7b846e7d 100644 --- a/benchmarks/src/instrumented_llm.rs +++ b/benchmarks/src/instrumented_llm.rs @@ -182,7 +182,6 @@ mod tests { input_tokens: 100, output_tokens: 50, finish_reason: FinishReason::Stop, - response_id: None, }) } @@ -196,7 +195,6 @@ mod tests { input_tokens: 200, output_tokens: 100, finish_reason: FinishReason::Stop, - response_id: None, }) } } diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 6d4a9553..734d62b9 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -358,10 +358,6 @@ impl Agent { } }); - tracing::info!( - "Heartbeat enabled with {}s interval", - hb_config.interval_secs - ); let hygiene = self .hygiene_config .as_ref() @@ -373,6 +369,7 @@ impl Agent { hygiene, workspace.clone(), self.cheap_llm().clone(), + self.safety().clone(), Some(notify_tx), )) } else { diff --git a/src/agent/commands.rs b/src/agent/commands.rs index 8a754062..2661fed1 100644 --- a/src/agent/commands.rs +++ b/src/agent/commands.rs @@ -13,7 +13,7 @@ use crate::agent::submission::SubmissionResult; use crate::agent::{Agent, MessageIntent}; use crate::channels::{IncomingMessage, StatusUpdate}; use crate::error::Error; -use crate::llm::ChatMessage; +use crate::llm::{ChatMessage, Reasoning}; impl Agent { /// Handle job-related intents without turn tracking. @@ -235,6 +235,7 @@ impl Agent { crate::workspace::hygiene::HygieneConfig::default(), workspace.clone(), self.llm().clone(), + self.safety().clone(), ); match runner.check_heartbeat().await { @@ -295,10 +296,11 @@ impl Agent { .with_max_tokens(512) .with_temperature(0.3); - match self.llm().complete(request).await { - Ok(response) => Ok(SubmissionResult::response(format!( + let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone()); + match reasoning.complete(request).await { + Ok((text, _usage)) => Ok(SubmissionResult::response(format!( "Thread Summary:\n\n{}", - response.content.trim() + text.trim() ))), Err(e) => Ok(SubmissionResult::error(format!("Summarize failed: {}", e))), } @@ -342,10 +344,11 @@ impl Agent { .with_max_tokens(512) .with_temperature(0.5); - match self.llm().complete(request).await { - Ok(response) => Ok(SubmissionResult::response(format!( + let reasoning = Reasoning::new(self.llm().clone(), self.safety().clone()); + match reasoning.complete(request).await { + Ok((text, _usage)) => Ok(SubmissionResult::response(format!( "Suggested Next Steps:\n\n{}", - response.content.trim() + text.trim() ))), Err(e) => Ok(SubmissionResult::error(format!("Suggest failed: {}", e))), } diff --git a/src/agent/compaction.rs b/src/agent/compaction.rs index 6e9479b6..573e1ebd 100644 --- a/src/agent/compaction.rs +++ b/src/agent/compaction.rs @@ -12,7 +12,8 @@ use chrono::Utc; use crate::agent::context_monitor::{CompactionStrategy, ContextBreakdown}; use crate::agent::session::Thread; use crate::error::Error; -use crate::llm::{ChatMessage, CompletionRequest, LlmProvider}; +use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning}; +use crate::safety::SafetyLayer; use crate::workspace::Workspace; /// Result of a compaction operation. @@ -33,12 +34,13 @@ pub struct CompactionResult { /// Compacts conversation context to stay within limits. pub struct ContextCompactor { llm: Arc, + safety: Arc, } impl ContextCompactor { /// Create a new context compactor. - pub fn new(llm: Arc) -> Self { - Self { llm } + pub fn new(llm: Arc, safety: Arc) -> Self { + Self { llm, safety } } /// Compact a thread's context using the given strategy. @@ -231,8 +233,9 @@ Be brief but capture all important details. Use bullet points."#, .with_max_tokens(1024) .with_temperature(0.3); - let response = self.llm.complete(request).await?; - Ok(response.content) + let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone()); + let (text, _) = reasoning.complete(request).await?; + Ok(text) } /// Write a summary to the workspace daily log. diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index fcead248..856e2772 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -109,10 +109,17 @@ impl Agent { let job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); const MAX_TOOL_ITERATIONS: usize = 10; + // Force a text-only response on the last iteration to guarantee termination + // instead of hard-erroring. The penultimate iteration also gets a nudge + // message so the LLM knows it should wrap up. + const FORCE_TEXT_AT: usize = MAX_TOOL_ITERATIONS; + const NUDGE_AT: usize = MAX_TOOL_ITERATIONS - 1; let mut iteration = 0; loop { iteration += 1; - if iteration > MAX_TOOL_ITERATIONS { + // Hard ceiling one past the forced-text iteration (should never be reached + // since FORCE_TEXT_AT guarantees a text response, but kept as a safety net). + if iteration > MAX_TOOL_ITERATIONS + 1 { return Err(crate::error::LlmError::InvalidResponse { provider: "agent".to_string(), reason: format!("Exceeded maximum tool iterations ({})", MAX_TOOL_ITERATIONS), @@ -143,6 +150,19 @@ impl Agent { .into()); } + // Inject a nudge message when approaching the iteration limit so the + // LLM is aware it should produce a final answer on the next turn. + if iteration == NUDGE_AT { + context_messages.push(ChatMessage::system( + "You are approaching the tool call limit. \ + Provide your best final answer on the next response \ + using the information you have gathered so far. \ + Do not call any more tools.", + )); + } + + let force_text = iteration >= FORCE_TEXT_AT; + // Refresh tool definitions each iteration so newly built tools become visible let tool_defs = self.tools().tool_definitions().await; @@ -162,8 +182,9 @@ impl Agent { tool_defs }; - // Call LLM with current context - let context = ReasoningContext::new() + // Call LLM with current context; force_text drops tools to guarantee a + // text response on the final iteration. + let mut context = ReasoningContext::new() .with_messages(context_messages.clone()) .with_tools(tool_defs) .with_metadata({ @@ -171,6 +192,14 @@ impl Agent { m.insert("thread_id".to_string(), thread_id.to_string()); m }); + context.force_text = force_text; + + if force_text { + tracing::info!( + iteration, + "Forcing text-only response (iteration limit reached)" + ); + } let output = reasoning.respond_with_tools(&context).await?; @@ -799,7 +828,6 @@ mod tests { input_tokens: 0, output_tokens: 0, finish_reason: FinishReason::Stop, - response_id: None, }) } @@ -813,7 +841,6 @@ mod tests { input_tokens: 0, output_tokens: 0, finish_reason: FinishReason::Stop, - response_id: None, }) } } diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index e495b3f3..a78bc263 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -29,7 +29,8 @@ use std::time::Duration; use tokio::sync::mpsc; use crate::channels::OutgoingResponse; -use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; +use crate::llm::{ChatMessage, CompletionRequest, LlmProvider, Reasoning}; +use crate::safety::SafetyLayer; use crate::workspace::Workspace; use crate::workspace::hygiene::HygieneConfig; @@ -100,6 +101,7 @@ pub struct HeartbeatRunner { hygiene_config: HygieneConfig, workspace: Arc, llm: Arc, + safety: Arc, response_tx: Option>, consecutive_failures: u32, } @@ -111,12 +113,14 @@ impl HeartbeatRunner { hygiene_config: HygieneConfig, workspace: Arc, llm: Arc, + safety: Arc, ) -> Self { Self { config, hygiene_config, workspace, llm, + safety, response_tx: None, consecutive_failures: 0, } @@ -258,25 +262,18 @@ impl HeartbeatRunner { .with_max_tokens(max_tokens) .with_temperature(0.3); - let response = match self.llm.complete(request).await { + let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone()); + let (content, _usage) = match reasoning.complete(request).await { Ok(r) => r, Err(e) => return HeartbeatResult::Failed(format!("LLM call failed: {}", e)), }; - let content = response.content.trim(); + let content = content.trim(); // Guard against empty content. Reasoning models (e.g. GLM-4.7) may // burn all output tokens on chain-of-thought and return content: null. if content.is_empty() { - return if response.finish_reason == FinishReason::Length { - HeartbeatResult::Failed( - "LLM response was truncated (finish_reason=length) with no content. \ - The model may have exhausted its token budget on reasoning." - .to_string(), - ) - } else { - HeartbeatResult::Failed("LLM returned empty content.".to_string()) - }; + return HeartbeatResult::Failed("LLM returned empty content.".to_string()); } // Check if nothing needs attention @@ -355,9 +352,10 @@ pub fn spawn_heartbeat( hygiene_config: HygieneConfig, workspace: Arc, llm: Arc, + safety: Arc, response_tx: Option>, ) -> tokio::task::JoinHandle<()> { - let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm); + let mut runner = HeartbeatRunner::new(config, hygiene_config, workspace, llm, safety); if let Some(tx) = response_tx { runner = runner.with_response_channel(tx); } diff --git a/src/agent/session.rs b/src/agent/session.rs index 87a1e1e4..070a0ad4 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -185,10 +185,6 @@ pub struct Thread { /// Pending auth token request (thread is in auth mode). #[serde(default)] pub pending_auth: Option, - /// Last NEAR AI response ID for response chaining. Persisted to DB - /// metadata so we can resume chaining across restarts. - #[serde(default)] - pub last_response_id: Option, } impl Thread { @@ -205,7 +201,6 @@ impl Thread { metadata: serde_json::Value::Null, pending_approval: None, pending_auth: None, - last_response_id: None, } } @@ -222,7 +217,6 @@ impl Thread { metadata: serde_json::Value::Null, pending_approval: None, pending_auth: None, - last_response_id: None, } } @@ -863,7 +857,6 @@ mod tests { thread.start_turn("hello"); thread.complete_turn("world"); - thread.last_response_id = Some("resp_abc123".to_string()); let json = serde_json::to_string(&thread).unwrap(); let restored: Thread = serde_json::from_str(&json).unwrap(); @@ -873,7 +866,6 @@ mod tests { assert_eq!(restored.turns.len(), 1); assert_eq!(restored.turns[0].user_input, "hello"); assert_eq!(restored.turns[0].response, Some("world".to_string())); - assert_eq!(restored.last_response_id, Some("resp_abc123".to_string())); } #[test] diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index c1d6442f..a30e8372 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -87,20 +87,6 @@ impl Agent { thread.restore_from_messages(chat_messages); } - // Restore response chain from conversation metadata - if let Some(store) = self.store() - && let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await - && let Some(rid) = metadata - .get("last_response_id") - .and_then(|v| v.as_str()) - .map(String::from) - { - thread.last_response_id = Some(rid.clone()); - self.llm() - .seed_response_chain(&thread_uuid.to_string(), rid); - tracing::debug!("Restored response chain for thread {}", thread_uuid); - } - // Insert into session and register with session manager { let mut sess = session.lock().await; @@ -228,7 +214,7 @@ impl Agent { ) .await; - let compactor = ContextCompactor::new(self.llm().clone()); + let compactor = ContextCompactor::new(self.llm().clone(), self.safety().clone()); if let Err(e) = compactor .compact(thread, strategy, self.workspace().map(|w| w.as_ref())) .await @@ -325,7 +311,6 @@ impl Agent { }; thread.complete_turn(&response); - self.persist_response_chain(thread); let _ = self .channels .send_status( @@ -335,8 +320,10 @@ impl Agent { ) .await; - // Fire-and-forget: persist turn to DB - self.persist_turn(thread_id, &message.user_id, content, Some(&response)); + // Persist turn to DB before returning so the write + // completes even if the process shuts down right after. + self.persist_turn(thread_id, &message.user_id, content, Some(&response)) + .await; Ok(SubmissionResult::response(response)) } @@ -366,15 +353,16 @@ impl Agent { thread.fail_turn(e.to_string()); // Persist the user message even on failure - self.persist_turn(thread_id, &message.user_id, content, None); + self.persist_turn(thread_id, &message.user_id, content, None) + .await; Ok(SubmissionResult::error(e.to_string())) } } } - /// Fire-and-forget: persist a turn (user message + optional assistant response) to the DB. - pub(super) fn persist_turn( + /// Persist a turn (user message + optional assistant response) to the DB. + pub(super) async fn persist_turn( &self, thread_id: Uuid, user_id: &str, @@ -386,70 +374,29 @@ impl Agent { None => return, }; - let user_id = user_id.to_string(); - let user_input = user_input.to_string(); - let response = response.map(String::from); + if let Err(e) = store + .ensure_conversation(thread_id, "gateway", user_id, None) + .await + { + tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e); + return; + } - tokio::spawn(async move { - if let Err(e) = store - .ensure_conversation(thread_id, "gateway", &user_id, None) + if let Err(e) = store + .add_conversation_message(thread_id, "user", user_input) + .await + { + tracing::warn!("Failed to persist user message: {}", e); + return; + } + + if let Some(resp) = response + && let Err(e) = store + .add_conversation_message(thread_id, "assistant", resp) .await - { - tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e); - return; - } - - if let Err(e) = store - .add_conversation_message(thread_id, "user", &user_input) - .await - { - tracing::warn!("Failed to persist user message: {}", e); - return; - } - - if let Some(ref resp) = response - && let Err(e) = store - .add_conversation_message(thread_id, "assistant", resp) - .await - { - tracing::warn!("Failed to persist assistant message: {}", e); - } - }); - } - - /// Sync the provider's response chain ID to the thread and DB metadata. - /// - /// Call after a successful agentic loop to persist the latest - /// `previous_response_id` so chaining survives restarts. - pub(super) fn persist_response_chain(&self, thread: &mut crate::agent::session::Thread) { - let tid = thread.id.to_string(); - let response_id = match self.llm().get_response_chain_id(&tid) { - Some(rid) => rid, - None => return, - }; - - // Update in-memory thread - thread.last_response_id = Some(response_id.clone()); - - // Fire-and-forget DB write - let store = match self.store() { - Some(s) => Arc::clone(s), - None => return, - }; - let thread_id = thread.id; - tokio::spawn(async move { - let val = serde_json::json!(response_id); - if let Err(e) = store - .update_conversation_metadata_field(thread_id, "last_response_id", &val) - .await - { - tracing::warn!( - "Failed to persist response chain for thread {}: {}", - thread_id, - e - ); - } - }); + { + tracing::warn!("Failed to persist assistant message: {}", e); + } } pub(super) async fn process_undo( @@ -562,7 +509,7 @@ impl Agent { crate::agent::context_monitor::CompactionStrategy::Summarize { keep_recent: 5 }, ); - let compactor = ContextCompactor::new(self.llm().clone()); + let compactor = ContextCompactor::new(self.llm().clone(), self.safety().clone()); match compactor .compact(thread, strategy, self.workspace().map(|w| w.as_ref())) .await @@ -1072,9 +1019,9 @@ impl Agent { let user_input = thread.last_turn().map(|t| t.user_input.clone()); thread.complete_turn(&response); if let Some(input) = user_input { - self.persist_turn(thread_id, &message.user_id, &input, Some(&response)); + self.persist_turn(thread_id, &message.user_id, &input, Some(&response)) + .await; } - self.persist_response_chain(thread); let _ = self .channels .send_status( @@ -1112,7 +1059,8 @@ impl Agent { let user_input = thread.last_turn().map(|t| t.user_input.clone()); thread.fail_turn(e.to_string()); if let Some(input) = user_input { - self.persist_turn(thread_id, &message.user_id, &input, None); + self.persist_turn(thread_id, &message.user_id, &input, None) + .await; } Ok(SubmissionResult::error(e.to_string())) } @@ -1131,7 +1079,8 @@ impl Agent { thread.clear_pending_approval(); thread.complete_turn(&rejection); if let Some(input) = user_input { - self.persist_turn(thread_id, &message.user_id, &input, Some(&rejection)); + self.persist_turn(thread_id, &message.user_id, &input, Some(&rejection)) + .await; } } } @@ -1171,9 +1120,9 @@ impl Agent { thread.enter_auth_mode(ext_name.clone()); thread.complete_turn(&instructions); if let Some(input) = user_input { - self.persist_turn(thread_id, &message.user_id, &input, Some(&instructions)); + self.persist_turn(thread_id, &message.user_id, &input, Some(&instructions)) + .await; } - self.persist_response_chain(thread); } } let _ = self diff --git a/src/app.rs b/src/app.rs index 4a7e60d5..dc97e33b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -396,7 +396,6 @@ impl AppBuilder { let tools = Arc::new(ToolRegistry::new()); tools.register_builtin_tools(); - tracing::info!("Registered {} built-in tools", tools.count()); // Create embeddings provider if configured let embeddings: Option> = if self.config.embeddings.enabled { @@ -681,12 +680,12 @@ impl AppBuilder { None }; - // Register dev tools if local tools are enabled - if self.config.agent.allow_local_tools { + // register_builder_tool() already calls register_dev_tools() internally, + // so only register them here when the builder didn't already do it. + let builder_registered_dev_tools = self.config.builder.enabled + && (self.config.agent.allow_local_tools || !self.config.sandbox.enabled); + if self.config.agent.allow_local_tools && !builder_registered_dev_tools { tools.register_dev_tools(); - tracing::info!( - "Local tools enabled (allow_local_tools=true), dev tools registered directly" - ); } Ok((mcp_session_manager, wasm_tool_runtime, extension_manager)) @@ -709,9 +708,6 @@ impl AppBuilder { // Seed workspace and backfill embeddings if let Some(ref ws) = workspace { match ws.seed_if_empty().await { - Ok(count) if count > 0 => { - tracing::info!("Workspace seeded with {} core files", count); - } Ok(_) => {} Err(e) => { tracing::warn!("Failed to seed workspace: {}", e); diff --git a/src/channels/web/log_layer.rs b/src/channels/web/log_layer.rs index 3a55b994..d072d7f5 100644 --- a/src/channels/web/log_layer.rs +++ b/src/channels/web/log_layer.rs @@ -22,7 +22,9 @@ use std::sync::{Arc, Mutex}; use serde::Serialize; use tokio::sync::broadcast; use tracing::field::{Field, Visit}; -use tracing_subscriber::Layer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer, reload}; use crate::safety::LeakDetector; @@ -102,6 +104,115 @@ impl Default for LogBroadcaster { } } +/// Handle for changing the tracing `EnvFilter` at runtime. +/// +/// Wraps a `reload::Handle` so the gateway can switch between log levels +/// (e.g. `ironclaw=debug`) without restarting the process. +pub struct LogLevelHandle { + handle: reload::Handle, + current_level: Mutex, + base_filter: String, +} + +impl LogLevelHandle { + pub fn new( + handle: reload::Handle, + initial_level: String, + base_filter: String, + ) -> Self { + Self { + handle, + current_level: Mutex::new(initial_level), + base_filter, + } + } + + /// Change the `ironclaw=` directive at runtime. + /// + /// `level` must be one of: trace, debug, info, warn, error. + pub fn set_level(&self, level: &str) -> Result<(), String> { + const VALID: &[&str] = &["trace", "debug", "info", "warn", "error"]; + let level = level.to_lowercase(); + if !VALID.contains(&level.as_str()) { + return Err(format!( + "invalid level '{}', must be one of: {}", + level, + VALID.join(", ") + )); + } + + let filter_str = if self.base_filter.is_empty() { + format!("ironclaw={}", level) + } else { + format!("ironclaw={},{}", level, self.base_filter) + }; + + let new_filter = EnvFilter::new(&filter_str); + self.handle + .reload(new_filter) + .map_err(|e| format!("failed to reload filter: {}", e))?; + + if let Ok(mut current) = self.current_level.lock() { + *current = level; + } + Ok(()) + } + + /// Returns the current ironclaw log level (e.g. "info", "debug"). + pub fn current_level(&self) -> String { + self.current_level + .lock() + .map(|l| l.clone()) + .unwrap_or_else(|_| "info".to_string()) + } +} + +/// Initialise the tracing subscriber with a reloadable `EnvFilter`. +/// +/// Returns the `LogLevelHandle` so callers can swap the filter at runtime. +/// The fmt layer and `WebLogLayer` are attached alongside the reloadable filter. +pub fn init_tracing(log_broadcaster: Arc) -> Arc { + let raw_filter = + std::env::var("RUST_LOG").unwrap_or_else(|_| "ironclaw=info,tower_http=warn".to_string()); + + // Split into the ironclaw directive and "everything else" (base_filter). + let mut ironclaw_level = String::from("info"); + let mut base_parts: Vec<&str> = Vec::new(); + + for part in raw_filter.split(',') { + let trimmed = part.trim(); + if trimmed.starts_with("ironclaw=") { + if let Some(lvl) = trimmed.strip_prefix("ironclaw=") { + ironclaw_level = lvl.to_string(); + } + } else if !trimmed.is_empty() { + base_parts.push(trimmed); + } + } + let base_filter = base_parts.join(","); + + let env_filter = EnvFilter::new(&raw_filter); + let (reload_layer, reload_handle) = reload::Layer::new(env_filter); + + let handle = Arc::new(LogLevelHandle::new( + reload_handle, + ironclaw_level, + base_filter, + )); + + tracing_subscriber::registry() + .with(reload_layer) + .with( + tracing_subscriber::fmt::layer() + .with_target(false) + .with_writer(crate::tracing_fmt::TruncatingStderr::default()), + ) + .with(WebLogLayer::new(log_broadcaster)) + .init(); + + handle +} + /// Visitor that extracts the `message` field and all extra key-value /// fields from a tracing event. /// diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 30bd1e7c..90a4cd04 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -41,7 +41,7 @@ use crate::skills::registry::SkillRegistry; use crate::tools::ToolRegistry; use crate::workspace::Workspace; -use self::log_layer::LogBroadcaster; +use self::log_layer::{LogBroadcaster, LogLevelHandle}; use self::server::GatewayState; use self::sse::SseManager; @@ -76,6 +76,7 @@ impl GatewayChannel { workspace: None, session_manager: None, log_broadcaster: None, + log_level_handle: None, extension_manager: None, tool_registry: None, store: None, @@ -105,6 +106,7 @@ impl GatewayChannel { workspace: self.state.workspace.clone(), session_manager: self.state.session_manager.clone(), log_broadcaster: self.state.log_broadcaster.clone(), + log_level_handle: self.state.log_level_handle.clone(), extension_manager: self.state.extension_manager.clone(), tool_registry: self.state.tool_registry.clone(), store: self.state.store.clone(), @@ -140,6 +142,12 @@ impl GatewayChannel { self } + /// Inject the log level handle for runtime log level control. + pub fn with_log_level_handle(mut self, h: Arc) -> Self { + self.rebuild_state(|s| s.log_level_handle = Some(h)); + self + } + /// Inject the extension manager for the extensions API. pub fn with_extension_manager(mut self, em: Arc) -> Self { self.rebuild_state(|s| s.extension_manager = Some(em)); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 06bcf436..cdcf9a94 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -122,6 +122,8 @@ pub struct GatewayState { pub session_manager: Option>, /// Log broadcaster for the logs SSE endpoint. pub log_broadcaster: Option>, + /// Handle for changing the tracing log level at runtime. + pub log_level_handle: Option>, /// Extension manager for extension management API. pub extension_manager: Option>, /// Tool registry for listing registered tools. @@ -204,6 +206,11 @@ pub async fn start_server( .route("/api/jobs/{id}/files/read", get(job_files_read_handler)) // Logs .route("/api/logs/events", get(logs_events_handler)) + .route("/api/logs/level", get(logs_level_get_handler)) + .route( + "/api/logs/level", + axum::routing::put(logs_level_set_handler), + ) // Extensions .route("/api/extensions", get(extensions_list_handler)) .route("/api/extensions/tools", get(extensions_tools_handler)) @@ -1620,6 +1627,38 @@ async fn logs_events_handler( )) } +async fn logs_level_get_handler( + State(state): State>, +) -> Result, (StatusCode, String)> { + let handle = state.log_level_handle.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Log level control not available".to_string(), + ))?; + Ok(Json(serde_json::json!({ "level": handle.current_level() }))) +} + +async fn logs_level_set_handler( + State(state): State>, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let handle = state.log_level_handle.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Log level control not available".to_string(), + ))?; + + let level = body + .get("level") + .and_then(|v| v.as_str()) + .ok_or((StatusCode::BAD_REQUEST, "missing 'level' field".to_string()))?; + + handle + .set_level(level) + .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + tracing::info!("Log level changed to '{}'", handle.current_level()); + Ok(Json(serde_json::json!({ "level": handle.current_level() }))) +} + // --- Extension handlers --- async fn extensions_list_handler( diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index fa473900..8d19b497 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -29,9 +29,11 @@ function authenticate() { sessionStorage.setItem('ironclaw_token', token); document.getElementById('auth-screen').style.display = 'none'; document.getElementById('app').style.display = 'flex'; - // Strip token from URL so it's not visible in the address bar + // Strip token and log_level from URL so they're not visible in the address bar const cleaned = new URL(window.location); + const urlLogLevel = cleaned.searchParams.get('log_level'); cleaned.searchParams.delete('token'); + cleaned.searchParams.delete('log_level'); window.history.replaceState({}, '', cleaned.pathname + cleaned.search); connectSSE(); connectLogSSE(); @@ -39,6 +41,12 @@ function authenticate() { loadThreads(); loadMemoryTree(); loadJobs(); + // Apply URL log_level param if present, otherwise just sync the dropdown + if (urlLogLevel) { + setServerLogLevel(urlLogLevel); + } else { + loadServerLogLevel(); + } }) .catch(() => { sessionStorage.removeItem('ironclaw_token'); @@ -1167,6 +1175,30 @@ function applyLogFilters() { } } +// --- Server-side log level control --- + +function setServerLogLevel(level) { + apiFetch('/api/logs/level', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ level: level }), + }) + .then(r => r.json()) + .then(data => { + document.getElementById('logs-server-level').value = data.level; + }) + .catch(err => console.error('Failed to set server log level:', err)); +} + +function loadServerLogLevel() { + apiFetch('/api/logs/level') + .then(r => r.json()) + .then(data => { + document.getElementById('logs-server-level').value = data.level; + }) + .catch(() => {}); // ignore if not available +} + // --- Extensions --- function loadExtensions() { diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index ddcf6892..125dd586 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -127,6 +127,12 @@
+