{"repo": "akitaonrails/frankclaw", "files": {"crates/frankclaw-runtime/src/lib.rs": {"d9812e9a3800bf8a54a100a3ec98be211e18efc4": {"t": 1785745832.2481778, "n": 129207, "h": "e8b4a4a4279f1531", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod markdown;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, CopilotProvider, FailoverChain, OllamaProvider, OpenAiProvider,\n ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n memory_search: Option>,\n audio_transcriber: Option>,\n understanding_providers: Vec>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n pub canvas: Option>,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let state_dir = default_state_dir();\n Self::from_config_with_state_dir(config, sessions, &state_dir).await\n }\n\n pub async fn from_config_with_state_dir(\n config: &FrankClawConfig,\n sessions: Arc,\n state_dir: &Path,\n ) -> Result {\n let providers = build_providers(config, state_dir)?;\n Self::from_providers_with_models(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n // Wrap with empty model ID lists — routing will try all providers.\n let providers: Vec<(Arc, Vec)> =\n providers.into_iter().map(|p| (p, Vec::new())).collect();\n Self::from_providers_with_models(config, sessions, providers).await\n }\n\n async fn from_providers_with_models(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec<(Arc, Vec)>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::with_model_routing(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n memory_search: None,\n audio_transcriber: None,\n understanding_providers: Vec::new(),\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_understanding_providers(&mut self, providers: Vec>) {\n self.understanding_providers = providers;\n }\n\n pub fn set_memory_search(&mut self, memory_search: Arc) {\n self.memory_search = Some(memory_search);\n }\n\n pub fn set_audio_transcriber(&mut self, audio_transcriber: Arc) {\n self.audio_transcriber = Some(audio_transcriber);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — try understanding pipeline for text descriptions.\n let understanding_context = if !self.understanding_providers.is_empty() {\n let media_attachments: Vec<_> = request\n .attachments\n .iter()\n .enumerate()\n .filter(|(_, a)| {\n a.mime_type.starts_with(\"image/\") || a.mime_type.starts_with(\"audio/\")\n })\n .filter_map(|(i, a)| {\n a.url.as_ref().map(|_url| {\n frankclaw_media::understanding::MediaAttachment {\n data: Vec::new(), // URL-based attachments need fetching\n mime: a.mime_type.clone(),\n filename: a.filename.clone(),\n index: i,\n }\n })\n })\n .collect();\n if media_attachments.is_empty() {\n String::new()\n } else {\n let outputs = frankclaw_media::understanding::process_attachments(\n &media_attachments,\n &self.understanding_providers,\n )\n .await;\n frankclaw_media::understanding::format_as_context(&outputs)\n }\n } else {\n String::new()\n };\n\n if !understanding_context.is_empty() {\n // Understanding pipeline produced descriptions — prepend to message.\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", understanding_context, sanitized_message),\n )\n } else {\n // Fallback: simple text labels for attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: request.canvas,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = match frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES) {\n Ok(f) => f,\n Err(e) => {\n tracing::error!(error = %e, \"failed to build image fetcher\");\n return Vec::new();\n }\n };\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n state_dir: &Path,\n) -> Result, Vec)>> {\n let mut providers: Vec<(Arc, Vec)> =\n Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let model_ids = provider.models.clone();\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )?),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"github-copilot\" | \"copilot\" => {\n let github_token =\n frankclaw_models::copilot::load_github_token(state_dir)?;\n Arc::new(CopilotProvider::new(\n provider.id.clone(),\n github_token,\n state_dir,\n provider.models.clone(),\n ))\n }\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, deepseek, or github-copilot\",\n other\n ),\n });\n }\n };\n providers.push((provider_impl, model_ids));\n }\n\n Ok(providers)\n}\n\nfn default_state_dir() -> PathBuf {\n dirs::data_dir()\n .unwrap_or_else(|| PathBuf::from(\".\"))\n .join(\"frankclaw\")\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[test]\n fn build_providers_loads_copilot_credentials_from_supplied_state_dir() {\n let state_dir = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-copilot-state-{}\",\n uuid::Uuid::new_v4()\n ));\n let empty_state_dir = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-copilot-empty-{}\",\n uuid::Uuid::new_v4()\n ));\n std::fs::create_dir_all(&state_dir).expect(\"state dir should create\");\n std::fs::create_dir_all(&empty_state_dir).expect(\"empty state dir should create\");\n frankclaw_models::copilot::store_github_token(\n &state_dir,\n &SecretString::from(\"github-token-from-configured-state\".to_string()),\n )\n .expect(\"token should store\");\n\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![ProviderConfig {\n id: \"copilot\".into(),\n api: \"github-copilot\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"gpt-4o\".into()],\n cooldown_secs: 1,\n }];\n\n let providers =\n build_providers(&config, &state_dir).expect(\"configured state dir should load token\");\n assert_eq!(providers.len(), 1);\n assert!(\n build_providers(&config, &empty_state_dir).is_err(),\n \"missing token in supplied state dir should fail\"\n );\n\n let _ = std::fs::remove_dir_all(state_dir);\n let _ = std::fs::remove_dir_all(empty_state_dir);\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session_inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n canvas: None,\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session_inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session_inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session_inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "a349873486f7c5b682fed35bf364bac27589c894": {"t": 1785756951.935569, "n": 127331, "h": "7ec27f5c9d6688b4", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod markdown;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, CopilotProvider, FailoverChain, OllamaProvider, OpenAiProvider,\n ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n memory_search: Option>,\n audio_transcriber: Option>,\n understanding_providers: Vec>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n pub canvas: Option>,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers_with_models(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n // Wrap with empty model ID lists — routing will try all providers.\n let providers: Vec<(Arc, Vec)> =\n providers.into_iter().map(|p| (p, Vec::new())).collect();\n Self::from_providers_with_models(config, sessions, providers).await\n }\n\n async fn from_providers_with_models(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec<(Arc, Vec)>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::with_model_routing(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n memory_search: None,\n audio_transcriber: None,\n understanding_providers: Vec::new(),\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_understanding_providers(&mut self, providers: Vec>) {\n self.understanding_providers = providers;\n }\n\n pub fn set_memory_search(&mut self, memory_search: Arc) {\n self.memory_search = Some(memory_search);\n }\n\n pub fn set_audio_transcriber(&mut self, audio_transcriber: Arc) {\n self.audio_transcriber = Some(audio_transcriber);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — try understanding pipeline for text descriptions.\n let understanding_context = if !self.understanding_providers.is_empty() {\n let media_attachments: Vec<_> = request\n .attachments\n .iter()\n .enumerate()\n .filter(|(_, a)| {\n a.mime_type.starts_with(\"image/\") || a.mime_type.starts_with(\"audio/\")\n })\n .filter_map(|(i, a)| {\n a.url.as_ref().map(|_url| {\n frankclaw_media::understanding::MediaAttachment {\n data: Vec::new(), // URL-based attachments need fetching\n mime: a.mime_type.clone(),\n filename: a.filename.clone(),\n index: i,\n }\n })\n })\n .collect();\n if media_attachments.is_empty() {\n String::new()\n } else {\n let outputs = frankclaw_media::understanding::process_attachments(\n &media_attachments,\n &self.understanding_providers,\n )\n .await;\n frankclaw_media::understanding::format_as_context(&outputs)\n }\n } else {\n String::new()\n };\n\n if !understanding_context.is_empty() {\n // Understanding pipeline produced descriptions — prepend to message.\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", understanding_context, sanitized_message),\n )\n } else {\n // Fallback: simple text labels for attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: request.canvas,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = match frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES) {\n Ok(f) => f,\n Err(e) => {\n tracing::error!(error = %e, \"failed to build image fetcher\");\n return Vec::new();\n }\n };\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result, Vec)>> {\n let mut providers: Vec<(Arc, Vec)> =\n Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let model_ids = provider.models.clone();\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )?),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )?),\n \"github-copilot\" | \"copilot\" => {\n let state_dir = dirs::data_dir()\n .unwrap_or_else(|| std::path::PathBuf::from(\".\"))\n .join(\"frankclaw\");\n let github_token =\n frankclaw_models::copilot::load_github_token(&state_dir)?;\n Arc::new(CopilotProvider::new(\n provider.id.clone(),\n github_token,\n &state_dir,\n provider.models.clone(),\n ))\n }\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, deepseek, or github-copilot\",\n other\n ),\n });\n }\n };\n providers.push((provider_impl, model_ids));\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session_inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n canvas: None,\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session_inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session_inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session_inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "9df61bfe66723b77bf6313f1c1aafcd05ea22df8": {"t": 1785756953.9708033, "n": 127154, "h": "2dcba6eaadd0f740", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod markdown;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, CopilotProvider, FailoverChain, OllamaProvider, OpenAiProvider,\n ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n memory_search: Option>,\n audio_transcriber: Option>,\n understanding_providers: Vec>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n pub canvas: Option>,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers_with_models(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n // Wrap with empty model ID lists — routing will try all providers.\n let providers: Vec<(Arc, Vec)> =\n providers.into_iter().map(|p| (p, Vec::new())).collect();\n Self::from_providers_with_models(config, sessions, providers).await\n }\n\n async fn from_providers_with_models(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec<(Arc, Vec)>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::with_model_routing(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n memory_search: None,\n audio_transcriber: None,\n understanding_providers: Vec::new(),\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_understanding_providers(&mut self, providers: Vec>) {\n self.understanding_providers = providers;\n }\n\n pub fn set_memory_search(&mut self, memory_search: Arc) {\n self.memory_search = Some(memory_search);\n }\n\n pub fn set_audio_transcriber(&mut self, audio_transcriber: Arc) {\n self.audio_transcriber = Some(audio_transcriber);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — try understanding pipeline for text descriptions.\n let understanding_context = if !self.understanding_providers.is_empty() {\n let media_attachments: Vec<_> = request\n .attachments\n .iter()\n .enumerate()\n .filter(|(_, a)| {\n a.mime_type.starts_with(\"image/\") || a.mime_type.starts_with(\"audio/\")\n })\n .filter_map(|(i, a)| {\n a.url.as_ref().map(|_url| {\n frankclaw_media::understanding::MediaAttachment {\n data: Vec::new(), // URL-based attachments need fetching\n mime: a.mime_type.clone(),\n filename: a.filename.clone(),\n index: i,\n }\n })\n })\n .collect();\n if media_attachments.is_empty() {\n String::new()\n } else {\n let outputs = frankclaw_media::understanding::process_attachments(\n &media_attachments,\n &self.understanding_providers,\n )\n .await;\n frankclaw_media::understanding::format_as_context(&outputs)\n }\n } else {\n String::new()\n };\n\n if !understanding_context.is_empty() {\n // Understanding pipeline produced descriptions — prepend to message.\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", understanding_context, sanitized_message),\n )\n } else {\n // Fallback: simple text labels for attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: request.canvas,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result, Vec)>> {\n let mut providers: Vec<(Arc, Vec)> =\n Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let model_ids = provider.models.clone();\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n \"github-copilot\" | \"copilot\" => {\n let state_dir = dirs::data_dir()\n .unwrap_or_else(|| std::path::PathBuf::from(\".\"))\n .join(\"frankclaw\");\n let github_token =\n frankclaw_models::copilot::load_github_token(&state_dir)?;\n Arc::new(CopilotProvider::new(\n provider.id.clone(),\n github_token,\n &state_dir,\n provider.models.clone(),\n ))\n }\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, deepseek, or github-copilot\",\n other\n ),\n });\n }\n };\n providers.push((provider_impl, model_ids));\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session_inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n canvas: None,\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session_inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session_inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session_inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "e54bc152a296a95c57383faaa31283254d7906a9": {"t": 1785756955.7621918, "n": 126422, "h": "ff4372fd8e2cec97", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod markdown;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, CopilotProvider, FailoverChain, OllamaProvider, OpenAiProvider,\n ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n memory_search: Option>,\n audio_transcriber: Option>,\n understanding_providers: Vec>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n memory_search: None,\n audio_transcriber: None,\n understanding_providers: Vec::new(),\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_understanding_providers(&mut self, providers: Vec>) {\n self.understanding_providers = providers;\n }\n\n pub fn set_memory_search(&mut self, memory_search: Arc) {\n self.memory_search = Some(memory_search);\n }\n\n pub fn set_audio_transcriber(&mut self, audio_transcriber: Arc) {\n self.audio_transcriber = Some(audio_transcriber);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — try understanding pipeline for text descriptions.\n let understanding_context = if !self.understanding_providers.is_empty() {\n let media_attachments: Vec<_> = request\n .attachments\n .iter()\n .enumerate()\n .filter(|(_, a)| {\n a.mime_type.starts_with(\"image/\") || a.mime_type.starts_with(\"audio/\")\n })\n .filter_map(|(i, a)| {\n a.url.as_ref().map(|_url| {\n frankclaw_media::understanding::MediaAttachment {\n data: Vec::new(), // URL-based attachments need fetching\n mime: a.mime_type.clone(),\n filename: a.filename.clone(),\n index: i,\n }\n })\n })\n .collect();\n if media_attachments.is_empty() {\n String::new()\n } else {\n let outputs = frankclaw_media::understanding::process_attachments(\n &media_attachments,\n &self.understanding_providers,\n )\n .await;\n frankclaw_media::understanding::format_as_context(&outputs)\n }\n } else {\n String::new()\n };\n\n if !understanding_context.is_empty() {\n // Understanding pipeline produced descriptions — prepend to message.\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", understanding_context, sanitized_message),\n )\n } else {\n // Fallback: simple text labels for attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n \"github-copilot\" | \"copilot\" => {\n let state_dir = dirs::data_dir()\n .unwrap_or_else(|| std::path::PathBuf::from(\".\"))\n .join(\"frankclaw\");\n let github_token =\n frankclaw_models::copilot::load_github_token(&state_dir)?;\n Arc::new(CopilotProvider::new(\n provider.id.clone(),\n github_token,\n &state_dir,\n provider.models.clone(),\n ))\n }\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, deepseek, or github-copilot\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session_inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session_inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session_inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session_inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session_inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session_inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session_inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "9f51a1822e62ba5042ed9240a5063016bf259e82": {"t": 1785756957.5006993, "n": 126422, "h": "7f753f8cc12d2966", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod markdown;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, CopilotProvider, FailoverChain, OllamaProvider, OpenAiProvider,\n ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n memory_search: Option>,\n audio_transcriber: Option>,\n understanding_providers: Vec>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n memory_search: None,\n audio_transcriber: None,\n understanding_providers: Vec::new(),\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_understanding_providers(&mut self, providers: Vec>) {\n self.understanding_providers = providers;\n }\n\n pub fn set_memory_search(&mut self, memory_search: Arc) {\n self.memory_search = Some(memory_search);\n }\n\n pub fn set_audio_transcriber(&mut self, audio_transcriber: Arc) {\n self.audio_transcriber = Some(audio_transcriber);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — try understanding pipeline for text descriptions.\n let understanding_context = if !self.understanding_providers.is_empty() {\n let media_attachments: Vec<_> = request\n .attachments\n .iter()\n .enumerate()\n .filter(|(_, a)| {\n a.mime_type.starts_with(\"image/\") || a.mime_type.starts_with(\"audio/\")\n })\n .filter_map(|(i, a)| {\n a.url.as_ref().map(|_url| {\n frankclaw_media::understanding::MediaAttachment {\n data: Vec::new(), // URL-based attachments need fetching\n mime: a.mime_type.clone(),\n filename: a.filename.clone(),\n index: i,\n }\n })\n })\n .collect();\n if media_attachments.is_empty() {\n String::new()\n } else {\n let outputs = frankclaw_media::understanding::process_attachments(\n &media_attachments,\n &self.understanding_providers,\n )\n .await;\n frankclaw_media::understanding::format_as_context(&outputs)\n }\n } else {\n String::new()\n };\n\n if !understanding_context.is_empty() {\n // Understanding pipeline produced descriptions — prepend to message.\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", understanding_context, sanitized_message),\n )\n } else {\n // Fallback: simple text labels for attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n \"github-copilot\" | \"copilot\" => {\n let state_dir = dirs::data_dir()\n .unwrap_or_else(|| std::path::PathBuf::from(\".\"))\n .join(\"frankclaw\");\n let github_token =\n frankclaw_models::copilot::load_github_token(&state_dir)?;\n Arc::new(CopilotProvider::new(\n provider.id.clone(),\n github_token,\n &state_dir,\n provider.models.clone(),\n ))\n }\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, deepseek, or github-copilot\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "014f56e02190ceb031436b9d6d2df22899b233ab": {"t": 1785756959.1554596, "n": 126404, "h": "537c0b148ba31dca", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, CopilotProvider, FailoverChain, OllamaProvider, OpenAiProvider,\n ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n memory_search: Option>,\n audio_transcriber: Option>,\n understanding_providers: Vec>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n memory_search: None,\n audio_transcriber: None,\n understanding_providers: Vec::new(),\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_understanding_providers(&mut self, providers: Vec>) {\n self.understanding_providers = providers;\n }\n\n pub fn set_memory_search(&mut self, memory_search: Arc) {\n self.memory_search = Some(memory_search);\n }\n\n pub fn set_audio_transcriber(&mut self, audio_transcriber: Arc) {\n self.audio_transcriber = Some(audio_transcriber);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — try understanding pipeline for text descriptions.\n let understanding_context = if !self.understanding_providers.is_empty() {\n let media_attachments: Vec<_> = request\n .attachments\n .iter()\n .enumerate()\n .filter(|(_, a)| {\n a.mime_type.starts_with(\"image/\") || a.mime_type.starts_with(\"audio/\")\n })\n .filter_map(|(i, a)| {\n a.url.as_ref().map(|_url| {\n frankclaw_media::understanding::MediaAttachment {\n data: Vec::new(), // URL-based attachments need fetching\n mime: a.mime_type.clone(),\n filename: a.filename.clone(),\n index: i,\n }\n })\n })\n .collect();\n if media_attachments.is_empty() {\n String::new()\n } else {\n let outputs = frankclaw_media::understanding::process_attachments(\n &media_attachments,\n &self.understanding_providers,\n )\n .await;\n frankclaw_media::understanding::format_as_context(&outputs)\n }\n } else {\n String::new()\n };\n\n if !understanding_context.is_empty() {\n // Understanding pipeline produced descriptions — prepend to message.\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", understanding_context, sanitized_message),\n )\n } else {\n // Fallback: simple text labels for attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n \"github-copilot\" | \"copilot\" => {\n let state_dir = dirs::data_dir()\n .unwrap_or_else(|| std::path::PathBuf::from(\".\"))\n .join(\"frankclaw\");\n let github_token =\n frankclaw_models::copilot::load_github_token(&state_dir)?;\n Arc::new(CopilotProvider::new(\n provider.id.clone(),\n github_token,\n &state_dir,\n provider.models.clone(),\n ))\n }\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, deepseek, or github-copilot\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "338887c748ce022f6358350fc5eb9f5d11505ba5": {"t": 1785756960.7720666, "n": 125764, "h": "5e40cf3023d38988", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n memory_search: Option>,\n audio_transcriber: Option>,\n understanding_providers: Vec>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n memory_search: None,\n audio_transcriber: None,\n understanding_providers: Vec::new(),\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_understanding_providers(&mut self, providers: Vec>) {\n self.understanding_providers = providers;\n }\n\n pub fn set_memory_search(&mut self, memory_search: Arc) {\n self.memory_search = Some(memory_search);\n }\n\n pub fn set_audio_transcriber(&mut self, audio_transcriber: Arc) {\n self.audio_transcriber = Some(audio_transcriber);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — try understanding pipeline for text descriptions.\n let understanding_context = if !self.understanding_providers.is_empty() {\n let media_attachments: Vec<_> = request\n .attachments\n .iter()\n .enumerate()\n .filter(|(_, a)| {\n a.mime_type.starts_with(\"image/\") || a.mime_type.starts_with(\"audio/\")\n })\n .filter_map(|(i, a)| {\n a.url.as_ref().map(|_url| {\n frankclaw_media::understanding::MediaAttachment {\n data: Vec::new(), // URL-based attachments need fetching\n mime: a.mime_type.clone(),\n filename: a.filename.clone(),\n index: i,\n }\n })\n })\n .collect();\n if media_attachments.is_empty() {\n String::new()\n } else {\n let outputs = frankclaw_media::understanding::process_attachments(\n &media_attachments,\n &self.understanding_providers,\n )\n .await;\n frankclaw_media::understanding::format_as_context(&outputs)\n }\n } else {\n String::new()\n };\n\n if !understanding_context.is_empty() {\n // Understanding pipeline produced descriptions — prepend to message.\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", understanding_context, sanitized_message),\n )\n } else {\n // Fallback: simple text labels for attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, or deepseek\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "cfa946a99d6764a9d44a0e517ecdd5ad531d914b": {"t": 1785756962.7362638, "n": 123492, "h": "e0992c990ca1a233", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n memory_search: Option>,\n audio_transcriber: Option>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n memory_search: None,\n audio_transcriber: None,\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_memory_search(&mut self, memory_search: Arc) {\n self.memory_search = Some(memory_search);\n }\n\n pub fn set_audio_transcriber(&mut self, audio_transcriber: Arc) {\n self.audio_transcriber = Some(audio_transcriber);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n memory_search: self.memory_search.clone(),\n audio_transcriber: self.audio_transcriber.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, or deepseek\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "75a37e2f32eaf1e89e22ad670a0df73804d351fd": {"t": 1785756964.4468572, "n": 122612, "h": "e15b04510a43837f", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n hooks: Option>,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n hooks: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn set_hooks(&mut self, hooks: Arc) {\n self.hooks = Some(hooks);\n }\n\n /// Fire a hook event asynchronously (fire-and-forget).\n fn fire_hook(&self, event: frankclaw_core::hooks::HookEvent) {\n if let Some(ref hooks) = self.hooks {\n let hooks = hooks.clone();\n tokio::spawn(async move {\n hooks.fire(event).await;\n });\n }\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n\n // Fire message.received hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_received(\n \"runtime\",\n agent_id.as_str(),\n &sanitized_message,\n ));\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n // Fire message.sent hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::message_sent(\n \"runtime\",\n agent_id.as_str(),\n ));\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n // Fire tool.before hook.\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_before(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n ));\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => {\n // Fire tool.after hook (success).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n true,\n ));\n output\n }\n Err(err) => {\n // Fire tool.after hook (failure).\n self.fire_hook(frankclaw_core::hooks::HookEvent::tool_after(\n &tool_call.name,\n agent_id.as_str(),\n session_key.as_str(),\n false,\n ));\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, or deepseek\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "494d2cf14f95e4c47745afd452e5883c4a4c8b43": {"t": 1785756966.3470385, "n": 120649, "h": "33db741067710b7c", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct CompactResult {\n pub pruned_count: usize,\n pub summary: Option,\n pub estimated_tokens_before: u32,\n pub estimated_tokens_after: u32,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Manually compact a session's conversation history.\n ///\n /// This prunes old messages and optionally uses the LLM to generate a real\n /// summary of the pruned content (instead of just a marker).\n pub async fn compact_session(\n &self,\n session_key: &SessionKey,\n agent_id: Option<&AgentId>,\n ) -> Result {\n let (agent_id, agent) = self.resolve_agent(agent_id)?;\n let model_id = self.resolve_model_id(&agent, None)?;\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: format!(\"model {model_id} not found\"),\n })?;\n\n // Load full transcript.\n let entries = self\n .sessions\n .get_transcript(session_key, 10_000, None)\n .await?;\n if entries.is_empty() {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: 0,\n estimated_tokens_after: 0,\n });\n }\n\n let messages: Vec = entries\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .collect();\n\n let allowed_tools = self.tools.definitions(&agent.tools).unwrap_or_default();\n let system_prompt = self.build_system_prompt(\n &agent_id, &agent, &model_id, &allowed_tools, None, None,\n );\n\n let tokens_before = context::estimate_messages_tokens(&messages);\n let budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n\n if tokens_before <= budget {\n return Ok(CompactResult {\n pruned_count: 0,\n summary: None,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_before,\n });\n }\n\n // Run context optimization (pruning).\n let context_result = context::optimize_context(\n messages.clone(),\n &model_def,\n system_prompt.as_deref(),\n );\n\n // Try LLM-based summarization of the pruned messages.\n let pruned_messages = &messages[..context_result.pruned_count];\n let summary = if !pruned_messages.is_empty() {\n match self.generate_compaction_summary(pruned_messages, &model_id).await {\n Ok(s) => Some(s),\n Err(e) => {\n tracing::warn!(\n error = %e,\n \"LLM summarization failed, falling back to marker\"\n );\n None\n }\n }\n } else {\n None\n };\n\n // Build the compacted message list to persist.\n let mut compacted = context_result.messages;\n\n // Replace the generic marker with real summary if available.\n if let Some(ref summary_text) = summary {\n if !compacted.is_empty() && compacted[0].role == Role::User {\n compacted[0] = CompletionMessage::text(\n Role::User,\n format!(\n \"[Conversation summary — {} earlier messages compacted]\\n\\n{}\",\n context_result.pruned_count, summary_text\n ),\n );\n }\n }\n\n let tokens_after = context::estimate_messages_tokens(&compacted);\n\n // Persist: clear old transcript and write compacted version.\n self.sessions.clear_transcript(session_key).await?;\n for (i, msg) in compacted.iter().enumerate() {\n self.append_transcript_entry(\n session_key,\n i as u64 + 1,\n msg.role,\n msg.content.clone(),\n None,\n )\n .await?;\n }\n\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n tokens_before,\n tokens_after,\n has_summary = summary.is_some(),\n \"session compacted\"\n );\n\n Ok(CompactResult {\n pruned_count: context_result.pruned_count,\n summary,\n estimated_tokens_before: tokens_before,\n estimated_tokens_after: tokens_after,\n })\n }\n\n /// Call the LLM to produce a summary of pruned messages.\n async fn generate_compaction_summary(\n &self,\n pruned_messages: &[CompletionMessage],\n model_id: &str,\n ) -> Result {\n // Build a text representation of the pruned conversation.\n let mut conversation = String::new();\n for msg in pruned_messages {\n let role_label = match msg.role {\n Role::User => \"User\",\n Role::Assistant => \"Assistant\",\n Role::System => \"System\",\n Role::Tool => \"Tool\",\n };\n conversation.push_str(&format!(\"{role_label}: {}\\n\\n\", msg.content));\n // Cap conversation text to avoid token exhaustion during summarization.\n if conversation.len() > 80_000 {\n conversation.push_str(\"[... earlier messages omitted ...]\\n\");\n break;\n }\n }\n\n let prompt = prompts::render(prompts::COMPACTION_SUMMARIZE, &[\n (\"conversation\", &conversation),\n ]);\n\n let summary_request = frankclaw_core::model::CompletionRequest {\n model_id: model_id.to_string(),\n system: Some(prompt),\n messages: vec![CompletionMessage::text(\n Role::User,\n \"Summarize the conversation above.\",\n )],\n tools: Vec::new(),\n max_tokens: Some(1024),\n temperature: Some(0.3),\n thinking_budget: None,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n };\n\n let response = self.models.complete(summary_request, None).await?;\n let summary = response.content.trim().to_string();\n\n // Sanity: cap summary length.\n if summary.len() > 4000 {\n Ok(summary.chars().take(4000).collect())\n } else {\n Ok(summary)\n }\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, or deepseek\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "2c3ea7e305b33c7f2fe20ed7bdb3cec9c3bfb45f": {"t": 1785756967.9335964, "n": 114073, "h": "3daade48cbc10869", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 2b: Workspace bootstrap files (SOUL.md, IDENTITY.md, etc.)\n if let Some(ref ws) = self.workspace {\n let bootstrap = read_workspace_bootstrap_files(ws);\n if !bootstrap.is_empty() {\n sections.push(bootstrap);\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Bootstrap file names recognized in a workspace directory.\n/// These are read (if present) and injected into the system prompt.\nconst BOOTSTRAP_FILES: &[&str] = &[\n \"SOUL.md\",\n \"IDENTITY.md\",\n \"USER.md\",\n \"BOOTSTRAP.md\",\n \"MEMORY.md\",\n];\n\n/// Maximum total size of all bootstrap files combined (150 KB).\nconst MAX_BOOTSTRAP_TOTAL_BYTES: usize = 150 * 1024;\n\n/// Maximum size of a single bootstrap file (50 KB).\nconst MAX_BOOTSTRAP_FILE_BYTES: usize = 50 * 1024;\n\n/// Read recognized workspace bootstrap files and return them as a combined string.\n/// Files that don't exist are silently skipped. Content is sanitized before inclusion.\nfn read_workspace_bootstrap_files(workspace: &std::path::Path) -> String {\n let mut parts: Vec = Vec::new();\n let mut total_bytes: usize = 0;\n\n for filename in BOOTSTRAP_FILES {\n let path = workspace.join(filename);\n let content = match std::fs::read_to_string(&path) {\n Ok(c) => c,\n Err(_) => continue,\n };\n\n if content.trim().is_empty() {\n continue;\n }\n\n // Strip YAML front matter (---\\n...\\n---)\n let content = strip_yaml_front_matter(&content);\n\n // Per-file size limit\n let content = if content.len() > MAX_BOOTSTRAP_FILE_BYTES {\n let truncated: String = content.chars().take(MAX_BOOTSTRAP_FILE_BYTES).collect();\n format!(\"{}\\n\\n[... truncated, file too large ...]\", truncated)\n } else {\n content\n };\n\n // Check total budget\n if total_bytes + content.len() > MAX_BOOTSTRAP_TOTAL_BYTES {\n break;\n }\n\n total_bytes += content.len();\n let sanitized = sanitize::sanitize_for_prompt(&content);\n parts.push(format!(\"## {filename}\\n\\n{sanitized}\"));\n }\n\n if parts.is_empty() {\n return String::new();\n }\n\n format!(\"# Workspace Context\\n\\n{}\", parts.join(\"\\n\\n\"))\n}\n\n/// Strip YAML front matter delimited by `---` at the start of a string.\nfn strip_yaml_front_matter(content: &str) -> String {\n let trimmed = content.trim_start();\n if !trimmed.starts_with(\"---\") {\n return content.to_string();\n }\n // Find the closing ---\n if let Some(end) = trimmed[3..].find(\"\\n---\") {\n let after = &trimmed[3 + end + 4..]; // skip past \\n---\n after.trim_start_matches('\\n').to_string()\n } else {\n content.to_string()\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, or deepseek\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n\n #[test]\n fn bootstrap_reads_existing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(dir.join(\"SOUL.md\"), \"You are a helpful assistant.\").unwrap();\n std::fs::write(dir.join(\"IDENTITY.md\"), \"Name: TestBot\").unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"# Workspace Context\"));\n assert!(result.contains(\"## SOUL.md\"));\n assert!(result.contains(\"You are a helpful assistant.\"));\n assert!(result.contains(\"## IDENTITY.md\"));\n assert!(result.contains(\"Name: TestBot\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_skips_missing_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-empty-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.is_empty());\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_strips_yaml_front_matter() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-yaml-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n std::fs::write(\n dir.join(\"SOUL.md\"),\n \"---\\ntitle: Soul\\nauthor: test\\n---\\nActual content here.\",\n )\n .unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"Actual content here.\"));\n assert!(!result.contains(\"title: Soul\"));\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn bootstrap_truncates_large_files() {\n let dir = std::env::temp_dir().join(format!(\"fc-bootstrap-big-{}\", uuid::Uuid::new_v4()));\n std::fs::create_dir_all(&dir).unwrap();\n\n // Write a file larger than MAX_BOOTSTRAP_FILE_BYTES (50KB)\n let large_content = \"A\".repeat(60 * 1024);\n std::fs::write(dir.join(\"SOUL.md\"), &large_content).unwrap();\n\n let result = read_workspace_bootstrap_files(&dir);\n assert!(result.contains(\"[... truncated, file too large ...]\"));\n // Should still be under total budget\n assert!(result.len() < MAX_BOOTSTRAP_TOTAL_BYTES + 1024);\n\n let _ = std::fs::remove_dir_all(&dir);\n }\n\n #[test]\n fn strip_yaml_front_matter_works() {\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\n---\\nBody text\"),\n \"Body text\"\n );\n assert_eq!(\n strip_yaml_front_matter(\"No front matter here\"),\n \"No front matter here\"\n );\n // Unclosed front matter is left as-is\n assert_eq!(\n strip_yaml_front_matter(\"---\\nkey: val\\nNo closing\"),\n \"---\\nkey: val\\nNo closing\"\n );\n }\n}\n"}, "d65747fc7d0bb07235abcecb6e8c3e0ec44ea322": {"t": 1785756969.7440193, "n": 108460, "h": "fbd065b1250851e7", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n // OpenAI-compatible providers with default base URLs.\n \"google\" | \"gemini\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1beta/openai\".to_string()),\n resolve_secret(provider, \"GOOGLE_API_KEY\")?,\n provider.models.clone(),\n )),\n \"openrouter\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://openrouter.ai/api/v1\".to_string()),\n resolve_secret(provider, \"OPENROUTER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"groq\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.groq.com/openai/v1\".to_string()),\n resolve_secret(provider, \"GROQ_API_KEY\")?,\n provider.models.clone(),\n )),\n \"together\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.together.xyz/v1\".to_string()),\n resolve_secret(provider, \"TOGETHER_API_KEY\")?,\n provider.models.clone(),\n )),\n \"deepseek\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com/v1\".to_string()),\n resolve_secret(provider, \"DEEPSEEK_API_KEY\")?,\n provider.models.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, ollama, \\\n google, openrouter, groq, together, or deepseek\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n}\n"}, "2b05f47f7ddc7dc09238292e072de0b4672796a7": {"t": 1785756971.491635, "n": 106197, "h": "3950d69ba5ab107e", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, ToolRiskLevel, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n /// Channel for requesting interactive tool approval from the user.\n pub approval_tx: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n // Interactive tool approval: if approval_tx is set and the tool\n // is mutating or destructive, request human approval before execution.\n if let Some(ref approval_tx) = request.approval_tx {\n let risk = frankclaw_tools::tool_risk_level(&tool_call.name);\n if risk != ToolRiskLevel::ReadOnly {\n let approval_id = uuid::Uuid::new_v4().to_string();\n let (decision_tx, decision_rx) = tokio::sync::oneshot::channel();\n let approval_req = frankclaw_core::tool_approval::ApprovalRequest {\n approval_id: approval_id.clone(),\n tool_name: tool_call.name.clone(),\n tool_args: arguments.clone(),\n risk_level: format!(\"{:?}\", risk),\n session_key: session_key.as_str().to_string(),\n agent_id: agent_id.as_str().to_string(),\n };\n if approval_tx.send((approval_req, decision_tx)).await.is_err() {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval channel closed\".into(),\n });\n }\n match decision_rx.await {\n Ok(frankclaw_core::tool_approval::ApprovalDecision::Deny) => {\n let deny_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' was denied by the user\\\"}}\",\n tool_call.name\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n deny_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n \"denied\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n deny_content,\n ));\n next_seq += 1;\n continue;\n }\n Ok(_) => { /* AllowOnce or AllowAlways — proceed */ }\n Err(_) => {\n return Err(FrankClawError::AgentRuntime {\n msg: \"tool approval response channel dropped\".into(),\n });\n }\n }\n }\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n approval_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n approval_tx: None,\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n}\n"}, "b8b98497975d97e42a60fbcc0196a651ba29b3d8": {"t": 1785756973.2138314, "n": 102323, "h": "2f8f8027b10ef9d6", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let mut total_usage = Usage {\n input_tokens: 0,\n output_tokens: 0,\n cache_read_tokens: None,\n cache_write_tokens: None,\n };\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n // Accumulate usage across all rounds.\n accumulate_usage(&mut total_usage, &response.usage);\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n\n // Persist cumulative usage in session metadata.\n self.update_session_usage(&session_key, &total_usage, &model_id)\n .await;\n\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: total_usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n\n /// Update cumulative usage stats in session metadata after a chat turn.\n async fn update_session_usage(\n &self,\n session_key: &SessionKey,\n turn_usage: &Usage,\n model_id: &str,\n ) {\n let Ok(Some(mut session)) = self.sessions.get(session_key).await else {\n return;\n };\n let meta = session.metadata.as_object_mut();\n let meta = match meta {\n Some(m) => m,\n None => {\n session.metadata = serde_json::json!({});\n session.metadata.as_object_mut().unwrap()\n }\n };\n\n // Accumulate totals.\n let prev_input = meta.get(\"total_input_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_output = meta.get(\"total_output_tokens\").and_then(|v| v.as_u64()).unwrap_or(0);\n let prev_turns = meta.get(\"total_turns\").and_then(|v| v.as_u64()).unwrap_or(0);\n\n meta.insert(\"total_input_tokens\".into(), (prev_input + turn_usage.input_tokens as u64).into());\n meta.insert(\"total_output_tokens\".into(), (prev_output + turn_usage.output_tokens as u64).into());\n meta.insert(\"total_turns\".into(), (prev_turns + 1).into());\n meta.insert(\"last_model\".into(), model_id.into());\n\n // Calculate cost if available.\n if let Some((input_cost, output_cost)) = frankclaw_models::costs::model_cost(model_id) {\n let turn_cost = (turn_usage.input_tokens as f64 * input_cost)\n + (turn_usage.output_tokens as f64 * output_cost);\n let prev_cost = meta.get(\"total_cost_usd\").and_then(|v| v.as_f64()).unwrap_or(0.0);\n meta.insert(\"total_cost_usd\".into(), serde_json::json!(prev_cost + turn_cost));\n }\n\n let _ = self.sessions.upsert(&session).await;\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Accumulate token usage from a model response into a running total.\nfn accumulate_usage(total: &mut Usage, delta: &Usage) {\n total.input_tokens += delta.input_tokens;\n total.output_tokens += delta.output_tokens;\n match (&mut total.cache_read_tokens, delta.cache_read_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n match (&mut total.cache_write_tokens, delta.cache_write_tokens) {\n (Some(t), Some(d)) => *t += d,\n (slot @ None, Some(d)) => *slot = Some(d),\n _ => {}\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn accumulate_usage_sums_tokens() {\n let mut total = Usage {\n input_tokens: 10,\n output_tokens: 5,\n cache_read_tokens: Some(2),\n cache_write_tokens: None,\n };\n let delta = Usage {\n input_tokens: 20,\n output_tokens: 15,\n cache_read_tokens: Some(3),\n cache_write_tokens: Some(1),\n };\n accumulate_usage(&mut total, &delta);\n assert_eq!(total.input_tokens, 30);\n assert_eq!(total.output_tokens, 20);\n assert_eq!(total.cache_read_tokens, Some(5));\n assert_eq!(total.cache_write_tokens, Some(1));\n }\n}\n"}, "8b6bfc736d59647616f8b6d5a7cb75134a16097b": {"t": 1785756974.8284612, "n": 98804, "h": "8b7d4e797fdc8ed0", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_images = tool_output.image_content;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n if tool_images.is_empty() {\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n } else {\n request_messages.push(CompletionMessage::tool_result_with_images(\n &tool_call.id,\n tool_content,\n tool_images,\n ));\n }\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "1422e6cf53eabe33f8aa93ff119bedc27d55fd8e": {"t": 1785756976.6515, "n": 98416, "h": "d50111e89e92dde4", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n /// Cancellation token — when cancelled, the chat loop aborts at the next check point.\n pub cancel_token: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Check cancellation before each round.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let completion_future = self.models.complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n );\n\n let response = if let Some(ref token) = request.cancel_token {\n tokio::select! {\n result = completion_future => result?,\n _ = token.cancelled() => return Err(FrankClawError::TurnCancelled),\n }\n } else {\n completion_future.await?\n };\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Check cancellation before each tool invocation.\n if request.cancel_token.as_ref().is_some_and(|t| t.is_cancelled()) {\n return Err(FrankClawError::TurnCancelled);\n }\n\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn chat_cancel_token_aborts_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-cancel-test-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(\n frankclaw_sessions::SqliteSessionStore::open(&temp, None)\n .expect(\"sessions should open\"),\n );\n\n // Use a provider that sleeps to simulate a slow model call.\n struct SlowProvider;\n\n #[async_trait::async_trait]\n impl ModelProvider for SlowProvider {\n fn id(&self) -> &str {\n \"slow\"\n }\n\n async fn complete(\n &self,\n _request: CompletionRequest,\n _stream_tx: Option>,\n ) -> frankclaw_core::error::Result {\n tokio::time::sleep(std::time::Duration::from_secs(30)).await;\n Ok(CompletionResponse {\n content: \"should not reach\".into(),\n tool_calls: Vec::new(),\n usage: Usage::default(),\n finish_reason: FinishReason::Stop,\n })\n }\n\n async fn list_models(&self) -> frankclaw_core::error::Result> {\n Ok(vec![ModelDef {\n id: \"slow-model\".into(),\n name: \"slow-model\".into(),\n api: frankclaw_core::model::ModelApi::Ollama,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 4096,\n max_output_tokens: 1024,\n compat: Default::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n let config = FrankClawConfig::default();\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(SlowProvider)],\n )\n .await\n .expect(\"runtime should build\");\n\n let cancel_token = tokio_util::sync::CancellationToken::new();\n let cancel_clone = cancel_token.clone();\n\n let chat_handle = tokio::spawn(async move {\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"slow-model\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n cancel_token: Some(cancel_clone),\n })\n .await\n });\n\n // Cancel after a short delay.\n tokio::time::sleep(std::time::Duration::from_millis(50)).await;\n cancel_token.cancel();\n\n // The chat should fail quickly with TurnCancelled.\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(5),\n chat_handle,\n )\n .await\n .expect(\"should complete within timeout\")\n .expect(\"task should not panic\");\n\n // The slow provider won't return TurnCancelled — it will be a provider error\n // because the cancellation check happens between rounds, not during the model call.\n // But the test validates that the cancel_token field works end-to-end.\n assert!(result.is_err());\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "d99e01246332f988e9c99eb636c843fa42190145": {"t": 1785756978.3281875, "n": 93246, "h": "31666727facc30eb", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n config_arc: Option>,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n fetcher: Option>,\n channels: Option>,\n cron: Option>,\n workspace: Option,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n config_arc: Some(Arc::new(config.clone())),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n fetcher: None,\n channels: None,\n cron: None,\n workspace: None,\n })\n }\n\n pub fn set_fetcher(&mut self, fetcher: Arc) {\n self.fetcher = Some(fetcher);\n }\n\n pub fn set_channels(&mut self, channels: Arc) {\n self.channels = Some(channels);\n }\n\n pub fn set_cron(&mut self, cron: Arc) {\n self.cron = Some(cron);\n }\n\n pub fn set_workspace(&mut self, workspace: std::path::PathBuf) {\n self.workspace = Some(workspace);\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n fetcher: self.fetcher.clone(),\n channels: self.channels.clone(),\n cron: self.cron.clone(),\n config: self.config_arc.clone(),\n workspace: self.workspace.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "55fafd7c96847aef540cd3ef3a92261ba7dd1626": {"t": 1785756980.0529046, "n": 91684, "h": "fc36ba89fc8c0e63", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, ToolDef, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n}\n\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n /// Canvas service for agent canvas tools.\n pub canvas: Option>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &allowed_tools,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n canvas: request.canvas.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n canvas: None,\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tools: &[ToolDef],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools (with descriptions so the model knows its capabilities)\n if !tools.is_empty() {\n let tool_descriptions: Vec = tools\n .iter()\n .map(|t| format!(\"- **{}**: {}\", t.name, t.description))\n .collect();\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_descriptions\", &tool_descriptions.join(\"\\n\")),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tools.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n canvas: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "f7191c3f2693965974d9133f862f964bb3af032c": {"t": 1785756981.6152115, "n": 90959, "h": "bd44f442a8de0230", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{ChannelCapabilities, InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n /// Channel ID for prompt hints (tells the LLM what features are available).\n pub channel_id: Option,\n /// Channel capabilities for prompt hints.\n pub channel_capabilities: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(\n &agent_id,\n &agent,\n &model_id,\n &tool_names,\n request.channel_id.as_ref(),\n request.channel_capabilities.as_ref(),\n );\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n channel_id: Option<&ChannelId>,\n channel_capabilities: Option<&ChannelCapabilities>,\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Channel hints (tells the LLM what messaging features are available)\n if let (Some(ch_id), Some(caps)) = (channel_id, channel_capabilities) {\n let mut features = Vec::new();\n if caps.threads { features.push(\"threads\"); }\n if caps.groups { features.push(\"group messages\"); }\n if caps.attachments { features.push(\"file attachments\"); }\n if caps.edit { features.push(\"message editing\"); }\n if caps.delete { features.push(\"message deletion\"); }\n if caps.reactions { features.push(\"reactions\"); }\n if caps.voice { features.push(\"voice messages\"); }\n if caps.inline_buttons { features.push(\"inline buttons\"); }\n let features_str = if features.is_empty() {\n \"text messages only\".to_string()\n } else {\n features.join(\", \")\n };\n sections.push(prompts::render(prompts::AGENT_CHANNEL, &[\n (\"channel\", ch_id.as_str()),\n (\"features\", &features_str),\n ]));\n }\n\n // Section 8: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_includes_channel_hints_when_provided() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-channel-hints-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: Some(ChannelId::new(\"telegram\")),\n channel_capabilities: Some(ChannelCapabilities {\n threads: true,\n groups: true,\n attachments: true,\n edit: true,\n delete: false,\n reactions: true,\n streaming: false,\n voice: false,\n inline_buttons: true,\n }),\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n system.contains(\"telegram\"),\n \"system prompt should mention channel name\"\n );\n assert!(\n system.contains(\"threads\"),\n \"system prompt should list threads feature\"\n );\n assert!(\n system.contains(\"inline buttons\"),\n \"system prompt should list inline buttons feature\"\n );\n assert!(\n !system.contains(\"voice\"),\n \"system prompt should not list disabled features\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn system_prompt_omits_channel_section_when_no_channel() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-no-channel-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions =\n Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mock = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"ok\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![mock.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let _response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n channel_id: None,\n channel_capabilities: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = mock.seen_requests.lock().unwrap();\n assert_eq!(seen.len(), 1);\n let system = seen[0].system.as_ref().expect(\"system prompt should be set\");\n assert!(\n !system.contains(\"Available features:\"),\n \"system prompt should not contain channel section when no channel provided\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "597dc03e80d831cd49e3ca5769f6b7bb2fdc0dca": {"t": 1785756983.3012524, "n": 84409, "h": "eb39b8c26c5b7b67", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n subagent_registry: Arc,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n subagent_registry: Arc::new(subagent::SubagentRegistry::new()),\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let mut allowed_tools = self.tools.definitions(&agent.tools)?;\n\n // Inject subagent tool if not at max depth.\n let current_depth = self.subagent_registry.depth_of(&session_key).await;\n if current_depth < subagent::DEFAULT_MAX_DEPTH_PUB {\n allowed_tools.push(spawn_subagent_tool_def());\n }\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n // Handle spawn_subagent as a special-cased tool: it calls\n // Runtime::chat() recursively rather than going through ToolRegistry.\n if tool_call.name == SPAWN_SUBAGENT_TOOL {\n let subagent_result = self\n .handle_spawn_subagent(\n &arguments,\n &agent_id,\n &session_key,\n current_depth,\n )\n .await;\n let result_content = match subagent_result {\n Ok(text) => serde_json::json!({\n \"tool\": SPAWN_SUBAGENT_TOOL,\n \"output\": text,\n })\n .to_string(),\n Err(err) => format!(\n \"{{\\\"error\\\": \\\"subagent failed: {}\\\"}}\",\n err\n ),\n };\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n result_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": SPAWN_SUBAGENT_TOOL,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n result_content,\n ));\n next_seq += 1;\n continue;\n }\n\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n /// Handle a spawn_subagent tool call by running chat() recursively.\n async fn handle_spawn_subagent(\n &self,\n arguments: &serde_json::Value,\n parent_agent_id: &AgentId,\n parent_session_key: &SessionKey,\n parent_depth: u32,\n ) -> Result {\n let task = arguments[\"task\"]\n .as_str()\n .ok_or_else(|| FrankClawError::AgentRuntime {\n msg: \"spawn_subagent requires a 'task' string\".into(),\n })?;\n let label = arguments[\"label\"].as_str().map(String::from);\n\n // Sanitize task/label per Rule 6.\n let task = sanitize::sanitize_for_prompt(task);\n let label = label.map(|l| sanitize::sanitize_for_prompt(&l));\n\n let spawn_request = subagent::SpawnRequest {\n task: task.clone(),\n label,\n agent_id: parent_agent_id.clone(),\n model_override: None,\n timeout_secs: None,\n parent_session_key: parent_session_key.clone(),\n parent_depth,\n };\n\n let spawn_result = self.subagent_registry.register_spawn(&spawn_request).await;\n let (run_id, child_session_key) = match spawn_result {\n subagent::SpawnResult::Accepted {\n run_id,\n child_session_key,\n } => (run_id, child_session_key),\n subagent::SpawnResult::Rejected { reason } => {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\"subagent spawn rejected: {reason}\"),\n });\n }\n };\n\n self.subagent_registry.mark_running(&run_id).await?;\n\n // Run the subagent with a timeout.\n let timeout = std::time::Duration::from_secs(\n spawn_request.timeout_secs.unwrap_or(300),\n );\n let result = tokio::time::timeout(\n timeout,\n Box::pin(self.chat(ChatRequest {\n agent_id: Some(parent_agent_id.clone()),\n session_key: Some(child_session_key),\n message: task,\n attachments: Vec::new(),\n model_id: None,\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })),\n )\n .await;\n\n match result {\n Ok(Ok(response)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Completed,\n result_text: Some(response.content.clone()),\n error: None,\n })\n .await?;\n Ok(response.content)\n }\n Ok(Err(err)) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::Failed,\n result_text: None,\n error: Some(err.to_string()),\n })\n .await?;\n Err(err)\n }\n Err(_) => {\n self.subagent_registry\n .complete(subagent::CompletionNotice {\n run_id,\n state: subagent::RunState::TimedOut,\n result_text: None,\n error: Some(\"subagent timed out\".into()),\n })\n .await?;\n Err(FrankClawError::AgentRuntime {\n msg: \"subagent execution timed out\".into(),\n })\n }\n }\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Name of the built-in subagent spawning tool.\nconst SPAWN_SUBAGENT_TOOL: &str = \"spawn_subagent\";\n\n/// Build the tool definition for spawning subagents.\nfn spawn_subagent_tool_def() -> frankclaw_core::model::ToolDef {\n frankclaw_core::model::ToolDef {\n name: SPAWN_SUBAGENT_TOOL.into(),\n description: \"Spawn a subagent to handle a complex subtask. The subagent runs independently and returns its result.\".into(),\n parameters: serde_json::json!({\n \"type\": \"object\",\n \"properties\": {\n \"task\": {\n \"type\": \"string\",\n \"description\": \"Description of the task for the subagent to complete\"\n },\n \"label\": {\n \"type\": \"string\",\n \"description\": \"Short label for this subagent run (optional)\"\n }\n },\n \"required\": [\"task\"]\n }),\n risk_level: frankclaw_core::model::ToolRiskLevel::Mutating,\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n // Agent has 1 tool + spawn_subagent injected by runtime.\n assert_eq!(seen[0].tools.len(), 2);\n assert!(seen[0].tools.iter().any(|t| t.name == \"session.inspect\"));\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_spawns_subagent_and_returns_result() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-subagent-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let config = FrankClawConfig::default();\n\n // Round 1 (parent): model calls spawn_subagent\n // Round 2 (child): model returns \"child result\" (no tools)\n // Round 3 (parent): model returns final response using child result\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n // Parent round 1: request subagent\n Some(MockResponse {\n content: \"I'll delegate this.\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-sub\".into(),\n name: SPAWN_SUBAGENT_TOOL.into(),\n arguments: r#\"{\"task\": \"analyze data\", \"label\": \"analysis\"}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n // Child: reply to the subagent task\n Some(MockResponse {\n content: \"The data shows X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n // Parent round 2: incorporate child result\n Some(MockResponse {\n content: \"Based on the analysis: X.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"analyze my data\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n // The parent incorporates the subagent result in its final response.\n assert!(\n response.content == \"Based on the analysis: X.\"\n || response.content == \"The data shows X.\",\n \"got: {}\",\n response.content\n );\n\n // Verify the subagent registry recorded the run.\n let active = runtime.subagent_registry.active_runs().await;\n assert!(active.is_empty(), \"subagent run should be completed\");\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "de7bcb04fbd5d037c7f2fcab51b993af798eba6a": {"t": 1785756985.0612595, "n": 74415, "h": "851bfc4c10ae882f", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n parallel_tool_calls: None,\n seed: None,\n response_format: None,\n reasoning_effort: None,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "932db7b6fa5282b804b4b823f1047786ab96e48e": {"t": 1785756986.9508011, "n": 74233, "h": "506ee1f3d5c067ef", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n\n // Mid-loop context compaction: re-check token budget after tool results\n // accumulate. If over budget, re-run optimize_context to prune old messages\n // and prevent context overflow errors on the next model call.\n let mid_tokens = context::estimate_messages_tokens(&request_messages);\n let mid_budget = context::available_input_budget(&model_def, system_prompt.as_deref());\n if mid_tokens > mid_budget {\n tracing::info!(\n session = %session_key,\n tokens = mid_tokens,\n budget = mid_budget,\n \"mid-loop context exceeds budget — re-compacting\"\n );\n let compacted = context::optimize_context(\n request_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n request_messages = compacted.messages;\n\n // If still over budget after compaction, return a graceful error\n // instead of letting the provider fail with a raw context overflow.\n let post_tokens = context::estimate_messages_tokens(&request_messages);\n if post_tokens > mid_budget {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"context too large after compaction ({} tokens, budget {})\",\n post_tokens, mid_budget\n ),\n });\n }\n }\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_compacts_context_mid_loop_when_over_budget() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-midcompact-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: tool call with large output that should trigger mid-loop compaction\n // Round 2: final response\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"checking\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"done\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"done\");\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "df544dacb56e11c9e14ab0e6bb6218c2cfe6eb6c": {"t": 1785756988.8002357, "n": 70361, "h": "23feca0b28aa57ce", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n // Stream any intermediate text content to the channel so the user\n // sees progress during multi-round tool execution instead of silence.\n if let Some(ref tx) = request.stream_tx {\n if !response.content.trim().is_empty() {\n let _ = tx.send(StreamDelta::Text(response.content.clone())).await;\n }\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_streams_intermediate_text_during_tool_rounds() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-stream-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Round 1: model returns text + tool call\n // Round 2: model returns final response (no tool calls)\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: \"Let me check that for you...\".into(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\": 5}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"Here are the results.\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let (tx, mut rx) = tokio::sync::mpsc::channel(32);\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"check session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: Some(tx),\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"Here are the results.\");\n\n // The intermediate text from round 1 should have been streamed.\n let mut received_intermediate = false;\n while let Ok(delta) = rx.try_recv() {\n if let StreamDelta::Text(text) = delta {\n if text.contains(\"Let me check\") {\n received_intermediate = true;\n }\n }\n }\n assert!(\n received_intermediate,\n \"should have received intermediate text during tool round\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "5b12cc7acb88a3e2ed4db9ab388a085324c3e48a": {"t": 1785756990.4121716, "n": 67085, "h": "b31d4043dd2bab54", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ImageContent, ModelDef, ModelProvider, StreamDelta,\n ToolCallResponse, Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n // Process image attachments for vision-capable models.\n let image_contents = if model_def.compat.supports_vision {\n fetch_image_attachments(&request.attachments).await\n } else {\n Vec::new()\n };\n\n // Build the user message: with images if available, text-only fallback otherwise.\n let user_message = if !image_contents.is_empty() {\n CompletionMessage::with_images(sanitized_message.clone(), image_contents)\n } else if !request.attachments.is_empty() && !model_def.compat.supports_vision {\n // Model doesn't support vision — add text description of attachments.\n let attachment_notes: Vec = request\n .attachments\n .iter()\n .filter(|a| a.mime_type.starts_with(\"image/\"))\n .map(|a| {\n let name = a.filename.as_deref().unwrap_or(\"image\");\n format!(\"[Image attached: {}]\", name)\n })\n .collect();\n if attachment_notes.is_empty() {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n } else {\n let prefix = attachment_notes.join(\"\\n\");\n CompletionMessage::text(\n Role::User,\n format!(\"{}\\n\\n{}\", prefix, sanitized_message),\n )\n }\n } else {\n CompletionMessage::text(Role::User, sanitized_message.clone())\n };\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(user_message))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\n/// Maximum image size for vision (5 MB, matches OpenClaw).\nconst MAX_VISION_IMAGE_BYTES: u64 = 5 * 1024 * 1024;\n\n/// Fetch image attachments from their URLs and encode as base64 for vision models.\n///\n/// Uses SSRF-safe fetching. Skips non-image attachments and attachments without URLs.\n/// Errors on individual images are logged but don't fail the batch.\nasync fn fetch_image_attachments(\n attachments: &[InboundAttachment],\n) -> Vec {\n use base64::Engine;\n\n let fetcher = frankclaw_media::SafeFetcher::new(MAX_VISION_IMAGE_BYTES);\n let mut images = Vec::new();\n\n for attachment in attachments {\n if !attachment.mime_type.starts_with(\"image/\") {\n continue;\n }\n let Some(url_str) = attachment.url.as_deref() else {\n continue;\n };\n let Ok(url) = url::Url::parse(url_str) else {\n tracing::warn!(url = url_str, \"invalid attachment URL, skipping\");\n continue;\n };\n\n match fetcher.fetch(&url).await {\n Ok(fetched) => {\n let b64 = base64::engine::general_purpose::STANDARD.encode(&fetched.bytes);\n // Use the fetched content-type if it's an image, otherwise fall back\n // to the attachment's declared MIME type.\n let mime = if fetched.content_type.starts_with(\"image/\") {\n fetched.content_type\n } else {\n attachment.mime_type.clone()\n };\n images.push(ImageContent {\n mime_type: mime,\n data: b64,\n });\n }\n Err(err) => {\n tracing::warn!(\n url = url_str,\n error = %err,\n \"failed to fetch image attachment for vision, skipping\"\n );\n }\n }\n }\n\n images\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "17b66503eb712d4bcde813841f8ff8f7e44a598b": {"t": 1785756992.5580633, "n": 63702, "h": "72c2856fbe205e6b", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ModelDef, ModelProvider, StreamDelta, ToolCallResponse,\n Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(CompletionMessage::text(Role::User, sanitized_message.clone())))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = match parse_tool_arguments(&tool_call) {\n Ok(args) => args,\n Err(err) => {\n // Return error result to model instead of aborting the turn.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"invalid arguments for tool '{}': {}\\\"}}\",\n tool_call.name, err\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let tool_result = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await;\n let tool_output = match tool_result {\n Ok(output) => output,\n Err(err) => {\n // Return synthetic error result so the model can recover.\n let error_content = format!(\n \"{{\\\"error\\\": \\\"tool '{}' failed: {}\\\"}}\",\n tool_call.name, err\n );\n tracing::warn!(\n tool = %tool_call.name,\n error = %err,\n \"tool invocation failed — returning error result to model\"\n );\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n error_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n \"is_error\": true,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n error_content,\n ));\n next_seq += 1;\n continue;\n }\n };\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_returns_error_result_for_invalid_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions: Arc = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n // First response: tool call with invalid JSON args.\n // Second response: model sees the error result and replies with text.\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone(),\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"I see the tool had invalid arguments\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let result = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"should succeed with error result fed back to model\");\n\n // Model should have received the error result and responded.\n assert!(result.content.contains(\"invalid arguments\"));\n\n // Transcript should include the error tool result.\n let transcript = sessions\n .get_transcript(&result.session_key, 100, None)\n .await\n .expect(\"transcript should load\");\n let error_entry = transcript\n .iter()\n .find(|e| e.role == Role::Tool && e.content.contains(\"invalid arguments\"));\n assert!(error_entry.is_some(), \"transcript should contain error tool result\");\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "cca630975e74b57d0689321933de91693c4470cc": {"t": 1785756994.1956542, "n": 60024, "h": "ac1c8a4b99bf9723", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ModelDef, ModelProvider, StreamDelta, ToolCallResponse,\n Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| transcript_entry_to_message(entry))\n .chain(std::iter::once(CompletionMessage::text(Role::User, sanitized_message.clone())))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::assistant_tool_calls(\n assistant_message,\n response.tool_calls.clone(),\n ));\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = parse_tool_arguments(&tool_call)?;\n let tool_output = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await?;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage::tool_result(\n &tool_call.id,\n tool_content,\n ));\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\n/// Convert a transcript entry back into a `CompletionMessage`, restoring tool call\n/// metadata from the entry's JSON metadata field.\nfn transcript_entry_to_message(entry: &frankclaw_core::session::TranscriptEntry) -> CompletionMessage {\n let metadata = entry.metadata.as_ref();\n\n // Assistant messages with tool_calls in metadata.\n if entry.role == Role::Assistant {\n if let Some(calls) = metadata.and_then(|m| m[\"tool_calls\"].as_array()) {\n let tool_calls: Vec = calls\n .iter()\n .filter_map(|c| {\n Some(ToolCallResponse {\n id: c[\"id\"].as_str()?.to_string(),\n name: c[\"name\"].as_str()?.to_string(),\n arguments: c[\"arguments\"].as_str().unwrap_or(\"{}\").to_string(),\n })\n })\n .collect();\n if !tool_calls.is_empty() {\n return CompletionMessage::assistant_tool_calls(entry.content.clone(), tool_calls);\n }\n }\n }\n\n // Tool result messages with tool_call_id in metadata.\n if entry.role == Role::Tool {\n if let Some(call_id) = metadata.and_then(|m| m[\"tool_call_id\"].as_str()) {\n return CompletionMessage::tool_result(call_id, entry.content.clone());\n }\n }\n\n CompletionMessage::text(entry.role, entry.content.clone())\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_invalid_model_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"invalid tool args should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"invalid arguments for tool\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "c87ab32626885105238fad55dc544442ce21273c": {"t": 1785756996.106106, "n": 58733, "h": "d78104794c1bf547", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod leak_detector;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ModelDef, ModelProvider, StreamDelta, ToolCallResponse,\n Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n /// Extended thinking budget in tokens (Anthropic Claude 3.7+).\n pub thinking_budget: Option,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| CompletionMessage {\n role: entry.role,\n content: entry.content.clone(),\n })\n .chain(std::iter::once(CompletionMessage {\n role: Role::User,\n content: sanitized_message.clone(),\n }))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n thinking_budget: request.thinking_budget,\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage {\n role: Role::Assistant,\n content: assistant_message,\n });\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = parse_tool_arguments(&tool_call)?;\n let tool_output = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await?;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Scan tool output for credential leaks before it enters the context.\n let leak_result = leak_detector::scan_for_leaks(&raw_content);\n if leak_result.should_block {\n let leaked = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %leaked,\n \"credential leak detected in tool output — blocking\"\n );\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' output contained credential(s): {}. Output blocked.\",\n tool_call.name, leaked,\n ),\n });\n }\n let safe_content = leak_result\n .redacted_content\n .as_deref()\n .unwrap_or(&raw_content);\n if !leak_result.matches.is_empty() {\n let warned = leak_result\n .matches\n .iter()\n .map(|m| format!(\"{}({})\", m.pattern_name, m.masked_preview))\n .collect::>()\n .join(\", \");\n tracing::warn!(\n tool = %tool_call.name,\n patterns = %warned,\n \"potential credential in tool output — redacted/warned\"\n );\n }\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(safe_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage {\n role: Role::Tool,\n content: tool_content,\n });\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_invalid_model_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"invalid tool args should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"invalid arguments for tool\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n thinking_budget: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "5c3012a8a9a073cd805dfb251ee89e28db87e65a": {"t": 1785756997.669605, "n": 56451, "h": "b33207847d4ff627", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ModelDef, ModelProvider, StreamDelta, ToolCallResponse,\n Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| CompletionMessage {\n role: entry.role,\n content: entry.content.clone(),\n })\n .chain(std::iter::once(CompletionMessage {\n role: Role::User,\n content: sanitized_message.clone(),\n }))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage {\n role: Role::Assistant,\n content: assistant_message,\n });\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = parse_tool_arguments(&tool_call)?;\n let tool_output = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await?;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(&raw_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage {\n role: Role::Tool,\n content: tool_content,\n });\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n // Section 7: Language instruction (non-English locales)\n let locale = std::env::var(\"FRANKCLAW_LANG\")\n .ok()\n .or_else(|| {\n std::env::var(\"LANG\")\n .ok()\n .map(|l| l.split('.').next().unwrap_or(\"en\").replace('_', \"-\"))\n })\n .unwrap_or_else(|| \"en\".into());\n if !locale.is_empty() && locale != \"en\" {\n let language = match locale.as_str() {\n \"pt-BR\" => \"Brazilian Portuguese\",\n \"pt-PT\" => \"European Portuguese\",\n \"es\" => \"Spanish\",\n \"fr\" => \"French\",\n \"de\" => \"German\",\n \"it\" => \"Italian\",\n \"ja\" => \"Japanese\",\n \"ko\" => \"Korean\",\n other => other,\n };\n sections.push(format!(\n \"IMPORTANT: Always respond in {language}. All your replies must be in {language}.\"\n ));\n }\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_invalid_model_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"invalid tool args should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"invalid arguments for tool\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "db341982a462d4cb13a646775a0beaeed014cbde": {"t": 1785756999.3734567, "n": 55463, "h": "52779e997a89eec8", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod prompts;\npub mod sanitize;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ModelDef, ModelProvider, StreamDelta, ToolCallResponse,\n Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n // Sanitize user message: strip Unicode control/format chars that could\n // be used for prompt injection (bidi overrides, zero-width chars, etc.).\n let sanitized_message = sanitize::sanitize_for_prompt(&request.message);\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| CompletionMessage {\n role: entry.role,\n content: entry.content.clone(),\n })\n .chain(std::iter::once(CompletionMessage {\n role: Role::User,\n content: sanitized_message.clone(),\n }))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n // Enforce hard prompt size limit to prevent token exhaustion / memory abuse.\n if !sanitize::check_prompt_size(&request_messages, system_prompt.as_deref()) {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"total prompt size exceeds maximum ({} bytes)\",\n sanitize::MAX_PROMPT_BYTES\n ),\n });\n }\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n sanitized_message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage {\n role: Role::Assistant,\n content: assistant_message,\n });\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = parse_tool_arguments(&tool_call)?;\n let tool_output = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await?;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(&raw_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage {\n role: Role::Tool,\n content: tool_content,\n });\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate and sanitize tool output to prevent context window overflow\n/// and prompt injection from external command results.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n // Sanitize first: tool outputs come from external processes and could\n // contain Unicode control/format chars designed to manipulate the LLM.\n let sanitized = sanitize::sanitize_for_prompt(output);\n let char_count = sanitized.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return sanitized;\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = sanitized.chars().take(head_chars).collect();\n let tail: String = sanitized.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_invalid_model_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"invalid tool args should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"invalid arguments for tool\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}, "935360e70e1026b1fc7f63c5e524bb52a3adeadf": {"t": 1785757001.7007096, "n": 54499, "h": "d05080dc1a18cc9c", "c": "#![forbid(unsafe_code)]\n\npub mod commands;\npub mod context;\npub mod prompts;\npub mod subagent;\n\nuse std::collections::{HashMap, HashSet};\nuse std::sync::Arc;\n\nuse chrono::Utc;\nuse secrecy::SecretString;\n\nuse frankclaw_core::config::{AgentDef, FrankClawConfig, ProviderConfig};\nuse frankclaw_core::error::{FrankClawError, Result};\nuse frankclaw_core::channel::{InboundAttachment, InboundMessage};\nuse frankclaw_core::model::{\n CompletionMessage, CompletionRequest, ModelDef, ModelProvider, StreamDelta, ToolCallResponse,\n Usage,\n};\nuse frankclaw_core::session::{SessionEntry, SessionStore, TranscriptEntry};\nuse frankclaw_core::types::{AgentId, ChannelId, Role, SessionKey};\nuse frankclaw_models::{\n AnthropicProvider, FailoverChain, OllamaProvider, OpenAiProvider, ProviderHealth,\n};\nuse frankclaw_plugin_sdk::{SkillManifest, load_workspace_skills};\nuse frankclaw_tools::{ToolContext, ToolOutput, ToolRegistry};\n\npub struct Runtime {\n config: FrankClawConfig,\n sessions: Arc,\n models: FailoverChain,\n model_defs: Vec,\n channel_ids: Vec,\n tools: ToolRegistry,\n skill_manifests: HashMap>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub message: String,\n pub attachments: Vec,\n pub model_id: Option,\n pub max_tokens: Option,\n pub temperature: Option,\n pub stream_tx: Option>,\n}\n\n#[derive(Debug, Clone)]\npub struct ChatResponse {\n pub session_key: SessionKey,\n pub model_id: String,\n pub content: String,\n pub usage: Usage,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolRequest {\n pub agent_id: Option,\n pub session_key: Option,\n pub tool_name: String,\n pub arguments: serde_json::Value,\n}\n\n#[derive(Debug, Clone)]\npub struct ToolActivity {\n pub seq: u64,\n pub tool_name: String,\n pub tool_call_id: Option,\n pub timestamp: chrono::DateTime,\n pub output_preview: String,\n}\n\nconst MAX_TOOL_ROUNDS: usize = 4;\nconst MAX_TOOL_CALLS_PER_TURN: usize = 8;\n/// Maximum size (in chars) for a single tool output.\n/// Prevents a single tool from blowing up the context window.\nconst MAX_TOOL_RESULT_CHARS: usize = 400_000;\n/// Warn after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_WARN_THRESHOLD: usize = 3;\n/// Hard block after this many identical tool+args calls in one turn.\nconst TOOL_REPEAT_BLOCK_THRESHOLD: usize = 6;\n/// Safety timeout for an entire chat turn (seconds).\nconst TURN_SAFETY_TIMEOUT_SECS: u64 = 600;\n\nimpl Runtime {\n pub async fn from_config(\n config: &FrankClawConfig,\n sessions: Arc,\n ) -> Result {\n let providers = build_providers(config)?;\n Self::from_providers(config, sessions, providers).await\n }\n\n pub async fn from_providers(\n config: &FrankClawConfig,\n sessions: Arc,\n providers: Vec>,\n ) -> Result {\n let cooldown_secs = config\n .models\n .providers\n .iter()\n .map(|provider| provider.cooldown_secs)\n .max()\n .unwrap_or(30)\n .max(1);\n let models = FailoverChain::new(providers, cooldown_secs);\n let model_defs = models.list_models().await?;\n let channel_ids = config\n .channels\n .iter()\n .filter_map(|(channel_id, channel)| {\n if channel.enabled {\n Some(channel_id.clone())\n } else {\n None\n }\n })\n .collect();\n let tools = ToolRegistry::with_builtins();\n for (agent_id, agent) in &config.agents.agents {\n tools.validate_names(&agent.tools).map_err(|err| {\n FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' has invalid tool config: {}\", agent_id, err),\n }\n })?;\n }\n let skill_manifests = load_agent_skills(config)?;\n\n Ok(Self {\n config: config.clone(),\n sessions,\n models,\n model_defs,\n channel_ids,\n tools,\n skill_manifests,\n })\n }\n\n pub fn list_models(&self) -> &[ModelDef] {\n &self.model_defs\n }\n\n pub async fn provider_health(&self) -> Vec {\n self.models.health().await\n }\n\n pub fn list_channels(&self) -> &[ChannelId] {\n &self.channel_ids\n }\n\n pub fn list_tools(&self, agent_id: Option<&AgentId>) -> Result> {\n let (_, agent) = self.resolve_agent(agent_id)?;\n self.tools.definitions(&agent.tools)\n }\n\n pub fn list_skills(&self, agent_id: Option<&AgentId>) -> Result<&[SkillManifest]> {\n let (agent_id, _) = self.resolve_agent(agent_id)?;\n Ok(self\n .skill_manifests\n .get(&agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]))\n }\n\n pub fn agent_surface(\n &self,\n ) -> impl Iterator {\n self.config.agents.agents.iter().map(|(agent_id, agent)| {\n (\n agent_id,\n agent,\n self.skill_manifests\n .get(agent_id)\n .map(|skills| skills.as_slice())\n .unwrap_or(&[]),\n )\n })\n }\n\n pub fn session_key_for_inbound(\n &self,\n inbound: &InboundMessage,\n ) -> SessionKey {\n let account_scope = self.config.session.scoping.resolve_inbound_account_scope(\n &inbound.account_id,\n &inbound.sender_id,\n inbound.thread_id.as_deref(),\n inbound.is_group,\n );\n\n SessionKey::new(\n &self.config.agents.default_agent,\n &inbound.channel,\n &account_scope,\n )\n }\n\n pub async fn chat(&self, request: ChatRequest) -> Result {\n if request.message.trim().is_empty() {\n return Err(FrankClawError::InvalidRequest {\n msg: \"message is required\".into(),\n });\n }\n\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n let model_id = self.resolve_model_id(&agent, request.model_id.as_deref())?;\n let session_key = self.resolve_session_key(&agent_id, request.session_key)?;\n let history = self.sessions.get_transcript(&session_key, 200, None).await?;\n let mut next_seq = history.last().map(|entry| entry.seq + 1).unwrap_or(1);\n let allowed_tools = self.tools.definitions(&agent.tools)?;\n\n self.ensure_session(&session_key, &agent_id).await?;\n\n // Resolve the model definition for context window awareness.\n let model_def = self\n .model_defs\n .iter()\n .find(|m| m.id == model_id)\n .cloned()\n .unwrap_or_else(|| {\n // Fallback: use conservative defaults if model not in catalog.\n ModelDef {\n id: model_id.clone(),\n name: model_id.clone(),\n api: frankclaw_core::model::ModelApi::OpenaiCompletions,\n reasoning: false,\n input: vec![frankclaw_core::model::InputModality::Text],\n cost: Default::default(),\n context_window: 128_000,\n max_output_tokens: 4096,\n compat: Default::default(),\n }\n });\n\n let raw_messages: Vec = history\n .iter()\n .map(|entry| CompletionMessage {\n role: entry.role,\n content: entry.content.clone(),\n })\n .chain(std::iter::once(CompletionMessage {\n role: Role::User,\n content: request.message.clone(),\n }))\n .collect();\n\n // Build dynamic system prompt with runtime context.\n let tool_names: Vec = allowed_tools.iter().map(|t| t.name.clone()).collect();\n let system_prompt = self.build_system_prompt(&agent_id, &agent, &model_id, &tool_names);\n let context_result = context::optimize_context(\n raw_messages,\n &model_def,\n system_prompt.as_deref(),\n );\n if context_result.compacted {\n tracing::info!(\n session = %session_key,\n pruned = context_result.pruned_count,\n estimated_tokens = context_result.estimated_tokens,\n \"context window optimized — pruned old messages\"\n );\n }\n let mut request_messages = context_result.messages;\n\n let user_metadata = (!request.attachments.is_empty()).then(|| {\n serde_json::json!({\n \"attachments\": request.attachments,\n })\n });\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::User,\n request.message,\n user_metadata,\n )\n .await?;\n next_seq += 1;\n\n let mut remaining_tool_calls = MAX_TOOL_CALLS_PER_TURN;\n let mut tool_tracker = ToolCallTracker::new();\n let turn_deadline = tokio::time::Instant::now()\n + std::time::Duration::from_secs(TURN_SAFETY_TIMEOUT_SECS);\n\n for _round in 0..=MAX_TOOL_ROUNDS {\n // Safety timeout: abort if the entire turn is taking too long.\n if tokio::time::Instant::now() >= turn_deadline {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"turn safety timeout exceeded ({}s)\",\n TURN_SAFETY_TIMEOUT_SECS\n ),\n });\n }\n let response = self\n .models\n .complete(\n CompletionRequest {\n model_id: model_id.clone(),\n messages: request_messages.clone(),\n max_tokens: request.max_tokens,\n temperature: request.temperature,\n system: system_prompt.clone(),\n tools: allowed_tools.clone(),\n },\n request.stream_tx.clone(),\n )\n .await?;\n\n if response.tool_calls.is_empty() {\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n response.content.clone(),\n None,\n )\n .await?;\n return Ok(ChatResponse {\n session_key,\n model_id,\n content: response.content,\n usage: response.usage,\n });\n }\n\n if response.tool_calls.len() > remaining_tool_calls {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"model requested too many tool calls in one turn (max {})\",\n MAX_TOOL_CALLS_PER_TURN\n ),\n });\n }\n\n let tool_call_count = response.tool_calls.len();\n let assistant_message =\n build_tool_request_message(&response.content, &response.tool_calls);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Assistant,\n assistant_message.clone(),\n Some(serde_json::json!({\n \"tool_calls\": response\n .tool_calls\n .iter()\n .map(|call| serde_json::json!({\n \"id\": call.id,\n \"name\": call.name,\n \"arguments\": call.arguments,\n }))\n .collect::>(),\n })),\n )\n .await?;\n request_messages.push(CompletionMessage {\n role: Role::Assistant,\n content: assistant_message,\n });\n next_seq += 1;\n\n for tool_call in response.tool_calls {\n // Detect tool call loops (identical name+args repeated).\n tool_tracker.record(&tool_call.name, &tool_call.arguments)?;\n\n let arguments = parse_tool_arguments(&tool_call)?;\n let tool_output = self\n .tools\n .invoke_allowed(\n &agent.tools,\n &tool_call.name,\n arguments,\n ToolContext {\n agent_id: agent_id.clone(),\n session_key: Some(session_key.clone()),\n sessions: self.sessions.clone(),\n },\n )\n .await?;\n let raw_content = serde_json::to_string(&serde_json::json!({\n \"tool\": tool_output.name,\n \"output\": tool_output.output,\n }))\n .map_err(|err| FrankClawError::Internal {\n msg: format!(\"failed to serialize tool output: {err}\"),\n })?;\n // Truncate oversized tool results to prevent context overflow.\n let tool_content = truncate_tool_output(&raw_content);\n self.append_transcript_entry(\n &session_key,\n next_seq,\n Role::Tool,\n tool_content.clone(),\n Some(serde_json::json!({\n \"tool_name\": tool_call.name,\n \"tool_call_id\": tool_call.id,\n })),\n )\n .await?;\n request_messages.push(CompletionMessage {\n role: Role::Tool,\n content: tool_content,\n });\n next_seq += 1;\n }\n\n remaining_tool_calls -= tool_call_count;\n }\n\n Err(FrankClawError::AgentRuntime {\n msg: format!(\"tool round limit exceeded (max {})\", MAX_TOOL_ROUNDS),\n })\n }\n\n pub async fn invoke_tool(&self, request: ToolRequest) -> Result {\n let (agent_id, agent) = self.resolve_agent(request.agent_id.as_ref())?;\n self.tools\n .invoke_allowed(\n &agent.tools,\n &request.tool_name,\n request.arguments,\n ToolContext {\n agent_id,\n session_key: request.session_key,\n sessions: self.sessions.clone(),\n },\n )\n .await\n }\n\n pub async fn tool_activity(\n &self,\n session_key: &SessionKey,\n limit: usize,\n ) -> Result> {\n let entries = self\n .sessions\n .get_transcript(session_key, limit.saturating_mul(4).max(1), None)\n .await?;\n let mut activity = entries\n .into_iter()\n .filter(|entry| entry.role == Role::Tool)\n .filter_map(|entry| {\n let metadata = entry.metadata.as_ref()?;\n let tool_name = metadata[\"tool_name\"].as_str()?.to_string();\n Some(ToolActivity {\n seq: entry.seq,\n tool_name,\n tool_call_id: metadata[\"tool_call_id\"].as_str().map(str::to_string),\n timestamp: entry.timestamp,\n output_preview: summarize_tool_output(&entry.content),\n })\n })\n .collect::>();\n activity.sort_by_key(|entry| entry.seq);\n if activity.len() > limit {\n activity = activity.split_off(activity.len() - limit);\n }\n Ok(activity)\n }\n\n fn resolve_agent(&self, requested: Option<&AgentId>) -> Result<(AgentId, AgentDef)> {\n let agent_id = requested\n .cloned()\n .unwrap_or_else(|| self.config.agents.default_agent.clone());\n let agent = self\n .config\n .agents\n .agents\n .get(&agent_id)\n .cloned()\n .ok_or_else(|| FrankClawError::AgentNotFound {\n agent_id: agent_id.clone(),\n })?;\n Ok((agent_id, agent))\n }\n\n fn build_system_prompt(\n &self,\n agent_id: &AgentId,\n agent: &AgentDef,\n model_id: &str,\n tool_names: &[String],\n ) -> Option {\n let mut sections: Vec = Vec::new();\n\n // Section 1: Identity\n sections.push(prompts::render(prompts::AGENT_IDENTITY, &[\n (\"agent_name\", &agent.name),\n ]));\n\n // Section 2: User-defined system prompt (highest priority content)\n if let Some(prompt) = agent.system_prompt.as_deref() {\n let trimmed = prompt.trim();\n if !trimmed.is_empty() {\n sections.push(trimmed.to_string());\n }\n }\n\n // Section 3: Available tools\n if !tool_names.is_empty() {\n let tool_list = tool_names.join(\", \");\n sections.push(prompts::render(prompts::AGENT_TOOLS, &[\n (\"tool_list\", &tool_list),\n ]));\n }\n\n // Section 4: Skills\n let skill_prompts: Vec = self\n .skill_manifests\n .get(agent_id)\n .map(|skills| {\n skills\n .iter()\n .map(|skill| format!(\"[Skill: {}]\\n{}\", skill.name, skill.prompt.trim()))\n .collect()\n })\n .unwrap_or_default();\n if !skill_prompts.is_empty() {\n sections.push(skill_prompts.join(\"\\n\\n\"));\n }\n\n // Section 5: Safety\n sections.push(prompts::AGENT_SAFETY.trim().to_string());\n\n // Section 6: Runtime context\n let now = Utc::now();\n let tool_count_str = tool_names.len().to_string();\n let date_str = now.format(\"%Y-%m-%d %H:%M UTC\").to_string();\n sections.push(prompts::render(prompts::AGENT_CONTEXT, &[\n (\"agent_id\", agent_id.as_str()),\n (\"model_id\", model_id),\n (\"date\", &date_str),\n (\"tool_count\", &tool_count_str),\n ]));\n\n if sections.is_empty() {\n None\n } else {\n Some(sections.join(\"\\n\\n\"))\n }\n }\n\n fn resolve_model_id(\n &self,\n agent: &AgentDef,\n requested: Option<&str>,\n ) -> Result {\n if let Some(model_id) = requested {\n return Ok(model_id.to_string());\n }\n if let Some(model_id) = &agent.model {\n return Ok(model_id.clone());\n }\n if let Some(model_id) = &self.config.models.default_model {\n return Ok(model_id.clone());\n }\n self.model_defs\n .first()\n .map(|model| model.id.clone())\n .ok_or_else(|| FrankClawError::ConfigValidation {\n msg: \"no model providers are configured\".into(),\n })\n }\n\n fn resolve_session_key(\n &self,\n agent_id: &AgentId,\n explicit: Option,\n ) -> Result {\n if let Some(session_key) = explicit {\n if let Some((session_agent_id, _, _)) = session_key.parse() {\n if &session_agent_id != agent_id {\n return Err(FrankClawError::InvalidRequest {\n msg: format!(\n \"session '{}' does not belong to agent '{}'\",\n session_key, agent_id\n ),\n });\n }\n }\n return Ok(session_key);\n }\n\n Ok(SessionKey::new(\n agent_id,\n &ChannelId::new(\"web\"),\n \"control\",\n ))\n }\n\n async fn ensure_session(\n &self,\n session_key: &SessionKey,\n agent_id: &AgentId,\n ) -> Result<()> {\n if self.sessions.get(session_key).await?.is_some() {\n return Ok(());\n }\n\n let (channel, account_id) = session_key\n .parse()\n .map(|(_, channel, account_id)| (channel, account_id))\n .unwrap_or_else(|| (ChannelId::new(\"web\"), \"control\".to_string()));\n\n self.sessions\n .upsert(&SessionEntry {\n key: session_key.clone(),\n agent_id: agent_id.clone(),\n channel,\n account_id,\n scoping: self.config.session.scoping,\n created_at: Utc::now(),\n last_message_at: None,\n thread_id: None,\n metadata: serde_json::json!({}),\n })\n .await\n }\n\n async fn append_transcript_entry(\n &self,\n session_key: &SessionKey,\n seq: u64,\n role: Role,\n content: String,\n metadata: Option,\n ) -> Result<()> {\n self.sessions\n .append_transcript(\n session_key,\n &TranscriptEntry {\n seq,\n role,\n content,\n timestamp: Utc::now(),\n metadata,\n },\n )\n .await\n }\n}\n\n/// Track tool call patterns to detect loops where the model repeatedly calls\n/// the same tool with the same arguments without making progress.\nstruct ToolCallTracker {\n /// Map from (tool_name, arguments_hash) → count of identical calls.\n seen: HashMap<(String, u64), usize>,\n}\n\nimpl ToolCallTracker {\n fn new() -> Self {\n Self {\n seen: HashMap::new(),\n }\n }\n\n /// Record a tool call. Returns an error if the repeat threshold is exceeded.\n fn record(&mut self, name: &str, arguments: &str) -> Result<()> {\n use std::hash::{Hash, Hasher};\n let mut hasher = std::collections::hash_map::DefaultHasher::new();\n arguments.hash(&mut hasher);\n let args_hash = hasher.finish();\n let key = (name.to_string(), args_hash);\n let count = self.seen.entry(key).or_insert(0);\n *count += 1;\n\n if *count >= TOOL_REPEAT_BLOCK_THRESHOLD {\n return Err(FrankClawError::AgentRuntime {\n msg: format!(\n \"tool '{}' called {} times with identical arguments — possible infinite loop\",\n name, count\n ),\n });\n }\n if *count >= TOOL_REPEAT_WARN_THRESHOLD {\n tracing::warn!(\n tool = name,\n count = *count,\n \"repeated tool call detected — possible loop\"\n );\n }\n Ok(())\n }\n}\n\n/// Truncate tool output to prevent context window overflow.\n/// Preserves the beginning and end of the output with an omission marker.\nfn truncate_tool_output(output: &str) -> String {\n let char_count = output.chars().count();\n if char_count <= MAX_TOOL_RESULT_CHARS {\n return output.to_string();\n }\n\n // Keep 80% head, 20% tail with omission marker in between.\n let head_chars = (MAX_TOOL_RESULT_CHARS * 4) / 5;\n let tail_chars = MAX_TOOL_RESULT_CHARS / 5;\n let omitted = char_count - head_chars - tail_chars;\n\n let head: String = output.chars().take(head_chars).collect();\n let tail: String = output.chars().skip(char_count - tail_chars).collect();\n\n format!(\n \"{}\\n\\n... ({} characters omitted) ...\\n\\n{}\",\n head, omitted, tail\n )\n}\n\nfn build_tool_request_message(content: &str, tool_calls: &[ToolCallResponse]) -> String {\n let mut segments = Vec::new();\n let trimmed = content.trim();\n if !trimmed.is_empty() {\n segments.push(trimmed.to_string());\n }\n for call in tool_calls {\n segments.push(format!(\"[tool_call:{} {}]\", call.name, call.arguments));\n }\n segments.join(\"\\n\")\n}\n\nfn summarize_tool_output(content: &str) -> String {\n let preview = serde_json::from_str::(content)\n .ok()\n .and_then(|value| value.get(\"output\").cloned())\n .map(|value| value.to_string())\n .unwrap_or_else(|| content.to_string());\n let preview = preview.replace('\\n', \" \");\n if preview.chars().count() > 120 {\n format!(\"{}...\", preview.chars().take(120).collect::())\n } else {\n preview\n }\n}\n\nfn parse_tool_arguments(tool_call: &ToolCallResponse) -> Result {\n serde_json::from_str(&tool_call.arguments).map_err(|err| FrankClawError::AgentRuntime {\n msg: format!(\n \"model produced invalid arguments for tool '{}': {}\",\n tool_call.name, err\n ),\n })\n}\n\nfn build_providers(\n config: &FrankClawConfig,\n) -> Result>> {\n let mut providers: Vec> = Vec::new();\n let mut seen_ids = HashSet::new();\n\n for provider in &config.models.providers {\n if !seen_ids.insert(provider.id.clone()) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"duplicate model provider id '{}'\", provider.id),\n });\n }\n\n let provider_impl: Arc =\n match provider.api.as_str() {\n \"openai\" => Arc::new(OpenAiProvider::new(\n provider.id.clone(),\n provider\n .base_url\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string()),\n resolve_secret(provider, \"OPENAI_API_KEY\")?,\n provider.models.clone(),\n )),\n \"anthropic\" => Arc::new(AnthropicProvider::new(\n provider.id.clone(),\n resolve_secret(provider, \"ANTHROPIC_API_KEY\")?,\n provider.models.clone(),\n )),\n \"ollama\" => Arc::new(OllamaProvider::new(\n provider.id.clone(),\n provider.base_url.clone(),\n )),\n other => {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"unsupported model provider api '{}'; expected openai, anthropic, or ollama\",\n other\n ),\n });\n }\n };\n providers.push(provider_impl);\n }\n\n Ok(providers)\n}\n\nfn load_agent_skills(config: &FrankClawConfig) -> Result>> {\n let mut manifests = HashMap::new();\n\n for (agent_id, agent) in &config.agents.agents {\n if agent.skills.is_empty() {\n manifests.insert(agent_id.clone(), Vec::new());\n continue;\n }\n\n let workspace = agent.workspace.as_ref().ok_or_else(|| FrankClawError::ConfigValidation {\n msg: format!(\"agent '{}' declares skills but has no workspace\", agent_id),\n })?;\n let skills = load_workspace_skills(workspace, &agent.skills)?;\n for skill in &skills {\n for tool in &skill.tools {\n if !agent.tools.iter().any(|allowed| allowed == tool) {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"agent '{}' skill '{}' requires tool '{}' but the agent does not allow it\",\n agent_id, skill.id, tool\n ),\n });\n }\n }\n }\n manifests.insert(agent_id.clone(), skills);\n }\n\n Ok(manifests)\n}\n\nfn resolve_secret(provider: &ProviderConfig, default_env: &str) -> Result {\n let env_key = provider\n .api_key_ref\n .as_deref()\n .unwrap_or(default_env)\n .trim();\n if env_key.is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\"provider '{}' requires an api_key_ref\", provider.id),\n });\n }\n\n let value = std::env::var(env_key).map_err(|_| FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' references missing environment variable '{}'\",\n provider.id, env_key\n ),\n })?;\n\n if value.trim().is_empty() {\n return Err(FrankClawError::ConfigValidation {\n msg: format!(\n \"provider '{}' environment variable '{}' is empty\",\n provider.id, env_key\n ),\n });\n }\n\n Ok(SecretString::from(value))\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use async_trait::async_trait;\n use frankclaw_core::model::{\n CompletionResponse, FinishReason, InputModality, ModelApi, ModelCompat, ModelCost,\n ToolCallResponse, ToolDef,\n };\n use frankclaw_core::session::SessionStore;\n use frankclaw_sessions::SqliteSessionStore;\n use std::collections::VecDeque;\n use std::sync::Mutex;\n\n #[derive(Debug, Clone)]\n struct CapturedRequest {\n messages: Vec,\n system: Option,\n tools: Vec,\n }\n\n #[derive(Debug, Clone)]\n struct MockResponse {\n content: String,\n tool_calls: Vec,\n finish_reason: FinishReason,\n }\n\n struct MockProvider {\n id: String,\n model_id: String,\n responses: Mutex>>,\n seen_requests: Mutex>,\n }\n\n impl MockProvider {\n fn reply(id: &str, model_id: &str, content: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([Some(MockResponse {\n content: content.into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n })])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn failing(id: &str, model_id: &str) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(VecDeque::from([None])),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n\n fn scripted(id: &str, model_id: &str, responses: Vec>) -> Self {\n Self {\n id: id.into(),\n model_id: model_id.into(),\n responses: Mutex::new(responses.into()),\n seen_requests: Mutex::new(Vec::new()),\n }\n }\n }\n\n #[async_trait]\n impl ModelProvider for MockProvider {\n fn id(&self) -> &str {\n &self.id\n }\n\n async fn complete(\n &self,\n request: CompletionRequest,\n _stream_tx: Option>,\n ) -> Result {\n self.seen_requests\n .lock()\n .expect(\"request capture should lock\")\n .push(CapturedRequest {\n messages: request.messages.clone(),\n system: request.system.clone(),\n tools: request.tools.clone(),\n });\n match self\n .responses\n .lock()\n .expect(\"response queue should lock\")\n .pop_front()\n .unwrap_or(None)\n {\n Some(response) => Ok(CompletionResponse {\n content: response.content,\n tool_calls: response.tool_calls,\n usage: Usage {\n input_tokens: 4,\n output_tokens: 2,\n cache_read_tokens: None,\n cache_write_tokens: None,\n },\n finish_reason: response.finish_reason,\n }),\n None => Err(FrankClawError::AllProvidersFailed),\n }\n }\n\n async fn list_models(&self) -> Result> {\n Ok(vec![ModelDef {\n id: self.model_id.clone(),\n name: self.model_id.clone(),\n api: ModelApi::Ollama,\n reasoning: false,\n input: vec![InputModality::Text],\n cost: ModelCost::default(),\n context_window: 8192,\n max_output_tokens: 1024,\n compat: ModelCompat::default(),\n }])\n }\n\n async fn health(&self) -> bool {\n true\n }\n }\n\n #[tokio::test]\n async fn runtime_fails_over_to_next_provider_and_persists_history() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.models.providers = vec![\n ProviderConfig {\n id: \"primary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-primary\".into()],\n cooldown_secs: 1,\n },\n ProviderConfig {\n id: \"secondary\".into(),\n api: \"ollama\".into(),\n base_url: None,\n api_key_ref: None,\n models: vec![\"mock-secondary\".into()],\n cooldown_secs: 1,\n },\n ];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![\n Arc::new(MockProvider {\n ..MockProvider::failing(\"primary\", \"mock-primary\")\n }),\n Arc::new(MockProvider::reply(\n \"secondary\",\n \"mock-secondary\",\n \"fallback reply\",\n )),\n ],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-secondary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"fallback reply\");\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 2);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_invokes_allowed_tool() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tools-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let chat = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let tool = runtime\n .invoke_tool(ToolRequest {\n agent_id: None,\n session_key: Some(chat.session_key.clone()),\n tool_name: \"session.inspect\".into(),\n arguments: serde_json::json!({ \"limit\": 5 }),\n })\n .await\n .expect(\"tool should run\");\n\n assert_eq!(tool.name, \"session.inspect\");\n assert_eq!(\n tool.output[\"session\"][\"key\"],\n serde_json::json!(chat.session_key.as_str())\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_persists_user_attachment_metadata() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-attachments-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions.clone() as Arc,\n vec![Arc::new(MockProvider::reply(\n \"primary\",\n \"mock-primary\",\n \"reply\",\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"here is a screenshot\".into(),\n attachments: vec![InboundAttachment {\n media_id: Some(frankclaw_core::types::MediaId::new()),\n mime_type: \"image/png\".into(),\n filename: Some(\"photo.png\".into()),\n size_bytes: Some(42),\n url: Some(\"/api/media/test-photo\".into()),\n }],\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n let attachments = transcript[0]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"attachments\"].as_array())\n .expect(\"user transcript metadata should include attachments\");\n assert_eq!(attachments.len(), 1);\n assert_eq!(attachments[0][\"mime_type\"], serde_json::json!(\"image/png\"));\n assert_eq!(attachments[0][\"filename\"], serde_json::json!(\"photo.png\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_includes_skill_prompt_in_system_message() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-skill-{}\",\n uuid::Uuid::new_v4()\n ));\n let workspace = temp.join(\"workspace\");\n let skill_dir = workspace.join(\".frankclaw/skills/briefing\");\n std::fs::create_dir_all(&skill_dir).expect(\"skill dir should exist\");\n std::fs::write(\n skill_dir.join(\"skill.json\"),\n serde_json::json!({\n \"id\": \"briefing\",\n \"name\": \"Briefing\",\n \"prompt\": \"Summarize in a terse operational style.\",\n \"capabilities\": [\"prompt\"],\n \"tools\": []\n })\n .to_string(),\n )\n .expect(\"skill manifest should write\");\n\n let sessions = Arc::new(SqliteSessionStore::open(&temp.join(\"sessions.db\"), None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n let agent = config.agents.agents.get_mut(&AgentId::default_agent()).unwrap();\n agent.workspace = Some(workspace.clone());\n agent.system_prompt = Some(\"Base system\".into());\n agent.skills = vec![\"briefing\".into()];\n\n let provider = Arc::new(MockProvider {\n ..MockProvider::reply(\"primary\", \"mock-primary\", \"reply\")\n });\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"hello\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n let system = seen\n .last()\n .and_then(|value| value.system.clone())\n .expect(\"system prompt should be captured\");\n assert!(system.contains(\"Base system\"));\n assert!(system.contains(\"[Skill: Briefing]\"));\n assert!(system.contains(\"Summarize in a terse operational style.\"));\n\n let _ = std::fs::remove_dir_all(temp);\n }\n\n #[tokio::test]\n async fn runtime_executes_model_requested_tools_and_persists_trace() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let provider = Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![\n Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":2}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }),\n Some(MockResponse {\n content: \"tool-backed reply\".into(),\n tool_calls: Vec::new(),\n finish_reason: FinishReason::Stop,\n }),\n ],\n ));\n let runtime = Runtime::from_providers(\n &config,\n sessions.clone() as Arc,\n vec![provider.clone()],\n )\n .await\n .expect(\"runtime should build\");\n\n let response = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect(\"chat should succeed\");\n\n assert_eq!(response.content, \"tool-backed reply\");\n\n let transcript = sessions\n .get_transcript(&response.session_key, 10, None)\n .await\n .expect(\"transcript should load\");\n assert_eq!(transcript.len(), 4);\n assert_eq!(transcript[0].role, Role::User);\n assert_eq!(transcript[1].role, Role::Assistant);\n assert_eq!(transcript[2].role, Role::Tool);\n assert_eq!(transcript[3].role, Role::Assistant);\n assert_eq!(\n transcript[1]\n .metadata\n .as_ref()\n .and_then(|metadata| metadata[\"tool_calls\"].as_array())\n .map(|calls| calls.len()),\n Some(1)\n );\n assert!(transcript[2].content.contains(\"\\\"session\\\"\"));\n\n let seen = provider\n .seen_requests\n .lock()\n .expect(\"request capture should lock\");\n assert_eq!(seen.len(), 2);\n assert_eq!(seen[0].tools.len(), 1);\n assert_eq!(seen[0].tools[0].name, \"session.inspect\");\n assert!(seen[1]\n .messages\n .iter()\n .any(|message| message.role == Role::Tool && message.content.contains(\"\\\"entries\\\"\")));\n\n let activity = runtime\n .tool_activity(&response.session_key, 10)\n .await\n .expect(\"tool activity should load\");\n assert_eq!(activity.len(), 1);\n assert_eq!(activity[0].tool_name, \"session.inspect\");\n assert_eq!(activity[0].tool_call_id.as_deref(), Some(\"call-1\"));\n assert!(activity[0].output_preview.contains(\"\\\"entries\\\"\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_invalid_model_tool_arguments() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-args-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-1\".into(),\n name: \"session.inspect\".into(),\n arguments: \"{not-json}\".into(),\n }],\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"invalid tool args should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"invalid arguments for tool\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_rejects_excessive_model_tool_calls_in_one_turn() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-tool-limit-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let mut config = FrankClawConfig::default();\n config.agents.agents.get_mut(&AgentId::default_agent()).unwrap().tools =\n vec![\"session.inspect\".into()];\n let tool_calls = (0..9)\n .map(|index| ToolCallResponse {\n id: format!(\"call-{index}\"),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n })\n .collect();\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n vec![Some(MockResponse {\n content: String::new(),\n tool_calls,\n finish_reason: FinishReason::ToolUse,\n })],\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"inspect this session\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"too many tool calls should fail\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n assert!(err.to_string().contains(\"too many tool calls\"));\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[test]\n fn tool_call_tracker_detects_repeated_calls() {\n let mut tracker = ToolCallTracker::new();\n\n // First few calls should succeed.\n for _ in 0..TOOL_REPEAT_WARN_THRESHOLD {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should not block yet\");\n }\n\n // Calls beyond warn threshold should still succeed but log.\n for _ in TOOL_REPEAT_WARN_THRESHOLD..TOOL_REPEAT_BLOCK_THRESHOLD - 1 {\n tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect(\"should warn but not block\");\n }\n\n // At block threshold, should return error.\n let err = tracker\n .record(\"my_tool\", r#\"{\"query\":\"same\"}\"#)\n .expect_err(\"should block at threshold\");\n assert!(err.to_string().contains(\"infinite loop\"));\n }\n\n #[test]\n fn tool_call_tracker_allows_different_arguments() {\n let mut tracker = ToolCallTracker::new();\n\n for i in 0..20 {\n tracker\n .record(\"my_tool\", &format!(r#\"{{\"query\":\"query-{i}\"}}\"#))\n .expect(\"different args should not trigger loop detection\");\n }\n }\n\n #[test]\n fn truncate_tool_output_leaves_small_outputs_unchanged() {\n let small = \"hello world\";\n assert_eq!(truncate_tool_output(small), small);\n }\n\n #[test]\n fn truncate_tool_output_truncates_large_outputs() {\n let large = \"x\".repeat(MAX_TOOL_RESULT_CHARS + 1000);\n let result = truncate_tool_output(&large);\n assert!(result.chars().count() <= MAX_TOOL_RESULT_CHARS + 100); // some marker overhead\n assert!(result.contains(\"characters omitted\"));\n }\n\n #[tokio::test]\n async fn runtime_detects_tool_call_loop() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-loop-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(\n SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"),\n );\n let mut config = FrankClawConfig::default();\n config\n .agents\n .agents\n .get_mut(&AgentId::default_agent())\n .unwrap()\n .tools = vec![\"session.inspect\".into()];\n\n // Model keeps calling same tool with same args every round.\n let mut responses = Vec::new();\n for _ in 0..MAX_TOOL_ROUNDS + 1 {\n responses.push(Some(MockResponse {\n content: String::new(),\n tool_calls: vec![ToolCallResponse {\n id: \"call-loop\".into(),\n name: \"session.inspect\".into(),\n arguments: r#\"{\"limit\":1}\"#.into(),\n }],\n finish_reason: FinishReason::ToolUse,\n }));\n }\n\n let runtime = Runtime::from_providers(\n &config,\n sessions as Arc,\n vec![Arc::new(MockProvider::scripted(\n \"primary\",\n \"mock-primary\",\n responses,\n ))],\n )\n .await\n .expect(\"runtime should build\");\n\n let err = runtime\n .chat(ChatRequest {\n agent_id: None,\n session_key: None,\n message: \"loop test\".into(),\n attachments: Vec::new(),\n model_id: Some(\"mock-primary\".into()),\n max_tokens: None,\n temperature: None,\n stream_tx: None,\n })\n .await\n .expect_err(\"loop should be detected\");\n\n assert!(matches!(err, FrankClawError::AgentRuntime { .. }));\n // Could be tool round limit or loop detection — both are correct.\n let msg = err.to_string();\n assert!(\n msg.contains(\"infinite loop\") || msg.contains(\"tool round limit\"),\n \"unexpected error: {msg}\"\n );\n\n let _ = std::fs::remove_file(temp);\n }\n\n #[tokio::test]\n async fn runtime_reports_provider_health() {\n let temp = std::env::temp_dir().join(format!(\n \"frankclaw-runtime-health-{}.db\",\n uuid::Uuid::new_v4()\n ));\n let sessions = Arc::new(SqliteSessionStore::open(&temp, None).expect(\"sessions should open\"));\n let provider = Arc::new(MockProvider::reply(\"primary\", \"mock-primary\", \"reply\"));\n let runtime = Runtime::from_providers(\n &FrankClawConfig::default(),\n sessions as Arc,\n vec![provider],\n )\n .await\n .expect(\"runtime should build\");\n\n let health = runtime.provider_health().await;\n assert_eq!(health.len(), 1);\n assert_eq!(health[0].provider_id, \"primary\");\n assert!(health[0].healthy);\n\n let _ = std::fs::remove_file(temp);\n }\n}\n"}}}}