mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
* fix: comprehensive security hardening across all layers Critical: - Replace --dangerously-skip-permissions with explicit tool allowlist via settings.json (Claude Code bridge) - Constant-time token comparison (subtle crate) in web auth and orchestrator auth to prevent timing attacks High: - Revoke tokens and clean up handles on container creation failure - Drop SETUID/SETGID capabilities from containers (keep only CHOWN) - Disable redirect following in HTTP tool and WASM wrapper (SSRF) - Reject URL userinfo (@) in WASM allowlist parser (host confusion) - Fix binary body bypassing leak detection (from_utf8 -> from_utf8_lossy) - Protect identity files from LLM overwrites (prompt injection defense) - Prevent tool shadowing: built-in tools cannot be replaced dynamically - User-scoped job APIs: list/detail/cancel/restart/prompt/events/files - CORS restricted to localhost origins, WebSocket origin validation - Sandbox shell fail-closed: no silent fallback to unsandboxed execution - Scrub secrets from log broadcaster before SSE broadcast - XSS sanitization on rendered markdown in web UI - WASM epoch ticker thread so timeout deadlines actually fire Medium: - Cap state transition history at 200 entries - SSE/WebSocket connection limit (100 max) - Request body size limit (1MB) - Response body size limit enforcement in WASM HTTP - UTF-8 safe string truncation (routine engine, shell tool) - Fix PolicyAction::Sanitize to actually run the sanitizer - TOCTOU fix in scheduler and context manager (hold write lock) - Project file serving moved behind auth - Path traversal guard on project_id - Session file permissions set to 0600 on unix - AtomicUsize for routine running_count (panic-safe) - Completion detection hardened against false positives and tool injection - Tool output no longer drives job completion (only LLM response) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address security review findings across all layers - Fix path traversal sandbox bypass via lexical normalization (file.rs) - Fix SSRF via DNS rebinding with pre-request hostname resolution (http.rs) - Add token budget enforcement on LLM calls (reasoning.rs, state.rs) - Fix cross-user chat history leak with ownership verification (store.rs, server.rs) - Add sliding-window rate limiter on gateway chat endpoint (server.rs) - Harden extension install: HTTPS-only, 50MB cap, WASM magic validation (manager.rs) - Add destructive command blocklist that overrides shell auto-approval (shell.rs) - Add 5MB response body size cap to HTTP tool (http.rs) Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: deduplicate shared helpers and remove dead code Extract floor_char_boundary and llm_signals_completion into src/util.rs, unifying diverging phrase lists from agent/worker.rs and worker/runtime.rs. Remove dead RespondResult::usage(), duplicate PROTECTED_IDENTITY_FILES constant, double LeakDetector scanning in WebLogLayer, and invalid 0.0.0.0 origin from WebSocket allow list. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review findings and CI test failures - Fix record_failed_approve: .truncate(true) wiped the attempts file before reading, so failed pairing attempts never accumulated and rate limiting never triggered. - Guard wizard WASM test: skip gracefully when channel build artifacts are absent (CI doesn't compile wasm32-wasip2 targets). - Fix DNS rebinding check: use port 0 instead of hardcoded 443, since the port is irrelevant for hostname resolution. - Remove hardcoded CORS port 3001: the dynamic addr.port() entries already cover the actual server port. - Require WebSocket Origin header: reject connections that omit it entirely, since browsers always send Origin for WS upgrades and a missing header indicates a non-browser client bypassing the check. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address second round of PR review findings - store.rs: reintroduce file locking around read-modify-write in record_failed_approve (concurrent callers could clobber each other). - sse.rs: replace load+check+fetch_add with atomic fetch_update in both subscribe_raw() and subscribe() to prevent overshooting max_connections. - ws.rs: decrement WS tracker before early return when subscribe_raw() returns None (connection limit reached), fixing a counter leak. - server.rs: parse WS Origin host exactly instead of prefix matching, preventing bypass via crafted origins like http://localhost.evil.com. - workspace_integration.rs: skip tests gracefully when Postgres is unreachable instead of panicking (fixes 10 CI failures). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add Origin header to WS integration tests The Origin header requirement added in a3b0190 broke the WS gateway integration tests. Test clients now send Origin: http://127.0.0.1:{port} to match the server's localhost validation. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
1027 lines
34 KiB
Rust
1027 lines
34 KiB
Rust
//! LLM reasoning capabilities for planning, tool selection, and evaluation.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::LlmError;
|
|
|
|
use crate::llm::{
|
|
ChatMessage, CompletionRequest, LlmProvider, ToolCall, ToolCompletionRequest, ToolDefinition,
|
|
};
|
|
use crate::safety::SafetyLayer;
|
|
|
|
/// Context for reasoning operations.
|
|
pub struct ReasoningContext {
|
|
/// Conversation history.
|
|
pub messages: Vec<ChatMessage>,
|
|
/// Available tools.
|
|
pub available_tools: Vec<ToolDefinition>,
|
|
/// Job description if working on a job.
|
|
pub job_description: Option<String>,
|
|
/// Current state description.
|
|
pub current_state: Option<String>,
|
|
/// Opaque metadata forwarded to the LLM provider (e.g. thread_id for chaining).
|
|
pub metadata: std::collections::HashMap<String, String>,
|
|
}
|
|
|
|
impl ReasoningContext {
|
|
/// Create a new reasoning context.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
messages: Vec::new(),
|
|
available_tools: Vec::new(),
|
|
job_description: None,
|
|
current_state: None,
|
|
metadata: std::collections::HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Add a message to the context.
|
|
pub fn with_message(mut self, message: ChatMessage) -> Self {
|
|
self.messages.push(message);
|
|
self
|
|
}
|
|
|
|
/// Set messages directly (for session-based context).
|
|
pub fn with_messages(mut self, messages: Vec<ChatMessage>) -> Self {
|
|
self.messages = messages;
|
|
self
|
|
}
|
|
|
|
/// Set available tools.
|
|
pub fn with_tools(mut self, tools: Vec<ToolDefinition>) -> Self {
|
|
self.available_tools = tools;
|
|
self
|
|
}
|
|
|
|
/// Set job description.
|
|
pub fn with_job(mut self, description: impl Into<String>) -> Self {
|
|
self.job_description = Some(description.into());
|
|
self
|
|
}
|
|
|
|
/// Set metadata (forwarded to the LLM provider).
|
|
pub fn with_metadata(mut self, metadata: std::collections::HashMap<String, String>) -> Self {
|
|
self.metadata = metadata;
|
|
self
|
|
}
|
|
}
|
|
|
|
impl Default for ReasoningContext {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// A planned action to take.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PlannedAction {
|
|
/// Tool to use.
|
|
pub tool_name: String,
|
|
/// Parameters for the tool.
|
|
pub parameters: serde_json::Value,
|
|
/// Reasoning for this action.
|
|
pub reasoning: String,
|
|
/// Expected outcome.
|
|
pub expected_outcome: String,
|
|
}
|
|
|
|
/// Result of planning.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ActionPlan {
|
|
/// Overall goal understanding.
|
|
pub goal: String,
|
|
/// Planned sequence of actions.
|
|
pub actions: Vec<PlannedAction>,
|
|
/// Estimated total cost.
|
|
pub estimated_cost: Option<f64>,
|
|
/// Estimated total time in seconds.
|
|
pub estimated_time_secs: Option<u64>,
|
|
/// Confidence in the plan (0-1).
|
|
pub confidence: f64,
|
|
}
|
|
|
|
/// Result of tool selection.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ToolSelection {
|
|
/// Selected tool name.
|
|
pub tool_name: String,
|
|
/// Parameters for the tool.
|
|
pub parameters: serde_json::Value,
|
|
/// Reasoning for the selection.
|
|
pub reasoning: String,
|
|
/// Alternative tools considered.
|
|
pub alternatives: Vec<String>,
|
|
}
|
|
|
|
/// Token usage from a single LLM call.
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct TokenUsage {
|
|
pub input_tokens: u32,
|
|
pub output_tokens: u32,
|
|
}
|
|
|
|
impl TokenUsage {
|
|
pub fn total(&self) -> u32 {
|
|
self.input_tokens + self.output_tokens
|
|
}
|
|
}
|
|
|
|
/// Result of a response with potential tool calls.
|
|
///
|
|
/// Used by the agent loop to handle tool execution before returning a final response.
|
|
#[derive(Debug, Clone)]
|
|
pub enum RespondResult {
|
|
/// A text response (no tools needed).
|
|
Text(String),
|
|
/// The model wants to call tools. Caller should execute them and call back.
|
|
/// Includes the optional content from the assistant message (some models
|
|
/// include explanatory text alongside tool calls).
|
|
ToolCalls {
|
|
tool_calls: Vec<ToolCall>,
|
|
content: Option<String>,
|
|
},
|
|
}
|
|
|
|
/// A `RespondResult` bundled with the token usage from the LLM call that produced it.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RespondOutput {
|
|
pub result: RespondResult,
|
|
pub usage: TokenUsage,
|
|
}
|
|
|
|
/// Reasoning engine for the agent.
|
|
pub struct Reasoning {
|
|
llm: Arc<dyn LlmProvider>,
|
|
#[allow(dead_code)] // Will be used for sanitizing tool outputs
|
|
safety: Arc<SafetyLayer>,
|
|
/// Optional workspace for loading identity/system prompts.
|
|
workspace_system_prompt: Option<String>,
|
|
}
|
|
|
|
impl Reasoning {
|
|
/// Create a new reasoning engine.
|
|
pub fn new(llm: Arc<dyn LlmProvider>, safety: Arc<SafetyLayer>) -> Self {
|
|
Self {
|
|
llm,
|
|
safety,
|
|
workspace_system_prompt: None,
|
|
}
|
|
}
|
|
|
|
/// Set a custom system prompt from workspace identity files.
|
|
///
|
|
/// This is typically loaded from workspace.system_prompt() which combines
|
|
/// AGENTS.md, SOUL.md, USER.md, and IDENTITY.md into a unified prompt.
|
|
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
|
if !prompt.is_empty() {
|
|
self.workspace_system_prompt = Some(prompt);
|
|
}
|
|
self
|
|
}
|
|
|
|
/// Generate a plan for completing a goal.
|
|
pub async fn plan(&self, context: &ReasoningContext) -> Result<ActionPlan, LlmError> {
|
|
let system_prompt = self.build_planning_prompt(context);
|
|
|
|
let mut messages = vec![ChatMessage::system(system_prompt)];
|
|
messages.extend(context.messages.clone());
|
|
|
|
if let Some(ref job) = context.job_description {
|
|
messages.push(ChatMessage::user(format!(
|
|
"Please create a plan to complete this job:\n\n{}",
|
|
job
|
|
)));
|
|
}
|
|
|
|
let request = CompletionRequest::new(messages)
|
|
.with_max_tokens(2048)
|
|
.with_temperature(0.3);
|
|
|
|
let response = self.llm.complete(request).await?;
|
|
|
|
// Parse the plan from the response
|
|
self.parse_plan(&response.content)
|
|
}
|
|
|
|
/// Select the best tool for the current situation.
|
|
pub async fn select_tool(
|
|
&self,
|
|
context: &ReasoningContext,
|
|
) -> Result<Option<ToolSelection>, LlmError> {
|
|
let tools = self.select_tools(context).await?;
|
|
Ok(tools.into_iter().next())
|
|
}
|
|
|
|
/// Select tools to execute (may return multiple for parallel execution).
|
|
///
|
|
/// The LLM may return multiple tool calls if it determines they can be
|
|
/// executed in parallel. This enables more efficient job completion.
|
|
pub async fn select_tools(
|
|
&self,
|
|
context: &ReasoningContext,
|
|
) -> Result<Vec<ToolSelection>, LlmError> {
|
|
if context.available_tools.is_empty() {
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
let mut request =
|
|
ToolCompletionRequest::new(context.messages.clone(), context.available_tools.clone())
|
|
.with_max_tokens(1024)
|
|
.with_tool_choice("auto");
|
|
request.metadata = context.metadata.clone();
|
|
|
|
let response = self.llm.complete_with_tools(request).await?;
|
|
|
|
let reasoning = response.content.unwrap_or_default();
|
|
|
|
let selections: Vec<ToolSelection> = response
|
|
.tool_calls
|
|
.into_iter()
|
|
.map(|tool_call| ToolSelection {
|
|
tool_name: tool_call.name,
|
|
parameters: tool_call.arguments,
|
|
reasoning: reasoning.clone(),
|
|
alternatives: vec![],
|
|
})
|
|
.collect();
|
|
|
|
Ok(selections)
|
|
}
|
|
|
|
/// Evaluate whether a task was completed successfully.
|
|
pub async fn evaluate_success(
|
|
&self,
|
|
context: &ReasoningContext,
|
|
result: &str,
|
|
) -> Result<SuccessEvaluation, LlmError> {
|
|
let system_prompt = r#"You are an evaluation assistant. Your job is to determine if a task was completed successfully.
|
|
|
|
Analyze the task description and the result, then provide:
|
|
1. Whether the task was successful (true/false)
|
|
2. A confidence score (0-1)
|
|
3. Detailed reasoning
|
|
4. Any issues found
|
|
5. Suggestions for improvement
|
|
|
|
Respond in JSON format:
|
|
{
|
|
"success": true/false,
|
|
"confidence": 0.0-1.0,
|
|
"reasoning": "...",
|
|
"issues": ["..."],
|
|
"suggestions": ["..."]
|
|
}"#;
|
|
|
|
let mut messages = vec![ChatMessage::system(system_prompt)];
|
|
|
|
if let Some(ref job) = context.job_description {
|
|
messages.push(ChatMessage::user(format!(
|
|
"Task description:\n{}\n\nResult:\n{}",
|
|
job, result
|
|
)));
|
|
} else {
|
|
messages.push(ChatMessage::user(format!(
|
|
"Result to evaluate:\n{}",
|
|
result
|
|
)));
|
|
}
|
|
|
|
let request = CompletionRequest::new(messages)
|
|
.with_max_tokens(1024)
|
|
.with_temperature(0.1);
|
|
|
|
let response = self.llm.complete(request).await?;
|
|
|
|
self.parse_evaluation(&response.content)
|
|
}
|
|
|
|
/// Generate a response to a user message.
|
|
///
|
|
/// If tools are available in the context, uses tool completion mode.
|
|
/// This is a convenience wrapper around `respond_with_tools()` that formats
|
|
/// tool calls as text for simple cases. Use `respond_with_tools()` when you
|
|
/// need to actually execute tool calls in an agentic loop.
|
|
pub async fn respond(&self, context: &ReasoningContext) -> Result<String, LlmError> {
|
|
let output = self.respond_with_tools(context).await?;
|
|
match output.result {
|
|
RespondResult::Text(text) => Ok(text),
|
|
RespondResult::ToolCalls {
|
|
tool_calls: calls, ..
|
|
} => {
|
|
// Format tool calls as text (legacy behavior for non-agentic callers)
|
|
let tool_info: Vec<String> = calls
|
|
.iter()
|
|
.map(|tc| format!("`{}({})`", tc.name, tc.arguments))
|
|
.collect();
|
|
Ok(format!("[Calling tools: {}]", tool_info.join(", ")))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Generate a response that may include tool calls, with token usage tracking.
|
|
///
|
|
/// Returns `RespondOutput` containing the result and token usage from the LLM call.
|
|
/// The caller should use `usage` to track cost/budget against the job.
|
|
pub async fn respond_with_tools(
|
|
&self,
|
|
context: &ReasoningContext,
|
|
) -> Result<RespondOutput, LlmError> {
|
|
let system_prompt = self.build_conversation_prompt(context);
|
|
|
|
let mut messages = vec![ChatMessage::system(system_prompt)];
|
|
messages.extend(context.messages.clone());
|
|
|
|
// If we have tools, use tool completion mode
|
|
if !context.available_tools.is_empty() {
|
|
let mut request = ToolCompletionRequest::new(messages, context.available_tools.clone())
|
|
.with_max_tokens(4096)
|
|
.with_temperature(0.7)
|
|
.with_tool_choice("auto");
|
|
request.metadata = context.metadata.clone();
|
|
|
|
let response = self.llm.complete_with_tools(request).await?;
|
|
let usage = TokenUsage {
|
|
input_tokens: response.input_tokens,
|
|
output_tokens: response.output_tokens,
|
|
};
|
|
|
|
// If there were tool calls, return them for execution
|
|
if !response.tool_calls.is_empty() {
|
|
return Ok(RespondOutput {
|
|
result: RespondResult::ToolCalls {
|
|
tool_calls: response.tool_calls,
|
|
content: response.content,
|
|
},
|
|
usage,
|
|
});
|
|
}
|
|
|
|
let content = response
|
|
.content
|
|
.unwrap_or_else(|| "I'm not sure how to respond to that.".to_string());
|
|
|
|
// Some models (e.g. GLM-4.7) emit tool calls as XML tags in content
|
|
// instead of using the structured tool_calls field. Try to recover
|
|
// them before giving up and returning plain text.
|
|
let recovered = recover_tool_calls_from_content(&content, &context.available_tools);
|
|
if !recovered.is_empty() {
|
|
let cleaned = clean_response(&content);
|
|
return Ok(RespondOutput {
|
|
result: RespondResult::ToolCalls {
|
|
tool_calls: recovered,
|
|
content: if cleaned.is_empty() {
|
|
None
|
|
} else {
|
|
Some(cleaned)
|
|
},
|
|
},
|
|
usage,
|
|
});
|
|
}
|
|
|
|
Ok(RespondOutput {
|
|
result: RespondResult::Text(clean_response(&content)),
|
|
usage,
|
|
})
|
|
} else {
|
|
// No tools, use simple completion
|
|
let mut request = CompletionRequest::new(messages)
|
|
.with_max_tokens(4096)
|
|
.with_temperature(0.7);
|
|
request.metadata = context.metadata.clone();
|
|
|
|
let response = self.llm.complete(request).await?;
|
|
Ok(RespondOutput {
|
|
result: RespondResult::Text(clean_response(&response.content)),
|
|
usage: TokenUsage {
|
|
input_tokens: response.input_tokens,
|
|
output_tokens: response.output_tokens,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
fn build_planning_prompt(&self, context: &ReasoningContext) -> String {
|
|
let tools_desc = if context.available_tools.is_empty() {
|
|
"No tools available.".to_string()
|
|
} else {
|
|
context
|
|
.available_tools
|
|
.iter()
|
|
.map(|t| format!("- {}: {}", t.name, t.description))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
};
|
|
|
|
format!(
|
|
r#"You are a planning assistant for an autonomous agent. Your job is to create detailed, actionable plans.
|
|
|
|
Available tools:
|
|
{tools_desc}
|
|
|
|
When creating a plan:
|
|
1. Break down the goal into specific, achievable steps
|
|
2. Select the most appropriate tool for each step
|
|
3. Consider dependencies between steps
|
|
4. Estimate costs and time realistically
|
|
5. Identify potential failure points
|
|
|
|
Respond with a JSON plan in this format:
|
|
{{
|
|
"goal": "Clear statement of the goal",
|
|
"actions": [
|
|
{{
|
|
"tool_name": "tool_to_use",
|
|
"parameters": {{}},
|
|
"reasoning": "Why this action",
|
|
"expected_outcome": "What should happen"
|
|
}}
|
|
],
|
|
"estimated_cost": 0.0,
|
|
"estimated_time_secs": 0,
|
|
"confidence": 0.0-1.0
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
fn build_conversation_prompt(&self, context: &ReasoningContext) -> String {
|
|
let tools_section = if context.available_tools.is_empty() {
|
|
String::new()
|
|
} else {
|
|
let tool_list: Vec<String> = context
|
|
.available_tools
|
|
.iter()
|
|
.map(|t| format!(" - {}: {}", t.name, t.description))
|
|
.collect();
|
|
format!(
|
|
"\n\n## Available Tools\nYou have access to these tools:\n{}\n\nCall tools when they would help accomplish the task.",
|
|
tool_list.join("\n")
|
|
)
|
|
};
|
|
|
|
// Include workspace identity prompt if available
|
|
let identity_section = if let Some(ref identity) = self.workspace_system_prompt {
|
|
format!("\n\n---\n\n{}", identity)
|
|
} else {
|
|
String::new()
|
|
};
|
|
|
|
format!(
|
|
r#"You are NEAR AI Agent, an autonomous assistant.
|
|
|
|
## Response Format
|
|
|
|
If you need to think through a problem, wrap your thinking in <thinking> tags. Everything outside these tags goes directly to the user.
|
|
|
|
Example:
|
|
<thinking>
|
|
Let me consider the options...
|
|
Option 1: ...
|
|
Option 2: ...
|
|
I'll go with option 1.
|
|
</thinking>
|
|
Here's the solution: [actual response to user]
|
|
|
|
## Guidelines
|
|
- Be concise and direct
|
|
- Use markdown formatting where helpful
|
|
- For code, use appropriate code blocks with language tags
|
|
- Call tools when they would help accomplish the task{}
|
|
|
|
The user sees ONLY content outside <thinking> tags.{}"#,
|
|
tools_section, identity_section
|
|
)
|
|
}
|
|
|
|
fn parse_plan(&self, content: &str) -> Result<ActionPlan, LlmError> {
|
|
// Try to extract JSON from the response
|
|
let json_str = extract_json(content).unwrap_or(content);
|
|
|
|
serde_json::from_str(json_str).map_err(|e| LlmError::InvalidResponse {
|
|
provider: self.llm.model_name().to_string(),
|
|
reason: format!("Failed to parse plan: {}", e),
|
|
})
|
|
}
|
|
|
|
fn parse_evaluation(&self, content: &str) -> Result<SuccessEvaluation, LlmError> {
|
|
let json_str = extract_json(content).unwrap_or(content);
|
|
|
|
serde_json::from_str(json_str).map_err(|e| LlmError::InvalidResponse {
|
|
provider: self.llm.model_name().to_string(),
|
|
reason: format!("Failed to parse evaluation: {}", e),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Result of success evaluation.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SuccessEvaluation {
|
|
pub success: bool,
|
|
pub confidence: f64,
|
|
pub reasoning: String,
|
|
#[serde(default)]
|
|
pub issues: Vec<String>,
|
|
#[serde(default)]
|
|
pub suggestions: Vec<String>,
|
|
}
|
|
|
|
/// Extract JSON from text that might contain other content.
|
|
fn extract_json(text: &str) -> Option<&str> {
|
|
// Find the first { and last } to extract JSON
|
|
let start = text.find('{')?;
|
|
let end = text.rfind('}')?;
|
|
if start < end {
|
|
Some(&text[start..=end])
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Clean up LLM response by stripping model-internal tags and reasoning patterns.
|
|
///
|
|
/// Some models (GLM-4.7, etc.) emit XML-tagged internal state like
|
|
/// Try to extract tool calls from content text where the model emitted them
|
|
/// as XML tags instead of using the structured tool_calls field.
|
|
///
|
|
/// Handles these formats:
|
|
/// - `<tool_call>tool_name</tool_call>` (bare name)
|
|
/// - `<tool_call>{"name":"x","arguments":{}}</tool_call>` (JSON)
|
|
/// - `<|tool_call|>...<|/tool_call|>` (pipe-delimited variant)
|
|
/// - `<function_call>...</function_call>` (function_call variant)
|
|
///
|
|
/// Only returns calls whose name matches an available tool.
|
|
fn recover_tool_calls_from_content(
|
|
content: &str,
|
|
available_tools: &[ToolDefinition],
|
|
) -> Vec<ToolCall> {
|
|
let tool_names: std::collections::HashSet<&str> =
|
|
available_tools.iter().map(|t| t.name.as_str()).collect();
|
|
let mut calls = Vec::new();
|
|
|
|
for (open, close) in &[
|
|
("<tool_call>", "</tool_call>"),
|
|
("<|tool_call|>", "<|/tool_call|>"),
|
|
("<function_call>", "</function_call>"),
|
|
("<|function_call|>", "<|/function_call|>"),
|
|
] {
|
|
let mut remaining = content;
|
|
while let Some(start) = remaining.find(open) {
|
|
let inner_start = start + open.len();
|
|
let after = &remaining[inner_start..];
|
|
let Some(end) = after.find(close) else {
|
|
break;
|
|
};
|
|
let inner = after[..end].trim();
|
|
remaining = &after[end + close.len()..];
|
|
|
|
if inner.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Try JSON first: {"name":"x","arguments":{}}
|
|
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(inner) {
|
|
if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) {
|
|
if tool_names.contains(name) {
|
|
let arguments = parsed
|
|
.get("arguments")
|
|
.cloned()
|
|
.unwrap_or(serde_json::Value::Object(Default::default()));
|
|
calls.push(ToolCall {
|
|
id: format!("recovered_{}", calls.len()),
|
|
name: name.to_string(),
|
|
arguments,
|
|
});
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bare tool name (e.g. "<tool_call>tool_list</tool_call>")
|
|
let name = inner.trim();
|
|
if tool_names.contains(name) {
|
|
calls.push(ToolCall {
|
|
id: format!("recovered_{}", calls.len()),
|
|
name: name.to_string(),
|
|
arguments: serde_json::Value::Object(Default::default()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
calls
|
|
}
|
|
|
|
/// `<tool_call>tool_list</tool_call>` or `<|tool_call|>` in the content field
|
|
/// instead of using the standard OpenAI tool_calls array. We strip all of
|
|
/// these before the response reaches channels/users.
|
|
fn clean_response(text: &str) -> String {
|
|
let text = strip_internal_tags(text);
|
|
strip_reasoning_patterns(&text)
|
|
}
|
|
|
|
/// Tags that are model-internal and should never reach users.
|
|
const INTERNAL_TAGS: &[&str] = &["thinking", "tool_call", "function_call", "tool_calls"];
|
|
|
|
/// Strip all model-internal XML tags from LLM output.
|
|
///
|
|
/// Handles standard XML tags (`<tag>...</tag>`) and pipe-delimited variants
|
|
/// (`<|tag|>...<|/tag|>`) used by some models (e.g. GLM-4.7).
|
|
fn strip_internal_tags(text: &str) -> String {
|
|
let mut result = text.to_string();
|
|
for tag in INTERNAL_TAGS {
|
|
result = strip_xml_tag(&result, tag);
|
|
result = strip_pipe_tag(&result, tag);
|
|
}
|
|
// Collapse triple+ newlines left behind by removed blocks
|
|
while result.contains("\n\n\n") {
|
|
result = result.replace("\n\n\n", "\n\n");
|
|
}
|
|
result.trim().to_string()
|
|
}
|
|
|
|
/// Strip `<tag>...</tag>` and `<tag ...>...</tag>` blocks from text.
|
|
fn strip_xml_tag(text: &str, tag: &str) -> String {
|
|
let open_exact = format!("<{}>", tag);
|
|
let open_prefix = format!("<{} ", tag); // for <tag attr="...">
|
|
let close = format!("</{}>", tag);
|
|
|
|
let mut result = String::with_capacity(text.len());
|
|
let mut remaining = text;
|
|
|
|
loop {
|
|
// Find the next opening tag (exact or with attributes)
|
|
let exact_pos = remaining.find(&open_exact);
|
|
let prefix_pos = remaining.find(&open_prefix);
|
|
let start = match (exact_pos, prefix_pos) {
|
|
(Some(a), Some(b)) => a.min(b),
|
|
(Some(a), None) => a,
|
|
(None, Some(b)) => b,
|
|
(None, None) => break,
|
|
};
|
|
|
|
// Add everything before the tag
|
|
result.push_str(&remaining[..start]);
|
|
|
|
// Find the end of the opening tag (the closing >)
|
|
let after_open = &remaining[start..];
|
|
let open_end = match after_open.find('>') {
|
|
Some(pos) => start + pos + 1,
|
|
None => break, // malformed, stop
|
|
};
|
|
|
|
// Find the closing tag
|
|
if let Some(close_offset) = remaining[open_end..].find(&close) {
|
|
let end = open_end + close_offset + close.len();
|
|
remaining = &remaining[end..];
|
|
} else {
|
|
// No closing tag, discard from here (malformed)
|
|
remaining = "";
|
|
break;
|
|
}
|
|
}
|
|
|
|
result.push_str(remaining);
|
|
result
|
|
}
|
|
|
|
/// Strip `<|tag|>...<|/tag|>` pipe-delimited blocks from text.
|
|
///
|
|
/// Some models (e.g. certain Chinese LLMs) use this format instead of
|
|
/// standard XML tags.
|
|
fn strip_pipe_tag(text: &str, tag: &str) -> String {
|
|
let open = format!("<|{}|>", tag);
|
|
let close = format!("<|/{}|>", tag);
|
|
|
|
let mut result = String::with_capacity(text.len());
|
|
let mut remaining = text;
|
|
|
|
while let Some(start) = remaining.find(&open) {
|
|
result.push_str(&remaining[..start]);
|
|
|
|
if let Some(close_offset) = remaining[start..].find(&close) {
|
|
let end = start + close_offset + close.len();
|
|
remaining = &remaining[end..];
|
|
} else {
|
|
remaining = "";
|
|
break;
|
|
}
|
|
}
|
|
|
|
result.push_str(remaining);
|
|
result
|
|
}
|
|
|
|
/// Strip any remaining reasoning that wasn't in proper <thinking> tags.
|
|
///
|
|
/// This is a simple fallback for models that don't follow the <thinking> tag
|
|
/// instruction. It looks for paragraph breaks where actual content follows.
|
|
fn strip_reasoning_patterns(text: &str) -> String {
|
|
let text = text.trim();
|
|
if text.is_empty() {
|
|
return text.to_string();
|
|
}
|
|
|
|
// If text already looks clean (starts with actual content), return as-is
|
|
// Actual content often starts with: markdown, code blocks, direct statements
|
|
let first_char = text.chars().next().unwrap_or(' ');
|
|
if first_char == '#' || first_char == '`' || first_char == '*' || first_char == '-' {
|
|
return text.to_string();
|
|
}
|
|
|
|
// Look for paragraph break followed by actual content
|
|
// Often models output: "thinking...\n\nActual response"
|
|
if let Some(idx) = text.find("\n\n") {
|
|
let after_break = text[idx + 2..].trim();
|
|
if !after_break.is_empty() {
|
|
let first_after = after_break.chars().next().unwrap_or(' ');
|
|
// If it starts with typical response markers, use content after break
|
|
if first_after == '#'
|
|
|| first_after == '`'
|
|
|| first_after == '*'
|
|
|| first_after == '-'
|
|
|| after_break.to_lowercase().starts_with("here")
|
|
|| after_break.to_lowercase().starts_with("i'd")
|
|
|| after_break.to_lowercase().starts_with("sure")
|
|
{
|
|
return after_break.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return original if no clear split found
|
|
text.to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_extract_json() {
|
|
let text = r#"Here's the plan:
|
|
{"goal": "test", "actions": []}
|
|
That's my plan."#;
|
|
|
|
let json = extract_json(text).unwrap();
|
|
assert!(json.starts_with('{'));
|
|
assert!(json.ends_with('}'));
|
|
}
|
|
|
|
#[test]
|
|
fn test_reasoning_context_builder() {
|
|
let context = ReasoningContext::new()
|
|
.with_message(ChatMessage::user("Hello"))
|
|
.with_job("Test job");
|
|
|
|
assert_eq!(context.messages.len(), 1);
|
|
assert!(context.job_description.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_thinking_tags_basic() {
|
|
let input = "<thinking>Let me think about this...</thinking>Hello, user!";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Hello, user!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_thinking_tags_multiple() {
|
|
let input =
|
|
"<thinking>First thought</thinking>Hello<thinking>Second thought</thinking> world!";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Hello world!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_thinking_tags_multiline() {
|
|
let input = r#"<thinking>
|
|
I need to consider:
|
|
1. What the user wants
|
|
2. How to respond
|
|
</thinking>
|
|
Here is my response to your question."#;
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Here is my response to your question.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_thinking_tags_no_tags() {
|
|
let input = "Just a normal response without thinking tags.";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Just a normal response without thinking tags.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_thinking_tags_unclosed() {
|
|
// Malformed: unclosed tag should strip from there to end
|
|
let input = "Hello <thinking>this never closes";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_tool_call_tags() {
|
|
// GLM-4.7 emits this garbage instead of using the tool_calls array
|
|
let input = "<tool_call>tool_list</tool_call>";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_tool_call_with_surrounding_text() {
|
|
let input = "Here is my answer.\n\n<tool_call>\n{\"name\": \"search\", \"arguments\": {}}\n</tool_call>";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Here is my answer.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_multiple_internal_tags() {
|
|
let input = "<thinking>Let me think</thinking>Hello!\n<tool_call>some_tool</tool_call>";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Hello!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_function_call_tags() {
|
|
let input = "Response text<function_call>{\"name\": \"foo\"}</function_call>";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Response text");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_tool_calls_plural() {
|
|
let input = "<tool_calls>[{\"id\": \"1\"}]</tool_calls>Actual response.";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Actual response.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_pipe_delimited_tags() {
|
|
let input = "<|tool_call|>{\"name\": \"search\"}<|/tool_call|>Hello!";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Hello!");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_pipe_delimited_thinking() {
|
|
let input = "<|thinking|>reasoning here<|/thinking|>The answer is 42.";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "The answer is 42.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_xml_tag_with_attributes() {
|
|
let input = "<tool_call type=\"function\">search()</tool_call>Done.";
|
|
let output = strip_internal_tags(input);
|
|
assert_eq!(output, "Done.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_response_preserves_normal_content() {
|
|
let input = "The function tool_call_handler works great. No tags here!";
|
|
let output = clean_response(input);
|
|
assert_eq!(
|
|
output,
|
|
"The function tool_call_handler works great. No tags here!"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_reasoning_paragraph_break() {
|
|
// Content after paragraph break with "here" marker
|
|
let input = "Some thinking here.\n\nHere's the answer:";
|
|
let output = strip_reasoning_patterns(input);
|
|
assert_eq!(output, "Here's the answer:");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_reasoning_markdown_after_break() {
|
|
// Content after paragraph break starting with markdown
|
|
let input = "Some reasoning.\n\n**The Solution**\n- Item 1";
|
|
let output = strip_reasoning_patterns(input);
|
|
assert_eq!(output, "**The Solution**\n- Item 1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_reasoning_preserves_markdown_start() {
|
|
// If response starts with markdown, keep as-is
|
|
let input = "**What type of tool?**\n- Option 1\n- Option 2";
|
|
let output = strip_reasoning_patterns(input);
|
|
assert_eq!(output, "**What type of tool?**\n- Option 1\n- Option 2");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_reasoning_preserves_code_start() {
|
|
// If response starts with code block, keep as-is
|
|
let input = "```rust\nfn main() {}\n```";
|
|
let output = strip_reasoning_patterns(input);
|
|
assert_eq!(output, "```rust\nfn main() {}\n```");
|
|
}
|
|
|
|
#[test]
|
|
fn test_strip_reasoning_no_paragraph_break() {
|
|
// Without clear paragraph break, return original
|
|
let input = "Some text without clear separation.";
|
|
let output = strip_reasoning_patterns(input);
|
|
assert_eq!(output, "Some text without clear separation.");
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_response_combined() {
|
|
// Combines thinking tags + paragraph break fallback
|
|
let input = "<thinking>Internal thought</thinking>Some text.\n\nHere's the answer.";
|
|
let output = clean_response(input);
|
|
assert_eq!(output, "Here's the answer.");
|
|
}
|
|
|
|
// -- recover_tool_calls_from_content tests --
|
|
|
|
fn make_tools(names: &[&str]) -> Vec<ToolDefinition> {
|
|
names
|
|
.iter()
|
|
.map(|n| ToolDefinition {
|
|
name: n.to_string(),
|
|
description: String::new(),
|
|
parameters: serde_json::json!({}),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_recover_bare_tool_name() {
|
|
let tools = make_tools(&["tool_list", "tool_auth"]);
|
|
let content = "<tool_call>tool_list</tool_call>";
|
|
let calls = recover_tool_calls_from_content(content, &tools);
|
|
assert_eq!(calls.len(), 1);
|
|
assert_eq!(calls[0].name, "tool_list");
|
|
assert_eq!(calls[0].arguments, serde_json::json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_recover_json_tool_call() {
|
|
let tools = make_tools(&["memory_search"]);
|
|
let content =
|
|
r#"<tool_call>{"name": "memory_search", "arguments": {"query": "test"}}</tool_call>"#;
|
|
let calls = recover_tool_calls_from_content(content, &tools);
|
|
assert_eq!(calls.len(), 1);
|
|
assert_eq!(calls[0].name, "memory_search");
|
|
assert_eq!(calls[0].arguments, serde_json::json!({"query": "test"}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_recover_pipe_delimited() {
|
|
let tools = make_tools(&["tool_list"]);
|
|
let content = "<|tool_call|>tool_list<|/tool_call|>";
|
|
let calls = recover_tool_calls_from_content(content, &tools);
|
|
assert_eq!(calls.len(), 1);
|
|
assert_eq!(calls[0].name, "tool_list");
|
|
}
|
|
|
|
#[test]
|
|
fn test_recover_unknown_tool_ignored() {
|
|
let tools = make_tools(&["tool_list"]);
|
|
let content = "<tool_call>nonexistent_tool</tool_call>";
|
|
let calls = recover_tool_calls_from_content(content, &tools);
|
|
assert!(calls.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_recover_no_tags() {
|
|
let tools = make_tools(&["tool_list"]);
|
|
let content = "Just a normal response.";
|
|
let calls = recover_tool_calls_from_content(content, &tools);
|
|
assert!(calls.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_recover_multiple_tool_calls() {
|
|
let tools = make_tools(&["tool_list", "tool_auth"]);
|
|
let content = "<tool_call>tool_list</tool_call>\n<tool_call>tool_auth</tool_call>";
|
|
let calls = recover_tool_calls_from_content(content, &tools);
|
|
assert_eq!(calls.len(), 2);
|
|
assert_eq!(calls[0].name, "tool_list");
|
|
assert_eq!(calls[1].name, "tool_auth");
|
|
}
|
|
|
|
#[test]
|
|
fn test_recover_function_call_variant() {
|
|
let tools = make_tools(&["shell"]);
|
|
let content =
|
|
r#"<function_call>{"name": "shell", "arguments": {"cmd": "ls"}}</function_call>"#;
|
|
let calls = recover_tool_calls_from_content(content, &tools);
|
|
assert_eq!(calls.len(), 1);
|
|
assert_eq!(calls[0].name, "shell");
|
|
}
|
|
|
|
#[test]
|
|
fn test_recover_with_surrounding_text() {
|
|
let tools = make_tools(&["tool_list"]);
|
|
let content = "Let me check.\n\n<tool_call>tool_list</tool_call>\n\nDone.";
|
|
let calls = recover_tool_calls_from_content(content, &tools);
|
|
assert_eq!(calls.len(), 1);
|
|
assert_eq!(calls[0].name, "tool_list");
|
|
}
|
|
}
|