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) <[email protected]>
This commit is contained in:
2026-03-22 12:38:07 -07:00
co-authored by Claude Opus 4.6
parent 4e8b94a555
commit 749c208b3c
4 changed files with 135 additions and 12 deletions
@@ -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());
@@ -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;
@@ -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(&params.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.";
+36 -2
View File
@@ -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<String> {
// 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
}