Compare commits

..
Author SHA1 Message Date
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Illia Polosukhin
1f18422b88 chore: release v0.8.0 (#249)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Illia Polosukhin <[email protected]>
2026-02-20 20:45:42 +00:00
448383cfb0 refactor: remove Responses API, consolidate to Chat Completions (#272)
* fix: strip reasoning from LLM responses and persist assistant messages reliably

- Filter out `type: "reasoning"` output items from NEAR AI Responses API
  parsing so chain-of-thought never reaches the UI (nearai.rs)
- Rewrite clean_response with regex-based tag stripping that is
  code-aware (preserves tags inside fenced blocks and inline backticks),
  supports 9+ tag names (think, thought, reasoning, reflection, etc.),
  handles <final> extraction, pipe-delimited tags, and case/whitespace
  tolerance (reasoning.rs)
- Add Reasoning::complete() helper so all non-agentic LLM call sites
  (summarize, suggest, heartbeat, compaction) get automatic response
  cleaning; thread SafetyLayer through to those callers
- Change persist_turn from fire-and-forget tokio::spawn to awaited async
  so both user and assistant messages are written before returning,
  preventing data loss on shutdown/restart
- Pass input_count through seed_response_chain so response chaining
  delta calculation is accurate after thread hydration on restart
- Make NearAiResponse.usage optional and preserve response_id in alt
  response path for chaining continuity
- Persist session token to DB during onboarding wizard so runtime
  loads it without legacy-key fallback; suppress spurious warning on
  fresh installs
- Fix dev tool double-registration when builder already registers them
- Load dotenv/ironclaw env for doctor and status subcommands
- Reduce startup log noise (demote info→debug for skills, remove
  redundant info lines)

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

* Nudge to not loop over tools continuesly

* refactor: remove Responses API, consolidate NEAR AI to Chat Completions only

The Responses API provider (nearai.rs, 1278 lines) added significant complexity
(response chaining state machine, delta message calculation, previous_response_id
persistence) for marginal benefit. This consolidates to the Chat Completions API
only, upgrading NearAiChatProvider with dual auth (session token + API key) and
401 retry for session token renewal.

- Delete src/llm/nearai.rs (Responses API provider)
- Upgrade nearai_chat.rs with SessionManager, dual auth, flexible list_models
- Remove response_id from CompletionResponse and ToolCompletionResponse
- Remove seed_response_chain/get_response_chain_id from LlmProvider trait
- Remove response chain persistence from agent (thread_ops, session)
- Remove NearAiApiMode enum and NEARAI_API_MODE config
- Clean up all wrapper providers (retry, circuit_breaker, failover, cache)
- Update documentation (CLAUDE.md, .env.example)

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

* feat: runtime log level control via gateway UI and URL parameter

Add server-side log level switching using tracing_subscriber::reload::Layer
so the EnvFilter can be swapped at runtime without restarting. Expose via
GET/PUT /api/logs/level endpoints, a "Server: LEVEL" dropdown in the logs
toolbar, and a ?log_level=debug URL parameter for one-click activation.

Also applies cargo fmt to pre-existing files (llm/, tests/).

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 20:43:32 +00:00
7df356c109 fix: persist WASM channel workspace writes across callbacks (#264)
* fix: persist WASM channel workspace writes across callbacks

WASM channel callbacks (polling, webhooks, on_start) call
workspace_write() to persist state, but the host code never committed
these writes — take_pending_writes() was never called. Additionally,
no WorkspaceReader was injected into channel capabilities, so
workspace_read() always returned None.

This caused Telegram's polling offset to reset to 0 on every tick,
making getUpdates re-deliver already-processed messages and producing
2-4 duplicate LLM responses per user message.

Add ChannelWorkspaceStore (Arc-wrapped HashMap with std::sync::RwLock)
that persists across callback invocations within a channel's lifetime.
Inject it as the WorkspaceReader and commit pending writes after every
callback execution (on_start, on_poll, on_http_request, execute_poll).

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

* style: fix formatting

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

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-02-20 15:52:21 +00:00
43 changed files with 1525 additions and 1823 deletions
+8 -10
View File
@@ -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)
+23
View File
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.8.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.7.0...ironclaw-v0.8.0) - 2026-02-20
### Added
- extension registry with metadata catalog and onboarding integration ([#238](https://github.com/nearai/ironclaw/pull/238))
- *(models)* add GPT-5.3 Codex, full GPT-5.x family, Claude 4.x series, o4-mini ([#197](https://github.com/nearai/ironclaw/pull/197))
- wire memory hygiene into the heartbeat loop ([#195](https://github.com/nearai/ironclaw/pull/195))
### Fixed
- persist WASM channel workspace writes across callbacks ([#264](https://github.com/nearai/ironclaw/pull/264))
- consolidate per-module ENV_MUTEX into crate-wide test lock ([#246](https://github.com/nearai/ironclaw/pull/246))
- remove auto-proceed fake user message injection from agent loop ([#255](https://github.com/nearai/ironclaw/pull/255))
- onboarding errors reset flow and remote server auth (#185, #186) ([#248](https://github.com/nearai/ironclaw/pull/248))
- parallelize tool call execution via JoinSet ([#219](https://github.com/nearai/ironclaw/pull/219)) ([#252](https://github.com/nearai/ironclaw/pull/252))
- prevent pipe deadlock in shell command execution ([#140](https://github.com/nearai/ironclaw/pull/140))
- persist turns after approval and add agent-level tests ([#250](https://github.com/nearai/ironclaw/pull/250))
### Other
- add automated PR labeling system ([#253](https://github.com/nearai/ironclaw/pull/253))
- update CLAUDE.md for recently merged features ([#183](https://github.com/nearai/ironclaw/pull/183))
## [0.7.0](https://github.com/nearai/ironclaw/compare/ironclaw-v0.6.0...ironclaw-v0.7.0) - 2026-02-19
### Added
+7 -11
View File
@@ -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
Generated
+1 -1
View File
@@ -2490,7 +2490,7 @@ dependencies = [
[[package]]
name = "ironclaw"
version = "0.7.0"
version = "0.8.0"
dependencies = [
"aes-gcm",
"aho-corasick",
+1 -1
View File
@@ -19,7 +19,7 @@ exclude = [
[package]
name = "ironclaw"
version = "0.7.0"
version = "0.8.0"
edition = "2024"
rust-version = "1.92"
description = "Secure personal AI assistant that protects your data and expands its capabilities on the fly"
-2
View File
@@ -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,
})
}
}
+1 -4
View File
@@ -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 {
+10 -7
View File
@@ -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))),
}
+8 -5
View File
@@ -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<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
}
impl ContextCompactor {
/// Create a new context compactor.
pub fn new(llm: Arc<dyn LlmProvider>) -> Self {
Self { llm }
pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> 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.
+32 -29
View File
@@ -33,16 +33,12 @@ impl Agent {
/// Returns `AgenticLoopResult::Response` on completion, or
/// `AgenticLoopResult::NeedApproval` if a tool requires user approval.
///
/// When `resume_after_tool` is true the loop already knows a tool was
/// executed earlier in this turn (e.g. an approved tool), so it won't
/// force the LLM to use tools if it responds with text.
pub(super) async fn run_agentic_loop(
&self,
message: &IncomingMessage,
session: Arc<Mutex<Session>>,
thread_id: Uuid,
initial_messages: Vec<ChatMessage>,
resume_after_tool: bool,
) -> Result<AgenticLoopResult, Error> {
// Load workspace system prompt (identity files: AGENTS.md, SOUL.md, etc.)
let system_prompt = if let Some(ws) = self.workspace() {
@@ -113,12 +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;
let mut tools_executed = resume_after_tool;
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),
@@ -149,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;
@@ -168,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({
@@ -177,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?;
@@ -199,30 +222,12 @@ impl Agent {
match output.result {
RespondResult::Text(text) => {
// If no tools have been executed yet, prompt the LLM to use tools
// This handles the case where the model explains what it will do
// instead of actually calling tools
if !tools_executed && iteration < 3 {
tracing::debug!(
"No tools executed yet (iteration {}), prompting for tool use",
iteration
);
context_messages.push(ChatMessage::assistant(&text));
context_messages.push(ChatMessage::user(
"Please proceed and use the available tools to complete this task.",
));
continue;
}
// Tools have been executed or we've tried multiple times, return response
return Ok(AgenticLoopResult::Response(text));
}
RespondResult::ToolCalls {
tool_calls,
content,
} => {
tools_executed = true;
// Add the assistant message with tool_calls to context.
// OpenAI protocol requires this before tool-result messages.
context_messages.push(ChatMessage::assistant_with_tool_calls(
@@ -823,7 +828,6 @@ mod tests {
input_tokens: 0,
output_tokens: 0,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
@@ -837,7 +841,6 @@ mod tests {
input_tokens: 0,
output_tokens: 0,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
}
+11 -13
View File
@@ -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<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
consecutive_failures: u32,
}
@@ -111,12 +113,14 @@ impl HeartbeatRunner {
hygiene_config: HygieneConfig,
workspace: Arc<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
) -> 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<Workspace>,
llm: Arc<dyn LlmProvider>,
safety: Arc<SafetyLayer>,
response_tx: Option<mpsc::Sender<OutgoingResponse>>,
) -> 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);
}
-8
View File
@@ -185,10 +185,6 @@ pub struct Thread {
/// Pending auth token request (thread is in auth mode).
#[serde(default)]
pub pending_auth: Option<PendingAuth>,
/// 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<String>,
}
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]
+41 -92
View File
@@ -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
@@ -278,7 +264,7 @@ impl Agent {
// Run the agentic tool execution loop
let result = self
.run_agentic_loop(message, session.clone(), thread_id, turn_messages, false)
.run_agentic_loop(message, session.clone(), thread_id, turn_messages)
.await;
// Re-acquire lock and check if interrupted
@@ -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
@@ -1057,7 +1004,7 @@ impl Agent {
// Continue the agentic loop (a tool was already executed this turn)
let result = self
.run_agentic_loop(message, session.clone(), thread_id, context_messages, true)
.run_agentic_loop(message, session.clone(), thread_id, context_messages)
.await;
// Handle the result
@@ -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
+5 -9
View File
@@ -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<Arc<dyn EmbeddingProvider>> = 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);
+97
View File
@@ -300,6 +300,51 @@ impl ChannelHostState {
}
}
/// In-memory workspace store for WASM channels.
///
/// Persists workspace writes across callback invocations within a single
/// channel lifetime. This allows WASM channels to maintain state (e.g.,
/// Telegram polling offsets) between poll ticks without requiring a
/// full database-backed workspace.
///
/// Uses `std::sync::RwLock` (not tokio) because WASM execution runs
/// inside `spawn_blocking`.
pub struct ChannelWorkspaceStore {
data: std::sync::RwLock<std::collections::HashMap<String, String>>,
}
impl ChannelWorkspaceStore {
/// Create a new empty workspace store.
pub fn new() -> Self {
Self {
data: std::sync::RwLock::new(std::collections::HashMap::new()),
}
}
/// Commit pending writes from a callback execution into the store.
pub fn commit_writes(&self, writes: &[PendingWorkspaceWrite]) {
if writes.is_empty() {
return;
}
if let Ok(mut data) = self.data.write() {
for write in writes {
tracing::debug!(
path = %write.path,
content_len = write.content.len(),
"Committing workspace write to channel store"
);
data.insert(write.path.clone(), write.content.clone());
}
}
}
}
impl crate::tools::wasm::WorkspaceReader for ChannelWorkspaceStore {
fn read(&self, path: &str) -> Option<String> {
self.data.read().ok()?.get(path).cloned()
}
}
/// Rate limiter for channel message emission.
///
/// Tracks emission rates across multiple executions.
@@ -497,4 +542,56 @@ mod tests {
assert_eq!(state.channel_name(), "telegram");
}
#[test]
fn test_channel_workspace_store_commit_and_read() {
use crate::channels::wasm::host::{ChannelWorkspaceStore, PendingWorkspaceWrite};
use crate::tools::wasm::WorkspaceReader;
let store = ChannelWorkspaceStore::new();
// Initially empty
assert!(store.read("channels/telegram/offset").is_none());
// Commit some writes
let writes = vec![
PendingWorkspaceWrite {
path: "channels/telegram/offset".to_string(),
content: "103".to_string(),
},
PendingWorkspaceWrite {
path: "channels/telegram/state.json".to_string(),
content: r#"{"ok":true}"#.to_string(),
},
];
store.commit_writes(&writes);
// Should be readable
assert_eq!(
store.read("channels/telegram/offset"),
Some("103".to_string())
);
assert_eq!(
store.read("channels/telegram/state.json"),
Some(r#"{"ok":true}"#.to_string())
);
// Overwrite a value
let writes2 = vec![PendingWorkspaceWrite {
path: "channels/telegram/offset".to_string(),
content: "200".to_string(),
}];
store.commit_writes(&writes2);
assert_eq!(
store.read("channels/telegram/offset"),
Some("200".to_string())
);
// Empty writes are a no-op
store.commit_writes(&[]);
assert_eq!(
store.read("channels/telegram/offset"),
Some("200".to_string())
);
}
}
+70 -10
View File
@@ -42,7 +42,9 @@ use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::error::WasmChannelError;
use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
use crate::channels::wasm::host::{
ChannelEmitRateLimiter, ChannelHostState, ChannelWorkspaceStore, EmittedMessage,
};
use crate::channels::wasm::router::RegisteredEndpoint;
use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
use crate::channels::wasm::schema::ChannelConfig;
@@ -547,6 +549,10 @@ pub struct WasmChannel {
/// Pairing store for DM pairing (guest access control).
pairing_store: Arc<PairingStore>,
/// In-memory workspace store persisting writes across callback invocations.
/// Ensures WASM channels can maintain state (e.g., polling offsets) between ticks.
workspace_store: Arc<ChannelWorkspaceStore>,
}
impl WasmChannel {
@@ -577,6 +583,7 @@ impl WasmChannel {
credentials: Arc::new(RwLock::new(HashMap::new())),
typing_task: RwLock::new(None),
pairing_store,
workspace_store: Arc::new(ChannelWorkspaceStore::new()),
}
}
@@ -634,6 +641,26 @@ impl WasmChannel {
self.endpoints.read().await.clone()
}
/// Inject the workspace store as the reader into a capabilities clone.
///
/// Ensures `workspace_read` capability is present with the store as its reader,
/// so WASM callbacks can read previously written workspace state.
fn inject_workspace_reader(
capabilities: &ChannelCapabilities,
store: &Arc<ChannelWorkspaceStore>,
) -> ChannelCapabilities {
let mut caps = capabilities.clone();
let ws_cap = caps
.tool_capabilities
.workspace_read
.get_or_insert_with(|| crate::tools::wasm::WorkspaceCapability {
allowed_prefixes: Vec::new(),
reader: None,
});
ws_cap.reader = Some(Arc::clone(store) as Arc<dyn crate::tools::wasm::WorkspaceReader>);
caps
}
/// Add channel host functions to the linker using generated bindings.
///
/// Uses the wasmtime::component::bindgen! generated `add_to_linker` function
@@ -765,12 +792,13 @@ impl WasmChannel {
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
let config_json = self.config_json.read().await.clone();
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone();
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
@@ -801,8 +829,13 @@ impl WasmChannel {
}
};
let host_state =
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok((config, host_state))
})
.await
@@ -897,10 +930,11 @@ impl WasmChannel {
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
let timeout = self.runtime.config().callback_timeout;
let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone();
// Prepare request data
let method = method.to_string();
@@ -940,8 +974,13 @@ impl WasmChannel {
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let response = convert_http_response(wit_response);
let host_state =
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok((response, host_state))
})
.await
@@ -989,11 +1028,12 @@ impl WasmChannel {
let runtime = Arc::clone(&self.runtime);
let prepared = Arc::clone(&self.prepared);
let capabilities = self.capabilities.clone();
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone();
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
@@ -1013,8 +1053,13 @@ impl WasmChannel {
.call_on_poll(&mut store)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let host_state =
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok(((), host_state))
})
.await
@@ -1501,6 +1546,7 @@ impl WasmChannel {
let credentials = self.credentials.clone();
let pairing_store = self.pairing_store.clone();
let callback_timeout = self.runtime.config().callback_timeout;
let workspace_store = self.workspace_store.clone();
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval);
@@ -1523,6 +1569,7 @@ impl WasmChannel {
&credentials,
pairing_store.clone(),
callback_timeout,
&workspace_store,
).await;
match result {
@@ -1565,7 +1612,10 @@ impl WasmChannel {
/// Execute a single poll callback with a fresh WASM instance.
///
/// Returns any emitted messages from the callback.
/// Returns any emitted messages from the callback. Pending workspace writes
/// are committed to the shared `ChannelWorkspaceStore` so state persists
/// across poll ticks (e.g., Telegram polling offset).
#[allow(clippy::too_many_arguments)]
async fn execute_poll(
channel_name: &str,
runtime: &Arc<WasmChannelRuntime>,
@@ -1574,6 +1624,7 @@ impl WasmChannel {
credentials: &RwLock<HashMap<String, String>>,
pairing_store: Arc<PairingStore>,
timeout: Duration,
workspace_store: &Arc<ChannelWorkspaceStore>,
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
// Skip if no WASM bytes (testing mode)
if prepared.component_bytes.is_empty() {
@@ -1586,9 +1637,10 @@ impl WasmChannel {
let runtime = Arc::clone(runtime);
let prepared = Arc::clone(prepared);
let capabilities = capabilities.clone();
let capabilities = Self::inject_workspace_reader(capabilities, workspace_store);
let credentials_snapshot = credentials.read().await.clone();
let channel_name_owned = channel_name.to_string();
let workspace_store = Arc::clone(workspace_store);
// Execute in blocking task with timeout
let result = tokio::time::timeout(timeout, async move {
@@ -1608,8 +1660,13 @@ impl WasmChannel {
.call_on_poll(&mut store)
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
let host_state =
let mut host_state =
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
// Commit pending workspace writes to the persistent store
let pending_writes = host_state.take_pending_writes();
workspace_store.commit_writes(&pending_writes);
Ok(host_state)
})
.await
@@ -2230,6 +2287,8 @@ mod tests {
let credentials = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
let timeout = std::time::Duration::from_secs(5);
let workspace_store = Arc::new(crate::channels::wasm::host::ChannelWorkspaceStore::new());
let result = WasmChannel::execute_poll(
"poll-test",
&runtime,
@@ -2238,6 +2297,7 @@ mod tests {
&credentials,
Arc::new(PairingStore::new()),
timeout,
&workspace_store,
)
.await;
+112 -1
View File
@@ -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<EnvFilter, tracing_subscriber::Registry>,
current_level: Mutex<String>,
base_filter: String,
}
impl LogLevelHandle {
pub fn new(
handle: reload::Handle<EnvFilter, tracing_subscriber::Registry>,
initial_level: String,
base_filter: String,
) -> Self {
Self {
handle,
current_level: Mutex::new(initial_level),
base_filter,
}
}
/// Change the `ironclaw=<level>` 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<LogBroadcaster>) -> Arc<LogLevelHandle> {
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.
///
+9 -1
View File
@@ -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<LogLevelHandle>) -> 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<ExtensionManager>) -> Self {
self.rebuild_state(|s| s.extension_manager = Some(em));
+39
View File
@@ -122,6 +122,8 @@ pub struct GatewayState {
pub session_manager: Option<Arc<SessionManager>>,
/// Log broadcaster for the logs SSE endpoint.
pub log_broadcaster: Option<Arc<LogBroadcaster>>,
/// Handle for changing the tracing log level at runtime.
pub log_level_handle: Option<Arc<crate::channels::web::log_layer::LogLevelHandle>>,
/// Extension manager for extension management API.
pub extension_manager: Option<Arc<ExtensionManager>>,
/// 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<Arc<GatewayState>>,
) -> Result<Json<serde_json::Value>, (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<Arc<GatewayState>>,
Json(body): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, (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(
+33 -1
View File
@@ -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() {
+6
View File
@@ -127,6 +127,12 @@
<div class="tab-panel" id="tab-logs">
<div class="logs-container">
<div class="logs-toolbar">
<select id="logs-server-level" onchange="setServerLogLevel(this.value)" title="Server-side log level (changes what the server emits)">
<option value="error">Server: ERROR</option>
<option value="warn">Server: WARN</option>
<option value="info" selected>Server: INFO</option>
<option value="debug">Server: DEBUG</option>
</select>
<select id="logs-level-filter">
<option value="all">All Levels</option>
<option value="ERROR">Error</option>
+1
View File
@@ -477,6 +477,7 @@ mod tests {
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,
+4 -49
View File
@@ -121,37 +121,7 @@ pub struct LlmConfig {
pub tinfoil: Option<TinfoilConfig>,
}
/// API mode for NEAR AI.
///
/// - `Responses` = **NEAR AI Chat** (`private.near.ai`, session token auth)
/// - `ChatCompletions` = **NEAR AI Cloud** (`cloud-api.near.ai`, API key auth)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NearAiApiMode {
/// NEAR AI Chat: Responses API with session token auth
#[default]
Responses,
/// NEAR AI Cloud: Chat Completions API with API key auth
ChatCompletions,
}
impl std::str::FromStr for NearAiApiMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"responses" | "response" => Ok(Self::Responses),
"chat_completions" | "chatcompletions" | "chat" | "completions" => {
Ok(Self::ChatCompletions)
}
_ => Err(format!(
"invalid API mode '{}', expected 'responses' or 'chat_completions'",
s
)),
}
}
}
/// NEAR AI configuration (shared by Chat and Cloud modes).
/// NEAR AI configuration.
#[derive(Debug, Clone)]
pub struct NearAiConfig {
/// Model to use (e.g., "claude-3-5-sonnet-20241022", "gpt-4o")
@@ -160,16 +130,13 @@ pub struct NearAiConfig {
/// Falls back to the main model if not set.
pub cheap_model: Option<String>,
/// Base URL for the NEAR AI API.
/// Chat mode default: `https://private.near.ai`
/// Cloud mode default: `https://cloud-api.near.ai`
/// Default: `https://private.near.ai` (session token) or `https://cloud-api.near.ai` (API key)
pub base_url: String,
/// Base URL for auth/refresh endpoints (default: https://private.near.ai)
pub auth_base_url: String,
/// Path to session file (default: ~/.ironclaw/session.json)
pub session_path: PathBuf,
/// API mode: NEAR AI Chat (Responses) or NEAR AI Cloud (ChatCompletions)
pub api_mode: NearAiApiMode,
/// API key for NEAR AI Cloud (required for ChatCompletions mode)
/// API key for NEAR AI Cloud. When set, uses API key auth; otherwise uses session token auth.
pub api_key: Option<SecretString>,
/// Optional fallback model for failover (default: None).
/// When set, a secondary provider is created with this model and wrapped
@@ -229,17 +196,6 @@ impl LlmConfig {
// Resolve NEAR AI config only when backend is NearAi (or when explicitly configured)
let nearai_api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from);
let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? {
mode_str.parse().map_err(|e| ConfigError::InvalidValue {
key: "NEARAI_API_MODE".to_string(),
message: e,
})?
} else if nearai_api_key.is_some() {
NearAiApiMode::ChatCompletions
} else {
NearAiApiMode::Responses
};
let nearai = NearAiConfig {
model: optional_env("NEARAI_MODEL")?
.or_else(|| settings.selected_model.clone())
@@ -249,7 +205,7 @@ impl LlmConfig {
}),
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
if api_mode == NearAiApiMode::ChatCompletions {
if nearai_api_key.is_some() {
"https://cloud-api.near.ai".to_string()
} else {
"https://private.near.ai".to_string()
@@ -260,7 +216,6 @@ impl LlmConfig {
session_path: optional_env("NEARAI_SESSION_PATH")?
.map(PathBuf::from)
.unwrap_or_else(default_session_path),
api_mode,
api_key: nearai_api_key,
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
+1 -1
View File
@@ -37,7 +37,7 @@ pub use self::embeddings::EmbeddingsConfig;
pub use self::heartbeat::HeartbeatConfig;
pub use self::hygiene::HygieneConfig;
pub use self::llm::{
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig, OllamaConfig,
AnthropicDirectConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
};
pub use self::routines::RoutineConfig;
-8
View File
@@ -296,14 +296,6 @@ impl LlmProvider for CircuitBreakerProvider {
self.inner.set_model(model)
}
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.inner.seed_response_chain(thread_id, response_id)
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.inner.get_response_chain_id(thread_id)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.inner.calculate_cost(input_tokens, output_tokens)
}
-13
View File
@@ -359,15 +359,6 @@ impl LlmProvider for FailoverProvider {
.await
}
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.providers[self.last_used.load(Ordering::Relaxed)]
.seed_response_chain(thread_id, response_id);
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.providers[self.last_used.load(Ordering::Relaxed)].get_response_chain_id(thread_id)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.providers[self.last_used.load(Ordering::Relaxed)]
.calculate_cost(input_tokens, output_tokens)
@@ -413,7 +404,6 @@ mod tests {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
}))),
tool_complete_result: Mutex::new(Some(Ok(ToolCompletionResponse {
content: Some(content.to_string()),
@@ -421,7 +411,6 @@ mod tests {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
}))),
}
}
@@ -803,7 +792,6 @@ mod tests {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
@@ -829,7 +817,6 @@ mod tests {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
+21 -34
View File
@@ -1,7 +1,7 @@
//! LLM integration for the agent.
//!
//! Supports multiple backends:
//! - **NEAR AI** (default): Session-based or API key auth via NEAR AI proxy
//! - **NEAR AI** (default): Session token or API key auth via Chat Completions API
//! - **OpenAI**: Direct API access with your own key
//! - **Anthropic**: Direct API access with your own key
//! - **Ollama**: Local model inference
@@ -10,7 +10,6 @@
pub mod circuit_breaker;
pub mod costs;
pub mod failover;
mod nearai;
mod nearai_chat;
mod provider;
mod reasoning;
@@ -21,8 +20,7 @@ pub mod session;
pub use circuit_breaker::{CircuitBreakerConfig, CircuitBreakerProvider};
pub use failover::{CooldownConfig, FailoverProvider};
pub use nearai::{ModelInfo, NearAiProvider};
pub use nearai_chat::NearAiChatProvider;
pub use nearai_chat::{ModelInfo, NearAiChatProvider};
pub use provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult,
@@ -41,7 +39,7 @@ use std::sync::Arc;
use rig::client::CompletionClient;
use secrecy::ExposeSecret;
use crate::config::{LlmBackend, LlmConfig, NearAiApiMode, NearAiConfig};
use crate::config::{LlmBackend, LlmConfig, NearAiConfig};
use crate::error::LlmError;
/// Create an LLM provider based on configuration.
@@ -71,24 +69,18 @@ pub fn create_llm_provider_with_config(
config: &NearAiConfig,
session: Arc<SessionManager>,
) -> Result<Arc<dyn LlmProvider>, LlmError> {
match config.api_mode {
NearAiApiMode::Responses => {
tracing::info!(
model = %config.model,
base_url = %config.base_url,
"Using NEAR AI Chat (Responses API, session token auth)"
);
Ok(Arc::new(NearAiProvider::new(config.clone(), session)?))
}
NearAiApiMode::ChatCompletions => {
tracing::info!(
model = %config.model,
base_url = %config.base_url,
"Using NEAR AI Cloud (Chat Completions API, API key auth)"
);
Ok(Arc::new(NearAiChatProvider::new(config.clone())?))
}
}
let auth_mode = if config.api_key.is_some() {
"API key"
} else {
"session token"
};
tracing::info!(
model = %config.model,
base_url = %config.base_url,
auth = auth_mode,
"Using NEAR AI (Chat Completions API)"
);
Ok(Arc::new(NearAiChatProvider::new(config.clone(), session)?))
}
fn create_openai_provider(config: &LlmConfig) -> Result<Arc<dyn LlmProvider>, LlmError> {
@@ -254,7 +246,7 @@ fn create_openai_compatible_provider(config: &LlmConfig) -> Result<Arc<dyn LlmPr
/// Create a cheap/fast LLM provider for lightweight tasks (heartbeat, routing, evaluation).
///
/// Uses `NEARAI_CHEAP_MODEL` if set, otherwise falls back to the main provider.
/// Currently only supports NEAR AI backends (Responses and ChatCompletions modes).
/// Currently only supports NEAR AI backend.
pub fn create_cheap_llm_provider(
config: &LlmConfig,
session: Arc<SessionManager>,
@@ -275,20 +267,16 @@ pub fn create_cheap_llm_provider(
let mut cheap_config = config.nearai.clone();
cheap_config.model = cheap_model.clone();
tracing::info!("Cheap LLM provider: {}", cheap_model);
match cheap_config.api_mode {
NearAiApiMode::Responses => Ok(Some(Arc::new(NearAiProvider::new(cheap_config, session)?))),
NearAiApiMode::ChatCompletions => {
Ok(Some(Arc::new(NearAiChatProvider::new(cheap_config)?)))
}
}
Ok(Some(Arc::new(NearAiChatProvider::new(
cheap_config,
session,
)?)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{LlmBackend, NearAiApiMode, NearAiConfig};
use crate::config::{LlmBackend, NearAiConfig};
use std::path::PathBuf;
fn test_nearai_config() -> NearAiConfig {
@@ -298,7 +286,6 @@ mod tests {
base_url: "https://api.near.ai".to_string(),
auth_base_url: "https://private.near.ai".to_string(),
session_path: PathBuf::from("/tmp/test-session.json"),
api_mode: NearAiApiMode::Responses,
api_key: None,
fallback_model: None,
max_retries: 3,
-1205
View File
File diff suppressed because it is too large Load Diff
+199 -61
View File
@@ -1,8 +1,12 @@
//! NEAR AI Cloud provider implementation (Chat Completions API).
//! NEAR AI provider implementation (Chat Completions API).
//!
//! This provider uses the NEAR AI Cloud API (`cloud-api.near.ai`) which
//! exposes an OpenAI-compatible chat completions endpoint with API key
//! authentication.
//! This provider uses the OpenAI-compatible Chat Completions endpoint with
//! dual auth support:
//! - **API key auth**: When `NEARAI_API_KEY` is set, uses Bearer API key
//! - **Session token auth**: Otherwise, uses `SessionManager` for Bearer session token
//! with automatic renewal on 401 errors
use std::sync::Arc;
use async_trait::async_trait;
use reqwest::Client;
@@ -14,38 +18,51 @@ use serde::{Deserialize, Serialize};
use crate::config::NearAiConfig;
use crate::error::LlmError;
use crate::llm::provider::{
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata,
Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse,
ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall,
ToolCompletionRequest, ToolCompletionResponse,
};
use crate::llm::session::SessionManager;
/// NEAR AI Cloud provider (Chat Completions API, API key auth).
/// Information about an available model from NEAR AI API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
/// Model identifier.
#[serde(alias = "id", alias = "model")]
pub name: String,
/// Optional provider name.
#[serde(default)]
pub provider: Option<String>,
}
/// NEAR AI provider (Chat Completions API, dual auth).
pub struct NearAiChatProvider {
client: Client,
config: NearAiConfig,
/// Session manager for session token auth (used when no API key is set).
session: Arc<SessionManager>,
active_model: std::sync::RwLock<String>,
flatten_tool_messages: bool,
}
impl NearAiChatProvider {
/// Create a new NEAR AI Cloud provider with API key auth.
/// Create a new NEAR AI Chat Completions provider.
///
/// Auth mode is determined by `config.api_key`:
/// - If set, uses Bearer API key auth
/// - If not set, uses session token auth via `SessionManager`
///
/// By default this enables tool-message flattening for compatibility with
/// providers that reject `role: "tool"` messages.
pub fn new(config: NearAiConfig) -> Result<Self, LlmError> {
Self::new_with_flatten(config, true)
pub fn new(config: NearAiConfig, session: Arc<SessionManager>) -> Result<Self, LlmError> {
Self::new_with_flatten(config, session, true)
}
/// Create a chat completions provider with configurable tool-message flattening.
pub fn new_with_flatten(
config: NearAiConfig,
session: Arc<SessionManager>,
flatten_tool_messages: bool,
) -> Result<Self, LlmError> {
if config.api_key.is_none() {
return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(),
});
}
let client = Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
@@ -58,6 +75,7 @@ impl NearAiChatProvider {
Ok(Self {
client,
config,
session,
active_model,
flatten_tool_messages,
})
@@ -74,23 +92,50 @@ impl NearAiChatProvider {
}
}
fn api_key(&self) -> String {
self.config
.api_key
.as_ref()
.map(|k| k.expose_secret().to_string())
.unwrap_or_default()
/// Returns true if using API key auth, false if session token auth.
fn uses_api_key(&self) -> bool {
self.config.api_key.is_some()
}
/// Resolve the Bearer token for the current auth mode.
async fn resolve_bearer_token(&self) -> Result<String, LlmError> {
if let Some(ref api_key) = self.config.api_key {
Ok(api_key.expose_secret().to_string())
} else {
let token = self.session.get_token().await?;
Ok(token.expose_secret().to_string())
}
}
/// Send a single request to the chat completions API.
///
/// Does not retry internally — retries are handled by the external
/// For session token auth, handles 401 by calling `session.handle_auth_failure()`
/// and retrying once.
///
/// Does not retry on other errors — retries are handled by the external
/// `RetryProvider` wrapper in the composition chain.
async fn send_request<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
body: &T,
) -> Result<R, LlmError> {
match self.send_request_inner(body).await {
Ok(result) => Ok(result),
Err(LlmError::SessionExpired { .. }) if !self.uses_api_key() => {
// Session expired, attempt renewal and retry once
self.session.handle_auth_failure().await?;
self.send_request_inner(body).await
}
Err(e) => Err(e),
}
}
/// Inner request implementation (single attempt).
async fn send_request_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
&self,
body: &T,
) -> Result<R, LlmError> {
let url = self.api_url("chat/completions");
let token = self.resolve_bearer_token().await?;
tracing::debug!("Sending request to NEAR AI Chat: {}", url);
@@ -103,7 +148,7 @@ impl NearAiChatProvider {
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {}", self.api_key()))
.header("Authorization", format!("Bearer {}", token))
.header("Content-Type", "application/json")
.json(body)
.send()
@@ -126,6 +171,17 @@ impl NearAiChatProvider {
let status_code = status.as_u16();
if status_code == 401 {
// For session token auth, distinguish session expired from plain auth failure
if !self.uses_api_key() {
let lower = response_text.to_lowercase();
let is_session_expired = lower.contains("session")
&& (lower.contains("expired") || lower.contains("invalid"));
if is_session_expired {
return Err(LlmError::SessionExpired {
provider: "nearai_chat".to_string(),
});
}
}
return Err(LlmError::AuthFailed {
provider: "nearai_chat".to_string(),
});
@@ -154,14 +210,31 @@ impl NearAiChatProvider {
})
}
/// Fetch available models with full metadata from the `/v1/models` endpoint.
async fn fetch_models(&self) -> Result<Vec<ApiModelEntry>, LlmError> {
/// Fetch available models from the NEAR AI API.
///
/// Handles session renewal on 401 (same pattern as `send_request`).
/// Supports multiple response formats: `{models: [...]}`, `{data: [...]}`, and plain array.
pub async fn list_models_full(&self) -> Result<Vec<ModelInfo>, LlmError> {
match self.list_models_inner().await {
Ok(models) => Ok(models),
Err(LlmError::SessionExpired { .. }) if !self.uses_api_key() => {
self.session.handle_auth_failure().await?;
self.list_models_inner().await
}
Err(e) => Err(e),
}
}
async fn list_models_inner(&self) -> Result<Vec<ModelInfo>, LlmError> {
let url = self.api_url("models");
let token = self.resolve_bearer_token().await?;
tracing::debug!("Fetching models from: {}", url);
let response = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key()))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| LlmError::RequestFailed {
@@ -176,6 +249,11 @@ impl NearAiChatProvider {
})?;
if !status.is_success() {
if status.as_u16() == 401 && !self.uses_api_key() {
return Err(LlmError::SessionExpired {
provider: "nearai_chat".to_string(),
});
}
let truncated = crate::agent::truncate_for_preview(&response_text, 512);
return Err(LlmError::RequestFailed {
provider: "nearai_chat".to_string(),
@@ -183,29 +261,97 @@ impl NearAiChatProvider {
});
}
// Flexible model entry parsing -- handle various field names
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ApiModelEntry>,
struct ModelMetadataInner {
#[serde(default)]
name: Option<String>,
#[serde(default, alias = "modelName", alias = "model_name")]
model_name: Option<String>,
}
let resp: ModelsResponse =
serde_json::from_str(&response_text).map_err(|e| LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: format!("JSON parse error: {}", e),
})?;
#[derive(Deserialize)]
struct ModelEntry {
#[serde(default)]
name: Option<String>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default, alias = "modelName", alias = "model_name")]
model_name: Option<String>,
#[serde(default, alias = "modelId", alias = "model_id")]
model_id: Option<String>,
#[serde(default)]
metadata: Option<ModelMetadataInner>,
}
Ok(resp.data)
impl ModelEntry {
fn get_name(&self) -> Option<String> {
self.name
.clone()
.or_else(|| self.id.clone())
.or_else(|| self.model.clone())
.or_else(|| self.model_name.clone())
.or_else(|| self.model_id.clone())
.or_else(|| self.metadata.as_ref().and_then(|m| m.name.clone()))
.or_else(|| self.metadata.as_ref().and_then(|m| m.model_name.clone()))
}
}
#[derive(Deserialize)]
struct ModelsResponse {
#[serde(default)]
models: Option<Vec<ModelEntry>>,
#[serde(default)]
data: Option<Vec<ModelEntry>>,
}
// Try {models: [...]} or {data: [...]} format
if let Ok(resp) = serde_json::from_str::<ModelsResponse>(&response_text)
&& let Some(entries) = resp.models.or(resp.data)
{
let models: Vec<ModelInfo> = entries
.into_iter()
.filter_map(|e| {
e.get_name().map(|name| ModelInfo {
name,
provider: None,
})
})
.collect();
if !models.is_empty() {
return Ok(models);
}
}
// Try direct array format
if let Ok(entries) = serde_json::from_str::<Vec<ModelEntry>>(&response_text) {
let models: Vec<ModelInfo> = entries
.into_iter()
.filter_map(|e| {
e.get_name().map(|name| ModelInfo {
name,
provider: None,
})
})
.collect();
if !models.is_empty() {
return Ok(models);
}
}
// Couldn't find model names in response
Err(LlmError::InvalidResponse {
provider: "nearai_chat".to_string(),
reason: format!(
"No model names found in response: {}",
&response_text[..response_text.len().min(300)]
),
})
}
}
/// Model entry as returned by the `/v1/models` API.
#[derive(Debug, Deserialize)]
struct ApiModelEntry {
id: String,
#[serde(default)]
context_length: Option<u32>,
}
#[async_trait]
impl LlmProvider for NearAiChatProvider {
async fn complete(&self, req: CompletionRequest) -> Result<CompletionResponse, LlmError> {
@@ -252,7 +398,6 @@ impl LlmProvider for NearAiChatProvider {
finish_reason,
input_tokens,
output_tokens,
response_id: None,
})
}
@@ -347,7 +492,6 @@ impl LlmProvider for NearAiChatProvider {
finish_reason,
input_tokens,
output_tokens,
response_id: None,
})
}
@@ -361,18 +505,8 @@ impl LlmProvider for NearAiChatProvider {
}
async fn list_models(&self) -> Result<Vec<String>, LlmError> {
let models = self.fetch_models().await?;
Ok(models.into_iter().map(|m| m.id).collect())
}
async fn model_metadata(&self) -> Result<ModelMetadata, LlmError> {
let active = self.active_model_name();
let models = self.fetch_models().await?;
let current = models.iter().find(|m| m.id == active);
Ok(ModelMetadata {
id: active,
context_length: current.and_then(|m| m.context_length),
})
let models = self.list_models_full().await?;
Ok(models.into_iter().map(|m| m.name).collect())
}
fn active_model_name(&self) -> String {
@@ -613,6 +747,7 @@ fn parse_usage(usage: Option<&ChatCompletionUsage>) -> (u32, u32) {
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::session::SessionConfig;
fn test_nearai_config(base_url: &str) -> NearAiConfig {
NearAiConfig {
@@ -620,7 +755,6 @@ mod tests {
base_url: base_url.to_string(),
auth_base_url: "https://private.near.ai".to_string(),
session_path: std::path::PathBuf::from("/tmp/session.json"),
api_mode: crate::config::NearAiApiMode::ChatCompletions,
api_key: Some(secrecy::SecretString::from("test-key".to_string())),
cheap_model: None,
fallback_model: None,
@@ -635,18 +769,22 @@ mod tests {
}
}
fn test_session() -> Arc<SessionManager> {
Arc::new(SessionManager::new(SessionConfig::default()))
}
#[test]
fn test_api_url_with_base_without_v1() {
let mut cfg = test_nearai_config("http://127.0.0.1:8318");
let provider = NearAiChatProvider::new(cfg.clone()).expect("provider");
let provider = NearAiChatProvider::new(cfg.clone(), test_session()).expect("provider");
assert_eq!(
provider.api_url("chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions"
);
cfg.base_url = "http://127.0.0.1:8318/".to_string();
let provider = NearAiChatProvider::new(cfg).expect("provider");
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
assert_eq!(
provider.api_url("/chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions"
@@ -657,7 +795,7 @@ mod tests {
fn test_api_url_with_base_already_v1() {
let cfg = test_nearai_config("http://127.0.0.1:8318/v1");
let provider = NearAiChatProvider::new(cfg).expect("provider");
let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider");
assert_eq!(
provider.api_url("chat/completions"),
"http://127.0.0.1:8318/v1/chat/completions"
-18
View File
@@ -153,8 +153,6 @@ pub struct CompletionResponse {
pub input_tokens: u32,
pub output_tokens: u32,
pub finish_reason: FinishReason,
/// Provider-specific response ID (e.g. for NEAR AI response chaining).
pub response_id: Option<String>,
}
/// Why the completion finished.
@@ -256,8 +254,6 @@ pub struct ToolCompletionResponse {
pub input_tokens: u32,
pub output_tokens: u32,
pub finish_reason: FinishReason,
/// Provider-specific response ID (e.g. for NEAR AI response chaining).
pub response_id: Option<String>,
}
/// Metadata about a model returned by the provider's API.
@@ -327,20 +323,6 @@ pub trait LlmProvider: Send + Sync {
})
}
/// Seed a response chain for a thread (e.g. restoring from DB).
///
/// Providers that support response chaining (e.g. NEAR AI `previous_response_id`)
/// store this so subsequent calls send only delta messages.
fn seed_response_chain(&self, _thread_id: &str, _response_id: String) {}
/// Get the last response chain ID for a thread.
///
/// Returns `None` if the provider doesn't support chaining or has no
/// stored state for this thread.
fn get_response_chain_id(&self, _thread_id: &str) -> Option<String> {
None
}
/// Calculate cost for a completion.
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
let (input_cost, output_cost) = self.cost_per_token();
+687 -159
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -228,14 +228,6 @@ impl LlmProvider for CachedProvider {
fn set_model(&self, model: &str) -> Result<(), LlmError> {
self.inner.set_model(model)
}
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.inner.seed_response_chain(thread_id, response_id);
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.inner.get_response_chain_id(thread_id)
}
}
#[cfg(test)]
-8
View File
@@ -210,14 +210,6 @@ impl LlmProvider for RetryProvider {
self.inner.set_model(model)
}
fn seed_response_chain(&self, thread_id: &str, response_id: String) {
self.inner.seed_response_chain(thread_id, response_id)
}
fn get_response_chain_id(&self, thread_id: &str) -> Option<String> {
self.inner.get_response_chain_id(thread_id)
}
fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal {
self.inner.calculate_cost(input_tokens, output_tokens)
}
-2
View File
@@ -445,7 +445,6 @@ where
input_tokens: saturate_u32(response.usage.input_tokens),
output_tokens: saturate_u32(response.usage.output_tokens),
finish_reason: finish,
response_id: None,
})
}
@@ -511,7 +510,6 @@ where
input_tokens: saturate_u32(response.usage.input_tokens),
output_tokens: saturate_u32(response.usage.output_tokens),
finish_reason: finish,
response_id: None,
})
}
+19 -9
View File
@@ -513,20 +513,30 @@ impl SessionManager {
})? {
value
} else {
tracing::warn!(
"nearai.session_token missing; falling back to legacy nearai.session for backwards compatibility"
);
store
// Try the legacy key. Only warn if it actually exists (real
// backwards-compat migration). When neither key is present
// (fresh install), just return the "No session in DB" error.
let legacy = store
.get_setting(&user_id, "nearai.session")
.await
.map_err(|e| LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: format!("DB query failed: {}", e),
})?
.ok_or(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "No session in DB".to_string(),
})?
})?;
match legacy {
Some(value) => {
tracing::warn!(
"nearai.session_token missing; falling back to legacy nearai.session for backwards compatibility"
);
value
}
None => {
return Err(LlmError::SessionRenewalFailed {
provider: "nearai".to_string(),
reason: "No session in DB".to_string(),
});
}
}
};
let session: SessionData =
+18 -31
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use clap::Parser;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
use tracing_subscriber::EnvFilter;
use ironclaw::{
agent::{Agent, AgentDeps, SessionManager},
@@ -14,7 +14,7 @@ use ironclaw::{
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
},
web::log_layer::{LogBroadcaster, WebLogLayer},
web::log_layer::LogBroadcaster,
},
cli::{
Cli, Command, run_mcp_command, run_pairing_command, run_service_command,
@@ -201,6 +201,9 @@ async fn main() -> anyhow::Result<()> {
)
.init();
let _ = dotenvy::dotenv();
ironclaw::bootstrap::load_ironclaw_env();
return ironclaw::cli::run_doctor_command().await;
}
Some(Command::Status) => {
@@ -210,6 +213,9 @@ async fn main() -> anyhow::Result<()> {
)
.init();
let _ = dotenvy::dotenv();
ironclaw::bootstrap::load_ironclaw_env();
return run_status_command().await;
}
Some(Command::Worker {
@@ -360,23 +366,14 @@ async fn main() -> anyhow::Result<()> {
};
let session = create_session_manager(session_config).await;
// Initialize tracing
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=warn"));
// Create log broadcaster before tracing init so the WebLogLayer can capture all events.
// This gets wired to the gateway's /api/logs/events SSE endpoint later.
let log_broadcaster = Arc::new(LogBroadcaster::new());
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_writer(ironclaw::tracing_fmt::TruncatingStderr::default()),
)
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
.init();
// Initialize tracing with a reloadable EnvFilter so the gateway can switch
// log levels (e.g. ironclaw=debug) at runtime without restarting.
let log_level_handle =
ironclaw::channels::web::log_layer::init_tracing(Arc::clone(&log_broadcaster));
// Create CLI channel
let repl_channel = if let Some(ref msg) = cli.message {
@@ -730,7 +727,6 @@ async fn main() -> anyhow::Result<()> {
// Initialize tool registry
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<Arc<dyn EmbeddingProvider>> = if config.embeddings.enabled {
@@ -1024,11 +1020,12 @@ async fn main() -> anyhow::Result<()> {
// Set up orchestrator for sandboxed job execution
// When allow_local_tools is false (default), the LLM uses create_job for FS/shell work.
// When allow_local_tools is true, dev tools are also registered directly (current behavior).
if 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 =
config.builder.enabled && (config.agent.allow_local_tools || !config.sandbox.enabled);
if 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"
);
}
// Shared state for job events (used by both orchestrator and web gateway)
@@ -1079,7 +1076,6 @@ async fn main() -> anyhow::Result<()> {
}
});
tracing::info!("Orchestrator API started on :50051, sandbox delegation enabled");
if config.claude_code.enabled {
tracing::info!(
"Claude Code sandbox mode available (model: {}, max_turns: {})",
@@ -1333,9 +1329,6 @@ async fn main() -> anyhow::Result<()> {
// Seed workspace with core identity files on first boot
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);
@@ -1426,6 +1419,7 @@ async fn main() -> anyhow::Result<()> {
}
gw = gw.with_session_manager(Arc::clone(&session_manager));
gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster));
gw = gw.with_log_level_handle(Arc::clone(&log_level_handle));
gw = gw.with_tool_registry(Arc::clone(&tools));
if let Some(ref ext_mgr) = extension_manager {
gw = gw.with_extension_manager(Arc::clone(ext_mgr));
@@ -1464,11 +1458,6 @@ async fn main() -> anyhow::Result<()> {
gw.auth_token()
));
tracing::info!(
"Web gateway enabled on {}:{}",
gw_config.host,
gw_config.port
);
tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
channel_names.push("gateway".to_string());
@@ -1511,8 +1500,6 @@ async fn main() -> anyhow::Result<()> {
Some(session_manager),
);
tracing::info!("Agent initialized, starting main loop...");
// Print boot screen for interactive CLI mode (not single-message mode).
if config.channels.cli.enabled && cli.message.is_none() {
let boot_info = ironclaw::boot_screen::BootInfo {
+54 -1
View File
@@ -826,6 +826,12 @@ impl SetupWizard {
self.session_manager = Some(session);
// Persist session token to the database so the runtime can load it
// via `attach_store()` → `load_session_from_db()` without the
// backwards-compat fallback. The session manager saved to disk but
// doesn't have a DB store attached during onboarding.
self.persist_session_to_db().await;
// If the user chose the API key path, NEARAI_API_KEY is now set
// in the environment. Persist it to the encrypted secrets store
// so inject_llm_keys_from_secrets() can load it on future runs.
@@ -1160,7 +1166,6 @@ impl SetupWizard {
base_url,
auth_base_url,
session_path: crate::llm::session::default_session_path(),
api_mode: crate::config::NearAiApiMode::Responses,
api_key: None,
fallback_model: None,
max_retries: 3,
@@ -1861,6 +1866,54 @@ impl SetupWizard {
Ok(())
}
/// Persist the NEAR AI session token to the database.
///
/// The session manager writes to disk during `ensure_authenticated()` but
/// doesn't have a DB store attached during onboarding. This reads the
/// session file from disk and stores it under the `nearai.session_token`
/// key so the runtime's `attach_store()` finds it without fallback.
///
/// Best-effort: silently ignores errors (no DB connection yet, no
/// session file, etc.).
async fn persist_session_to_db(&self) {
let session_path = crate::llm::session::default_session_path();
let data = match std::fs::read_to_string(&session_path) {
Ok(d) if !d.trim().is_empty() => d,
_ => return,
};
let value: serde_json::Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(_) => return,
};
#[cfg(feature = "postgres")]
if let Some(ref pool) = self.db_pool {
let store = crate::history::Store::from_pool(pool.clone());
if let Err(e) = store
.set_setting("default", "nearai.session_token", &value)
.await
{
tracing::debug!("Could not persist session token to postgres: {}", e);
} else {
tracing::debug!("Session token persisted to database");
return;
}
}
#[cfg(feature = "libsql")]
if let Some(ref backend) = self.db_backend {
use crate::db::SettingsStore as _;
if let Err(e) = backend
.set_setting("default", "nearai.session_token", &value)
.await
{
tracing::debug!("Could not persist session token to libsql: {}", e);
} else {
tracing::debug!("Session token persisted to database");
}
}
}
/// Persist settings to DB and bootstrap .env after each step.
///
/// Silently ignores errors (e.g., DB not connected yet before step 1
+1 -1
View File
@@ -197,7 +197,7 @@ impl SkillRegistry {
let source = make_source(path.clone());
match self.load_skill_md(&skill_md, trust, source).await {
Ok((name, skill)) => {
tracing::info!("Loaded skill: {}", name);
tracing::debug!("Loaded skill: {}", name);
results.push((name, skill));
}
Err(e) => {
-2
View File
@@ -168,7 +168,6 @@ impl LlmProvider for StubLlm {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
@@ -186,7 +185,6 @@ impl LlmProvider for StubLlm {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
}
-2
View File
@@ -227,7 +227,6 @@ impl WorkerHttpClient {
input_tokens: proxy_resp.input_tokens,
output_tokens: proxy_resp.output_tokens,
finish_reason: parse_finish_reason(&proxy_resp.finish_reason),
response_id: None,
})
}
@@ -255,7 +254,6 @@ impl WorkerHttpClient {
input_tokens: proxy_resp.input_tokens,
output_tokens: proxy_resp.output_tokens,
finish_reason: parse_finish_reason(&proxy_resp.finish_reason),
response_id: None,
})
}
+3 -1
View File
@@ -15,6 +15,7 @@ use ironclaw::{
config::Config,
history::Store,
llm::{SessionConfig, create_llm_provider, create_session_manager},
safety::SafetyLayer,
workspace::Workspace,
};
@@ -96,7 +97,8 @@ async fn test_heartbeat_end_to_end() {
let hb_config = ironclaw::agent::HeartbeatConfig::default();
let hygiene_config = ironclaw::workspace::hygiene::HygieneConfig::default();
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm);
let safety = Arc::new(SafetyLayer::new(&config.safety));
let runner = HeartbeatRunner::new(hb_config, hygiene_config, workspace, llm, safety);
let result = runner.check_heartbeat().await;
+2 -5
View File
@@ -71,7 +71,6 @@ impl LlmProvider for MockLlmProvider {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
@@ -97,7 +96,6 @@ impl LlmProvider for MockLlmProvider {
input_tokens: 15,
output_tokens: 8,
finish_reason: FinishReason::ToolUse,
response_id: None,
})
} else {
Ok(ToolCompletionResponse {
@@ -106,7 +104,6 @@ impl LlmProvider for MockLlmProvider {
input_tokens: 10,
output_tokens: 4,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
}
@@ -145,7 +142,6 @@ impl LlmProvider for FixedModelProvider {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
@@ -159,7 +155,6 @@ impl LlmProvider for FixedModelProvider {
input_tokens: 10,
output_tokens: 5,
finish_reason: FinishReason::Stop,
response_id: None,
})
}
@@ -190,6 +185,7 @@ async fn start_test_server_with_provider(
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,
@@ -674,6 +670,7 @@ async fn test_no_llm_provider_returns_503() {
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,
+1
View File
@@ -43,6 +43,7 @@ async fn start_test_server() -> (
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,