From 749c208b3cfd1fc7c5c4f8b33692444c9a72a6df Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Sun, 22 Mar 2026 12:38:07 -0700 Subject: [PATCH] feat(engine): enable CodeAct/RLM mode with code block detection The engine now operates in CodeAct/RLM mode: System prompt (executor/prompt.rs): - Instructs LLM to write Python in ```repl fenced blocks - Documents available tools as callable Python functions - Documents llm_query(), llm_query_batched(), FINAL() - Documents context variables (context, goal, step_number, previous_results) - Strategy guidance: examine context, break into steps, use tools, call FINAL() Code block detection (bridge/llm_adapter.rs): - extract_code_block() scans LLM text responses for ```repl or ```python blocks - When detected, returns LlmResponse::Code instead of LlmResponse::Text - The ExecutionLoop routes Code responses through Monty for execution No structured tool definitions sent to LLM: - Tools are described in the system prompt as Python functions - The LLM call sends empty actions array, forcing text-mode responses - This ensures the LLM writes code blocks (CodeAct) instead of structured tool calls (which would bypass the REPL) 85 tests passing, zero clippy warnings. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/executor/loop_engine.rs | 23 ++--- crates/ironclaw_engine/src/executor/mod.rs | 1 + crates/ironclaw_engine/src/executor/prompt.rs | 85 +++++++++++++++++++ src/bridge/llm_adapter.rs | 38 ++++++++- 4 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 crates/ironclaw_engine/src/executor/prompt.rs diff --git a/crates/ironclaw_engine/src/executor/loop_engine.rs b/crates/ironclaw_engine/src/executor/loop_engine.rs index d1942068..f413d483 100644 --- a/crates/ironclaw_engine/src/executor/loop_engine.rs +++ b/crates/ironclaw_engine/src/executor/loop_engine.rs @@ -61,15 +61,14 @@ impl ExecutionLoop { // Transition to Running self.thread.transition_to(ThreadState::Running, None)?; - // Inject system prompt if none exists + // Inject CodeAct/RLM system prompt if none exists if !self.thread.messages.iter().any(|m| m.role == crate::types::message::MessageRole::System) { - self.thread.messages.insert( - 0, - ThreadMessage::system( - "You are a helpful assistant. Use the available tools to accomplish the user's request. \ - Respond concisely.", - ), - ); + // Get available actions for the prompt + let active_leases = self.leases.active_for_thread(self.thread.id).await; + let actions = self.effects.available_actions(&active_leases).await + .unwrap_or_default(); + let system_prompt = crate::executor::prompt::build_codeact_system_prompt(&actions); + self.thread.messages.insert(0, ThreadMessage::system(system_prompt)); } let max_iterations = self.thread.config.max_iterations; @@ -163,7 +162,7 @@ impl ExecutionLoop { let active_leases = self.leases.active_for_thread(self.thread.id).await; // 5. Build context - let (messages, actions) = + let (messages, _actions) = build_step_context(&self.thread.messages, &active_leases, &self.effects).await?; // 6. Create step @@ -174,6 +173,10 @@ impl ExecutionLoop { }); // 7. Call LLM + // CodeAct/RLM: send NO structured tool definitions — tools are described + // in the system prompt as Python functions. The LLM produces text with + // ```repl code blocks that the bridge detects and converts to LlmResponse::Code. + // This avoids the LLM using structured tool calls instead of writing code. let force_text = iteration >= max_iterations.saturating_sub(1); let config = LlmCallConfig { force_text, @@ -181,7 +184,7 @@ impl ExecutionLoop { ..LlmCallConfig::default() }; - let llm_output = self.llm.complete(&messages, &actions, &config).await?; + let llm_output = self.llm.complete(&messages, &[], &config).await?; step.tokens_used = llm_output.usage; self.thread.total_tokens_used += llm_output.usage.total(); step.llm_response = Some(llm_output.response.clone()); diff --git a/crates/ironclaw_engine/src/executor/mod.rs b/crates/ironclaw_engine/src/executor/mod.rs index 6f9592b6..77ac8350 100644 --- a/crates/ironclaw_engine/src/executor/mod.rs +++ b/crates/ironclaw_engine/src/executor/mod.rs @@ -9,6 +9,7 @@ pub mod compaction; pub mod context; pub mod intent; pub mod loop_engine; +pub mod prompt; pub mod scripting; pub mod structured; diff --git a/crates/ironclaw_engine/src/executor/prompt.rs b/crates/ironclaw_engine/src/executor/prompt.rs new file mode 100644 index 00000000..eab256e6 --- /dev/null +++ b/crates/ironclaw_engine/src/executor/prompt.rs @@ -0,0 +1,85 @@ +//! System prompt construction for the execution loop. +//! +//! Builds a CodeAct/RLM system prompt that instructs the LLM to write +//! Python code in ```repl blocks with tools available as callable functions. + +use crate::types::capability::ActionDef; + +/// Build the system prompt for CodeAct/RLM execution. +/// +/// The prompt instructs the LLM to: +/// - Write Python code in ```repl fenced blocks +/// - Call tools as regular Python functions +/// - Use llm_query(prompt, context) for sub-agent calls +/// - Use FINAL(answer) to return the final answer +/// - Access thread context via the `context` variable +pub fn build_codeact_system_prompt(actions: &[ActionDef]) -> String { + let mut prompt = String::from(CODEACT_PREAMBLE); + + // Add tool documentation + if !actions.is_empty() { + prompt.push_str("\n## Available tools (call as Python functions)\n\n"); + for action in actions { + prompt.push_str(&format!("- `{}(", action.name)); + // Extract parameter names from JSON schema + if let Some(props) = action.parameters_schema.get("properties") + && let Some(obj) = props.as_object() + { + let params: Vec<&str> = obj.keys().map(String::as_str).collect(); + prompt.push_str(¶ms.join(", ")); + } + prompt.push_str(&format!(")` — {}\n", action.description)); + } + } + + prompt.push_str(CODEACT_POSTAMBLE); + prompt +} + +const CODEACT_PREAMBLE: &str = "\ +You are an AI assistant with a Python REPL environment. You solve tasks by writing and executing Python code. + +## How to respond + +Write Python code inside ```repl fenced blocks. The code will be executed, and you'll see the output. + +```repl +result = web_fetch(url=\"https://api.example.com/data\") +print(result) +``` + +You can write multiple code blocks across turns. Variables persist between blocks within the same turn. + +## Special functions + +- `llm_query(prompt, context=None)` — Ask a sub-agent to analyze text or answer a question. Returns a string. Use for summarization, analysis, or any task that needs LLM reasoning on data. +- `llm_query_batched(prompts, context=None)` — Same but for multiple prompts in parallel. Returns a list of strings. +- `FINAL(answer)` — Call this when you have the final answer. The argument is returned to the user. + +## Context variables + +- `context` — List of prior conversation messages (each is a dict with 'role' and 'content') +- `goal` — The current task description +- `step_number` — Current execution step +- `previous_results` — Dict of prior tool call results + +## Important rules + +1. Always write code in ```repl blocks — plain text responses are for brief explanations only +2. When you have the final answer, call `FINAL(answer)` inside a code block +3. Tool results are returned as Python objects — use them directly, don't parse JSON +4. If a tool call fails, the error appears as a Python exception — handle it or try a different approach +5. For large data, process it in chunks using llm_query() on subsets rather than loading everything into context +6. Outputs are truncated to 8000 chars — use variables to store large intermediate results"; + +const CODEACT_POSTAMBLE: &str = " + +## Strategy + +1. First, examine the context and understand the task +2. Break complex tasks into steps +3. Use tools to gather information or take actions +4. Use llm_query() to analyze or summarize large text +5. Call FINAL() with the answer when done + +Think step by step. Execute code immediately — don't just describe what you would do."; diff --git a/src/bridge/llm_adapter.rs b/src/bridge/llm_adapter.rs index 8b17aedd..10eb7289 100644 --- a/src/bridge/llm_adapter.rs +++ b/src/bridge/llm_adapter.rs @@ -102,7 +102,7 @@ impl LlmBackend for LlmBridgeAdapter { reason: e.to_string(), })?; - // Convert response + // Convert response — check for code blocks (CodeAct/RLM pattern) let llm_response = if !response.tool_calls.is_empty() { LlmResponse::ActionCalls { calls: response @@ -117,7 +117,15 @@ impl LlmBackend for LlmBridgeAdapter { content: response.content.clone(), } } else { - LlmResponse::Text(response.content.unwrap_or_default()) + let text = response.content.unwrap_or_default(); + // Detect ```repl or ```python fenced code blocks + match extract_code_block(&text) { + Some(code) => LlmResponse::Code { + code, + content: Some(text), + }, + None => LlmResponse::Text(text), + } }; Ok(LlmOutput { @@ -181,3 +189,29 @@ fn action_def_to_tool_def(action: &ActionDef) -> ToolDefinition { parameters: action.parameters_schema.clone(), } } + +/// Extract Python code from ```repl or ```python fenced blocks. +/// +/// Matches the pattern used by RLM implementations (fast-rlm uses ```repl, +/// official RLM uses ```repl, some models output ```python). +fn extract_code_block(text: &str) -> Option { + // Try ```repl first (preferred), then ```python + for marker in ["```repl", "```python"] { + if let Some(start) = text.find(marker) { + let code_start = start + marker.len(); + // Skip to next line + let code_start = text[code_start..] + .find('\n') + .map(|i| code_start + i + 1) + .unwrap_or(code_start); + // Find closing ``` + if let Some(end) = text[code_start..].find("```") { + let code = text[code_start..code_start + end].trim(); + if !code.is_empty() { + return Some(code.to_string()); + } + } + } + } + None +}