mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f18422b88 | ||
|
|
448383cfb0 | ||
|
|
7df356c109 |
+8
-10
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -2490,7 +2490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ironclaw"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))),
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+199
-61
@@ -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"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user