diff --git a/Dockerfile b/Dockerfile index 7b30b258d..a193be28f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,12 +15,9 @@ ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \ CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS} RUN cargo build --release --bin openfang -FROM rust:1-slim-bookworm +FROM python:3.14-slim-bookworm RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ - python3 \ - python3-pip \ - python3-venv \ nodejs \ npm \ && rm -rf /var/lib/apt/lists/* diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 39fa42e2d..f8336ba59 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -49,8 +49,8 @@ use openfang_channels::discourse::DiscourseAdapter; use openfang_channels::gitter::GitterAdapter; use openfang_channels::gotify::GotifyAdapter; use openfang_channels::linkedin::LinkedInAdapter; -use openfang_channels::mumble::MumbleAdapter; use openfang_channels::mqtt::MqttAdapter; +use openfang_channels::mumble::MumbleAdapter; use openfang_channels::ntfy::NtfyAdapter; use openfang_channels::webhook::WebhookAdapter; use openfang_channels::wecom::WeComAdapter; diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index e547a896b..b3ce41dc7 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -579,7 +579,8 @@ pub async fn get_agent_session( msg.get_mut("tools").and_then(|v| v.as_array_mut()) { if let Some(tool_obj) = tools_arr.get_mut(tool_idx) { - tool_obj["result"] = serde_json::Value::String(result.clone()); + tool_obj["result"] = + serde_json::Value::String(result.clone()); tool_obj["is_error"] = serde_json::Value::Bool(*is_error); } diff --git a/crates/openfang-channels/src/lib.rs b/crates/openfang-channels/src/lib.rs index 949d39350..7b122d2a2 100644 --- a/crates/openfang-channels/src/lib.rs +++ b/crates/openfang-channels/src/lib.rs @@ -48,8 +48,8 @@ pub mod discourse; pub mod gitter; pub mod gotify; pub mod linkedin; -pub mod mumble; pub mod mqtt; +pub mod mumble; pub mod ntfy; pub mod webhook; pub mod wecom; diff --git a/crates/openfang-channels/src/line.rs b/crates/openfang-channels/src/line.rs index b20294afc..fa3ecd80c 100644 --- a/crates/openfang-channels/src/line.rs +++ b/crates/openfang-channels/src/line.rs @@ -108,7 +108,7 @@ impl LineAdapter { diff |= a ^ b; } if diff != 0 { - let computed = base64::engine::general_purpose::STANDARD.encode(&result); + let computed = base64::engine::general_purpose::STANDARD.encode(result); // Log first/last 4 chars of each signature for debugging without leaking full HMAC let comp_redacted = format!( "{}...{}", @@ -381,8 +381,7 @@ impl ChannelAdapter for LineAdapter { axum::routing::post({ let secret = Arc::clone(&channel_secret); let tx = Arc::clone(&tx); - move |headers: axum::http::HeaderMap, - body: axum::body::Bytes| { + move |headers: axum::http::HeaderMap, body: axum::body::Bytes| { let secret = Arc::clone(&secret); let tx = Arc::clone(&tx); async move { @@ -404,8 +403,7 @@ impl ChannelAdapter for LineAdapter { shutdown_rx: watch::channel(false).1, }; - if !signature.is_empty() - && !adapter.verify_signature(&body, signature) + if !signature.is_empty() && !adapter.verify_signature(&body, signature) { warn!("LINE: invalid webhook signature"); return axum::http::StatusCode::UNAUTHORIZED; diff --git a/crates/openfang-channels/src/mqtt.rs b/crates/openfang-channels/src/mqtt.rs index 69bb6349b..a3e5b1549 100644 --- a/crates/openfang-channels/src/mqtt.rs +++ b/crates/openfang-channels/src/mqtt.rs @@ -152,7 +152,10 @@ impl MqttAdapter { } /// Parse host:port string. - fn parse_host_port(s: &str, default_port: u16) -> Result<(String, u16), Box> { + fn parse_host_port( + s: &str, + default_port: u16, + ) -> Result<(String, u16), Box> { let s = s.trim(); if let Some(colon_pos) = s.rfind(':') { let host = s[..colon_pos].to_string(); @@ -239,7 +242,8 @@ impl ChannelAdapter for MqttAdapter { async fn start( &self, - ) -> Result + Send>>, Box> { + ) -> Result + Send>>, Box> + { let options = self.build_mqtt_options()?; let (client, mut eventloop) = AsyncClient::new(options, 10); diff --git a/crates/openfang-kernel/src/heartbeat.rs b/crates/openfang-kernel/src/heartbeat.rs index ddfe0488d..00d41a1f5 100644 --- a/crates/openfang-kernel/src/heartbeat.rs +++ b/crates/openfang-kernel/src/heartbeat.rs @@ -149,12 +149,17 @@ pub fn check_agents(registry: &AgentRegistry, config: &HeartbeatConfig) -> Vec, ) -> KernelResult<()> { - let catalog_entry = self - .model_catalog - .read() - .ok() - .and_then(|catalog| { - // When the caller specifies a provider, use provider-aware lookup - // so we resolve the model on the correct provider — not a builtin - // from a different provider that happens to share the same name (#833). - if let Some(ep) = explicit_provider { - catalog.find_model_for_provider(model, ep).cloned() - } else { - catalog.find_model(model).cloned() - } - }); + let catalog_entry = self.model_catalog.read().ok().and_then(|catalog| { + // When the caller specifies a provider, use provider-aware lookup + // so we resolve the model on the correct provider — not a builtin + // from a different provider that happens to share the same name (#833). + if let Some(ep) = explicit_provider { + catalog.find_model_for_provider(model, ep).cloned() + } else { + catalog.find_model(model).cloned() + } + }); let provider = if let Some(ep) = explicit_provider { // User explicitly set the provider — use it as-is Some(ep.to_string()) @@ -3329,6 +3327,7 @@ impl OpenFangKernel { system_prompt: def.agent.system_prompt.clone(), api_key_env: def.agent.api_key_env.clone(), base_url: def.agent.base_url.clone(), + thinking: None, }, capabilities: ManifestCapabilities { tools: def.tools.clone(), @@ -3369,7 +3368,15 @@ impl OpenFangKernel { } else { None }, + // Do NOT set tool_allowlist here — capabilities.tools (line 3223) already + // provides the primary tool filter in available_tools() Step 1. + // Setting tool_allowlist to def.tools would cause Step 4 to strip out + // MCP tools that were correctly added in Step 3 via mcp_servers opt-in, + // because MCP tool names (mcp_github_*, etc.) are dynamic and not in + // the hand's static tool list. + tool_allowlist: Vec::new(), tool_blocklist: Vec::new(), + ptc_enabled: None, // Custom profile avoids ToolProfile-based expansion overriding the // explicit tool list. profile: if !def.tools.is_empty() { @@ -5173,9 +5180,20 @@ impl OpenFangKernel { .cloned() .collect() }; + // When an agent explicitly lists MCP servers via `mcp_servers`, + // include all tools from those servers without requiring each + // tool name in the declared tools list. MCP tool names are + // dynamic and change when the upstream server updates, so + // `mcp_servers` acts as the opt-in for the entire server's + // tool set. If the agent does NOT list mcp_servers (empty + // allowlist → all MCP tools are candidates), fall back to the + // declared-tools filter to avoid flooding the context. + let mcp_explicitly_opted_in = !mcp_allowlist.is_empty(); for t in mcp_candidates { - // If agent declares specific tools, only include matching MCP tools - if !tools_unrestricted && !declared_tools.iter().any(|d| d == &t.name) { + if !tools_unrestricted + && !mcp_explicitly_opted_in + && !declared_tools.iter().any(|d| d == &t.name) + { continue; } all_tools.push(t); @@ -5917,6 +5935,12 @@ impl KernelHandle for OpenFangKernel { OpenFangKernel::kill_agent(self, id).map_err(|e| format!("Kill failed: {e}")) } + fn touch_active(&self, agent_id: &str) { + if let Ok(id) = agent_id.parse::() { + let _ = self.registry.set_state(id, AgentState::Running); + } + } + fn memory_store(&self, key: &str, value: serde_json::Value) -> Result<(), String> { let agent_id = shared_memory_agent_id(); self.memory @@ -6631,6 +6655,7 @@ mod tests { exec_policy: None, tool_allowlist: vec![], tool_blocklist: vec![], + ptc_enabled: None, }; manifest.capabilities.tools = vec!["file_read".to_string(), "web_fetch".to_string()]; manifest.capabilities.agent_spawn = true; @@ -6668,6 +6693,7 @@ mod tests { exec_policy: None, tool_allowlist: vec![], tool_blocklist: vec![], + ptc_enabled: None, } } diff --git a/crates/openfang-kernel/src/registry.rs b/crates/openfang-kernel/src/registry.rs index 841085ad3..fbb947c0c 100644 --- a/crates/openfang-kernel/src/registry.rs +++ b/crates/openfang-kernel/src/registry.rs @@ -395,6 +395,7 @@ mod tests { exec_policy: None, tool_allowlist: vec![], tool_blocklist: vec![], + ptc_enabled: None, }, state: AgentState::Created, mode: AgentMode::default(), diff --git a/crates/openfang-kernel/src/wizard.rs b/crates/openfang-kernel/src/wizard.rs index ad6dafe84..2efb8debe 100644 --- a/crates/openfang-kernel/src/wizard.rs +++ b/crates/openfang-kernel/src/wizard.rs @@ -163,6 +163,7 @@ impl SetupWizard { system_prompt, api_key_env: None, base_url: None, + thinking: None, }, resources: ResourceQuota::default(), priority: Priority::default(), @@ -182,6 +183,7 @@ impl SetupWizard { exec_policy: None, tool_allowlist: vec![], tool_blocklist: vec![], + ptc_enabled: None, }; let skills_to_install: Vec = intent diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index f773def41..064e6b20a 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -102,6 +102,39 @@ fn append_tool_error_guidance(tool_result_blocks: &mut Vec) { } } +/// System prompt supplement appended when Programmatic Tool Calling (PTC) is enabled. +const PTC_SYSTEM_PROMPT_SUPPLEMENT: &str = "\n\n\ +## Programmatic Tool Calling (execute_code)\n\n\ +You have access to an `execute_code` tool that runs Python code with tool functions.\n\ +Use `execute_code` whenever you need to:\n\ +- Read or edit multiple files (loop instead of N separate calls)\n\ +- Search and filter results, then print only relevant data\n\ +- Perform any workflow with 2+ tool calls where intermediate data can be filtered\n\ +- Batch operations (create tasks, check endpoints, process items)\n\n\ +**How it works:** Tool functions are plain synchronous Python functions (no async/await).\n\ +Call them directly: `result = file_read(path=\"src/main.ts\")`.\n\ +Tool results go to your code, NOT your context window. Only `print()` output enters\n\ +your context. This dramatically reduces context usage.\n\n\ +**Important rules:**\n\ +- All tool functions are **synchronous** — call them directly, no `await`, no `asyncio`.\n\ +- `print()` is the ONLY way to return data to your context. Slice large output: `print(result[:2000])`\n\ +- Always use try/except for error handling.\n\ +- Some params are renamed to avoid Python reserved words: `type` -> `type_`, `class` -> `class_`, `from` -> `from_`\n\ +- All tool functions return `str`. Parse JSON results with `json.loads(result)` if needed.\n\n\ +**Example — reading and filtering files:**\n\ +```python\n\ +import json\n\ +try:\n\ + files = [\"src/main.ts\", \"src/config.ts\", \"src/utils.ts\"]\n\ + for f in files:\n\ + content = file_read(path=f)\n\ + if \"TODO\" in content:\n\ + print(f\"Found TODO in {f}\")\n\ + print(content[:500])\n\ +except Exception as e:\n\ + print(f\"Error: {e}\")\n\ +```\n"; + /// Strip a provider prefix from a model ID before sending to the API. /// /// Many models are stored as `provider/org/model` (e.g. `openrouter/google/gemini-2.5-flash`) @@ -326,6 +359,39 @@ pub async fn run_agent_loop( let mut total_usage = TokenUsage::default(); let final_response; + // ── Programmatic Tool Calling (PTC) ───────────────────────────────── + // If PTC is enabled, replace the tool list with: direct tools + execute_code. + // PTC tools get compact Python function signatures instead of full JSON schemas. + let ptc_global_enabled = manifest.ptc_enabled.unwrap_or(false); + let ptc_config = crate::ptc::PtcConfig::default(); + + let mut ptc_instance: Option = + if ptc_global_enabled && !available_tools.is_empty() { + match crate::ptc::init_ptc(available_tools).await { + Ok(instance) => Some(instance), + Err(e) => { + warn!("PTC initialization failed, falling back to direct tools: {e}"); + None + } + } + } else { + None + }; + + // If PTC is active, swap the tool list: direct tools + execute_code + let ptc_tools_vec: Vec; + let available_tools = if let Some(ref ptc) = ptc_instance { + ptc_tools_vec = ptc.agent_tools(); + &ptc_tools_vec[..] + } else { + available_tools + }; + + // Append PTC system prompt supplement if PTC is active + if ptc_instance.is_some() { + system_prompt.push_str(PTC_SYSTEM_PROMPT_SUPPLEMENT); + } + // Safety valve: trim excessively long message histories to prevent context overflow. // The full compaction system handles sophisticated summarization, but this prevents // the catastrophic case where 200+ messages cause instant context overflow. @@ -396,7 +462,7 @@ pub async fn run_agent_loop( max_tokens: manifest.model.max_tokens, temperature: manifest.model.temperature, system: Some(system_prompt.clone()), - thinking: None, + thinking: manifest.model.thinking.clone(), }; // Notify phase: Thinking @@ -663,9 +729,18 @@ pub async fn run_agent_loop( content: MessageContent::Blocks(assistant_blocks), }); - // Build allowed tool names list for capability enforcement - let allowed_tool_names: Vec = + // Build allowed tool names list for capability enforcement. + // When PTC is active, include all PTC tools too (they're called + // from the IPC server, not directly by the LLM). + let mut allowed_tool_names: Vec = available_tools.iter().map(|t| t.name.clone()).collect(); + if let Some(ref ptc) = ptc_instance { + for t in &ptc.ptc_tools { + if !allowed_tool_names.contains(&t.name) { + allowed_tool_names.push(t.name.clone()); + } + } + } let caller_id_str = session.agent_id.to_string(); // Execute each tool call with loop guard, timeout, and truncation @@ -752,48 +827,131 @@ pub async fn run_agent_loop( let effective_exec_policy = manifest.exec_policy.as_ref(); // Timeout-wrapped execution - let timeout = tool_timeout_for(&tool_call.name); - let timeout_secs = timeout.as_secs(); - let result = match tokio::time::timeout( - timeout, - tool_runner::execute_tool( - &tool_call.id, - &tool_call.name, - &tool_call.input, - kernel.as_ref(), - Some(&allowed_tool_names), - Some(&caller_id_str), - skill_registry, - mcp_connections, - web_ctx, - browser_ctx, - if hand_allowed_env.is_empty() { - None - } else { - Some(&hand_allowed_env) - }, - workspace_root, - media_engine, - effective_exec_policy, - tts_engine, - docker_config, - process_manager, - ), - ) - .await + // PTC interception: if this is execute_code and PTC is active, + // run Python and concurrently dispatch tool calls from the IPC channel. + let result = if let (true, Some(ptc)) = + (tool_call.name == "execute_code", ptc_instance.as_mut()) { - Ok(result) => result, - Err(_) => { - warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs); - openfang_types::tool::ToolResult { + let code = tool_call.input["code"].as_str().unwrap_or(""); + let ptc_timeout = tool_call.input["timeout"] + .as_u64() + .unwrap_or(ptc_config.timeout_secs) + .clamp(10, 600); + + // Generate SDK and run Python + let sdk = + crate::ptc::generate_python_sdk(&ptc.ptc_tools, ptc.ipc_server.port()); + let full_script = crate::ptc::wrap_user_code(&sdk, code); + + // Spawn the Python subprocess as a future + let ws = workspace_root.map(|p| p.to_path_buf()); + let ptc_env = hand_allowed_env.clone(); + let mut python_fut = tokio::spawn(async move { + crate::ptc::execute_python( + &full_script, + ptc_timeout, + ws.as_deref(), + &ptc_env, + ) + .await + }); + + // Concurrently handle IPC tool requests while Python runs. + // JoinHandle is Unpin so we can select! on &mut directly. + let python_result: Option = loop { + tokio::select! { + // Python finished + py_result = &mut python_fut => { + break py_result.ok(); + } + // IPC tool request from Python + Some(req) = ptc.ipc_server.request_rx.recv() => { + let eff_exec_policy = manifest.exec_policy.as_ref(); + let tool_result = tool_runner::execute_tool( + &req.tool_call_id, + &req.tool_name, + &req.input, + kernel.as_ref(), + Some(&allowed_tool_names), + Some(&caller_id_str), + skill_registry, + mcp_connections, + web_ctx, + browser_ctx, + if hand_allowed_env.is_empty() { + None + } else { + Some(&hand_allowed_env) + }, + workspace_root, + media_engine, + eff_exec_policy, + tts_engine, + docker_config, + process_manager, + ) + .await; + let _ = req.response_tx.send(tool_result); + } + } + }; + + match python_result { + Some(py) => ptc_python_result_to_tool_result( + py, + &tool_call.id, + ptc_config.max_stdout_bytes, + ), + None => openfang_types::tool::ToolResult { tool_use_id: tool_call.id.clone(), - content: format!( - "Tool '{}' timed out after {}s.", - tool_call.name, timeout_secs - ), + content: "execute_code: Python subprocess failed".to_string(), is_error: true, - } + }, } + } else { + let timeout = tool_timeout_for(&tool_call.name); + let timeout_secs = timeout.as_secs(); + match tokio::time::timeout( + timeout, + tool_runner::execute_tool( + &tool_call.id, + &tool_call.name, + &tool_call.input, + kernel.as_ref(), + Some(&allowed_tool_names), + Some(&caller_id_str), + skill_registry, + mcp_connections, + web_ctx, + browser_ctx, + if hand_allowed_env.is_empty() { + None + } else { + Some(&hand_allowed_env) + }, + workspace_root, + media_engine, + effective_exec_policy, + tts_engine, + docker_config, + process_manager, + ), + ) + .await + { + Ok(result) => result, + Err(_) => { + warn!(tool = %tool_call.name, "Tool execution timed out after {}s", timeout_secs); + openfang_types::tool::ToolResult { + tool_use_id: tool_call.id.clone(), + content: format!( + "Tool '{}' timed out after {}s.", + tool_call.name, timeout_secs + ), + is_error: true, + } + } + } // end else (non-execute_code tool dispatch) }; // Fire AfterToolCall hook @@ -1490,6 +1648,37 @@ pub async fn run_agent_loop_streaming( let mut total_usage = TokenUsage::default(); let final_response; + // ── Programmatic Tool Calling (PTC) — streaming ───────────────────── + let ptc_global_enabled = manifest.ptc_enabled.unwrap_or(false); + let ptc_config = crate::ptc::PtcConfig::default(); + + let mut ptc_instance: Option = if ptc_global_enabled + && !available_tools.is_empty() + { + match crate::ptc::init_ptc(available_tools).await { + Ok(instance) => Some(instance), + Err(e) => { + warn!("PTC initialization failed (streaming), falling back to direct tools: {e}"); + None + } + } + } else { + None + }; + + let ptc_tools_vec: Vec; + let available_tools = if let Some(ref ptc) = ptc_instance { + ptc_tools_vec = ptc.agent_tools(); + &ptc_tools_vec[..] + } else { + available_tools + }; + + // Append PTC system prompt supplement if PTC is active (streaming) + if ptc_instance.is_some() { + system_prompt.push_str(PTC_SYSTEM_PROMPT_SUPPLEMENT); + } + // Safety valve: trim excessively long message histories to prevent context overflow. if messages.len() > MAX_HISTORY_MESSAGES { let trim_count = messages.len() - MAX_HISTORY_MESSAGES; @@ -1576,7 +1765,7 @@ pub async fn run_agent_loop_streaming( max_tokens: manifest.model.max_tokens, temperature: manifest.model.temperature, system: Some(system_prompt.clone()), - thinking: None, + thinking: manifest.model.thinking.clone(), }; // Notify phase: on first iteration emit Streaming; on subsequent @@ -1818,8 +2007,16 @@ pub async fn run_agent_loop_streaming( content: MessageContent::Blocks(assistant_blocks), }); - let allowed_tool_names: Vec = + // Include PTC tools in allowed names (they're callable from IPC, not the LLM) + let mut allowed_tool_names: Vec = available_tools.iter().map(|t| t.name.clone()).collect(); + if let Some(ref ptc) = ptc_instance { + for t in &ptc.ptc_tools { + if !allowed_tool_names.contains(&t.name) { + allowed_tool_names.push(t.name.clone()); + } + } + } let caller_id_str = session.agent_id.to_string(); // Execute each tool call with loop guard, timeout, and truncation @@ -1905,48 +2102,124 @@ pub async fn run_agent_loop_streaming( let effective_exec_policy = manifest.exec_policy.as_ref(); // Timeout-wrapped execution - let timeout = tool_timeout_for(&tool_call.name); - let timeout_secs = timeout.as_secs(); - let result = match tokio::time::timeout( - timeout, - tool_runner::execute_tool( - &tool_call.id, - &tool_call.name, - &tool_call.input, - kernel.as_ref(), - Some(&allowed_tool_names), - Some(&caller_id_str), - skill_registry, - mcp_connections, - web_ctx, - browser_ctx, - if hand_allowed_env.is_empty() { - None - } else { - Some(&hand_allowed_env) - }, - workspace_root, - media_engine, - effective_exec_policy, - tts_engine, - docker_config, - process_manager, - ), - ) - .await + // PTC interception (streaming): same as non-streaming path. + let result = if let (true, Some(ptc)) = + (tool_call.name == "execute_code", ptc_instance.as_mut()) { - Ok(result) => result, - Err(_) => { - warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs); - openfang_types::tool::ToolResult { + let code = tool_call.input["code"].as_str().unwrap_or(""); + let ptc_timeout = tool_call.input["timeout"] + .as_u64() + .unwrap_or(ptc_config.timeout_secs) + .clamp(10, 600); + + let sdk = + crate::ptc::generate_python_sdk(&ptc.ptc_tools, ptc.ipc_server.port()); + let full_script = crate::ptc::wrap_user_code(&sdk, code); + + let ws = workspace_root.map(|p| p.to_path_buf()); + let ptc_env = hand_allowed_env.clone(); + let mut python_fut = tokio::spawn(async move { + crate::ptc::execute_python( + &full_script, + ptc_timeout, + ws.as_deref(), + &ptc_env, + ) + .await + }); + + let python_result: Option = loop { + tokio::select! { + py_result = &mut python_fut => { + break py_result.ok(); + } + Some(req) = ptc.ipc_server.request_rx.recv() => { + // Touch heartbeat: prove agent is alive during PTC execution + if let Some(ref kh) = kernel { + kh.touch_active(&caller_id_str); + } + let eff_exec_policy = manifest.exec_policy.as_ref(); + let tool_result = tool_runner::execute_tool( + &req.tool_call_id, + &req.tool_name, + &req.input, + kernel.as_ref(), + Some(&allowed_tool_names), + Some(&caller_id_str), + skill_registry, + mcp_connections, + web_ctx, + browser_ctx, + if hand_allowed_env.is_empty() { None } else { Some(&hand_allowed_env) }, + workspace_root, + media_engine, + eff_exec_policy, + tts_engine, + docker_config, + process_manager, + ) + .await; + let _ = req.response_tx.send(tool_result); + } + } + }; + + match python_result { + Some(py) => ptc_python_result_to_tool_result( + py, + &tool_call.id, + ptc_config.max_stdout_bytes, + ), + None => openfang_types::tool::ToolResult { tool_use_id: tool_call.id.clone(), - content: format!( - "Tool '{}' timed out after {}s.", - tool_call.name, timeout_secs - ), + content: "execute_code: Python subprocess failed".to_string(), is_error: true, - } + }, } + } else { + let timeout = tool_timeout_for(&tool_call.name); + let timeout_secs = timeout.as_secs(); + match tokio::time::timeout( + timeout, + tool_runner::execute_tool( + &tool_call.id, + &tool_call.name, + &tool_call.input, + kernel.as_ref(), + Some(&allowed_tool_names), + Some(&caller_id_str), + skill_registry, + mcp_connections, + web_ctx, + browser_ctx, + if hand_allowed_env.is_empty() { + None + } else { + Some(&hand_allowed_env) + }, + workspace_root, + media_engine, + effective_exec_policy, + tts_engine, + docker_config, + process_manager, + ), + ) + .await + { + Ok(result) => result, + Err(_) => { + warn!(tool = %tool_call.name, "Tool execution timed out after {}s (streaming)", timeout_secs); + openfang_types::tool::ToolResult { + tool_use_id: tool_call.id.clone(), + content: format!( + "Tool '{}' timed out after {}s.", + tool_call.name, timeout_secs + ), + is_error: true, + } + } + } // end else (non-execute_code tool dispatch, streaming) }; // Fire AfterToolCall hook @@ -2141,6 +2414,43 @@ pub async fn run_agent_loop_streaming( /// 12. `tool_name\n{"key":"value"}` — bare name + JSON on next line (Llama 4 Scout) /// 13. `{"name":"tool","arguments":{...}}` — Llama 3.1+ variant /// +/// Build a ToolResult from a PythonResult, applying output truncation. +fn ptc_python_result_to_tool_result( + py: crate::ptc::executor::PythonResult, + tool_use_id: &str, + max_stdout_bytes: usize, +) -> openfang_types::tool::ToolResult { + let mut parts: Vec = Vec::new(); + if !py.stdout.trim().is_empty() { + let stdout = if py.stdout.len() > max_stdout_bytes { + format!( + "{}\n\n[output truncated at {} bytes]", + &py.stdout[..max_stdout_bytes], + max_stdout_bytes + ) + } else { + py.stdout.trim().to_string() + }; + parts.push(stdout); + } + if py.exit_code != 0 { + if !py.stderr.trim().is_empty() { + parts.push(format!("\n[stderr]\n{}", py.stderr.trim())); + } + parts.push(format!("\n[exit code: {}]", py.exit_code)); + } + let output = if parts.is_empty() { + "(no output)".to_string() + } else { + parts.join("\n") + }; + openfang_types::tool::ToolResult { + tool_use_id: tool_use_id.to_string(), + content: output, + is_error: py.exit_code != 0, + } +} + /// Validates tool names against available tools and returns synthetic `ToolCall` entries. fn recover_text_tool_calls(text: &str, available_tools: &[ToolDefinition]) -> Vec { let mut calls = Vec::new(); @@ -4458,7 +4768,9 @@ mod tests { context_window_tokens: 0, label: None, }; - let manifest = test_manifest(); + let mut manifest = test_manifest(); + // Disable PTC so the raw tool list (with web_search) is used for recovery + manifest.ptc_enabled = Some(false); let driver: Arc = Arc::new(TextToolCallDriver::new()); // Provide web_search as an available tool so recovery can match it @@ -4586,7 +4898,9 @@ mod tests { context_window_tokens: 0, label: None, }; - let manifest = test_manifest(); + let mut manifest = test_manifest(); + // Disable PTC so the raw tool list (with web_search) is used for recovery + manifest.ptc_enabled = Some(false); let driver: Arc = Arc::new(TextToolCallDriver::new()); let tools = vec![ToolDefinition { diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index 05f75f952..3186e4f4a 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -1478,7 +1478,10 @@ mod tests { Message::assistant("Done reading."), ]; let adjusted = adjust_split_for_tool_pairs(&messages, 2); - assert_eq!(adjusted, 1, "Should pull back split to keep ToolUse + ToolResult together"); + assert_eq!( + adjusted, 1, + "Should pull back split to keep ToolUse + ToolResult together" + ); } #[test] @@ -1489,7 +1492,10 @@ mod tests { Message::user("c"), ]; let adjusted = adjust_split_for_tool_pairs(&messages, 1); - assert_eq!(adjusted, 1, "Should not change split for plain text messages"); + assert_eq!( + adjusted, 1, + "Should not change split for plain text messages" + ); } #[test] diff --git a/crates/openfang-runtime/src/context_overflow.rs b/crates/openfang-runtime/src/context_overflow.rs index 397bf9d4e..22d14642c 100644 --- a/crates/openfang-runtime/src/context_overflow.rs +++ b/crates/openfang-runtime/src/context_overflow.rs @@ -32,10 +32,14 @@ fn safe_drain_boundary(messages: &[Message], mut boundary: usize) -> usize { // is in the last drained message (boundary - 1). Pull boundary back by 1. if messages[boundary].role == Role::User { if let MessageContent::Blocks(blocks) = &messages[boundary].content { - let has_tool_result = blocks.iter().any(|b| matches!(b, ContentBlock::ToolResult { .. })); + let has_tool_result = blocks + .iter() + .any(|b| matches!(b, ContentBlock::ToolResult { .. })); if has_tool_result && boundary > 0 && messages[boundary - 1].role == Role::Assistant { if let MessageContent::Blocks(asst_blocks) = &messages[boundary - 1].content { - let has_tool_use = asst_blocks.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. })); + let has_tool_use = asst_blocks + .iter() + .any(|b| matches!(b, ContentBlock::ToolUse { .. })); if has_tool_use { boundary -= 1; debug!( @@ -135,7 +139,8 @@ pub fn recover_from_overflow( debug!( estimated_tokens = estimated, removing = remove, - "Stage 1: moderate trim to last {} messages", messages.len() - remove + "Stage 1: moderate trim to last {} messages", + messages.len() - remove ); messages.drain(..remove); // Re-check after trim @@ -156,7 +161,8 @@ pub fn recover_from_overflow( warn!( estimated_tokens = estimate_tokens(messages, system_prompt, tools), removing = remove, - "Stage 2: aggressive overflow compaction to last {} messages", messages.len() - remove + "Stage 2: aggressive overflow compaction to last {} messages", + messages.len() - remove ); let summary = Message::user(format!( "[System: {} earlier messages were removed due to context overflow. \ @@ -373,7 +379,10 @@ mod tests { ]; // Boundary 2 would cut between the assistant(ToolUse) at [1] and user(ToolResult) at [2]. let adjusted = safe_drain_boundary(&msgs, 2); - assert_eq!(adjusted, 1, "Should pull boundary back to keep the ToolUse/ToolResult pair together"); + assert_eq!( + adjusted, 1, + "Should pull boundary back to keep the ToolUse/ToolResult pair together" + ); } #[test] @@ -385,7 +394,10 @@ mod tests { Message::assistant("d"), ]; let adjusted = safe_drain_boundary(&msgs, 2); - assert_eq!(adjusted, 2, "Should not change boundary for plain text messages"); + assert_eq!( + adjusted, 2, + "Should not change boundary for plain text messages" + ); } #[test] diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index e3e1b7633..4f09d7407 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -244,6 +244,13 @@ pub trait KernelHandle: Send + Sync { let _ = agent_id; } + /// Touch the agent's `last_active` timestamp to signal the heartbeat monitor + /// that the agent is alive. Called during long-running PTC tool execution so + /// the agent isn't incorrectly marked as unresponsive. + fn touch_active(&self, _agent_id: &str) { + // Default: no-op. The kernel overrides this to update the registry. + } + /// Spawn an agent with capability inheritance enforcement. /// `parent_caps` are the parent's granted capabilities. The kernel MUST verify /// that every capability in the child manifest is covered by `parent_caps`. diff --git a/crates/openfang-runtime/src/lib.rs b/crates/openfang-runtime/src/lib.rs index 9e88fe8b9..83561e6b5 100644 --- a/crates/openfang-runtime/src/lib.rs +++ b/crates/openfang-runtime/src/lib.rs @@ -37,6 +37,7 @@ pub mod model_catalog; pub mod process_manager; pub mod prompt_builder; pub mod provider_health; +pub mod ptc; pub mod python_runtime; pub mod reply_directives; pub mod retry; diff --git a/crates/openfang-runtime/src/prompt_builder.rs b/crates/openfang-runtime/src/prompt_builder.rs index fbe0bdbd3..a2410ffb2 100644 --- a/crates/openfang-runtime/src/prompt_builder.rs +++ b/crates/openfang-runtime/src/prompt_builder.rs @@ -59,6 +59,10 @@ pub struct PromptContext { pub sender_id: Option, /// Sender display name. pub sender_name: Option, + /// Whether Programmatic Tool Calling is enabled for this agent. + /// When true, the `## Your Tools` section is omitted — tool information + /// is provided via the `execute_code` tool description instead. + pub ptc_enabled: bool, } /// Build the complete system prompt from a `PromptContext`. @@ -77,8 +81,9 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String { sections.push(format!("## Current Date\nToday is {date}.")); } - // Section 2 — Tool Call Behavior (skip for subagents) - if !ctx.is_subagent { + // Section 2 — Tool Call Behavior (skip for subagents and PTC agents — + // PTC agents get their own behavioral guidance in the PTC supplement) + if !ctx.is_subagent && !ctx.ptc_enabled { sections.push(TOOL_CALL_BEHAVIOR.to_string()); } @@ -91,10 +96,13 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String { } } - // Section 3 — Available Tools (always present if tools exist) - let tools_section = build_tools_section(&ctx.granted_tools); - if !tools_section.is_empty() { - sections.push(tools_section); + // Section 3 — Available Tools (skip when PTC is enabled — tools are listed + // as Python function signatures in the execute_code tool description instead) + if !ctx.ptc_enabled { + let tools_section = build_tools_section(&ctx.granted_tools); + if !tools_section.is_empty() { + sections.push(tools_section); + } } // Section 4 — Memory Protocol (always present) @@ -109,8 +117,9 @@ pub fn build_system_prompt(ctx: &PromptContext) -> String { )); } - // Section 6 — MCP Servers (only if summary present) - if !ctx.mcp_summary.is_empty() { + // Section 6 — MCP Servers (skip when PTC is enabled — MCP tools are listed + // as Python functions in the execute_code tool description instead) + if !ctx.ptc_enabled && !ctx.mcp_summary.is_empty() { sections.push(build_mcp_section(&ctx.mcp_summary)); } diff --git a/crates/openfang-runtime/src/ptc/executor.rs b/crates/openfang-runtime/src/ptc/executor.rs new file mode 100644 index 000000000..3f101b4a6 --- /dev/null +++ b/crates/openfang-runtime/src/ptc/executor.rs @@ -0,0 +1,176 @@ +//! Python subprocess executor for Programmatic Tool Calling. +//! +//! Spawns `python3 -u -c