diff --git a/crates/ironclaw_engine/src/executor/loop_engine.rs b/crates/ironclaw_engine/src/executor/loop_engine.rs index 45b6ec48..985beb8f 100644 --- a/crates/ironclaw_engine/src/executor/loop_engine.rs +++ b/crates/ironclaw_engine/src/executor/loop_engine.rs @@ -97,6 +97,10 @@ impl ExecutionLoop { let max_nudges = self.thread.config.max_tool_intent_nudges; let nudge_enabled = self.thread.config.enable_tool_intent_nudge; let start_time = std::time::Instant::now(); + + // Persisted state across code steps — accumulates return values + // and tool results so the next step can access them via `state`. + let mut persisted_state = serde_json::json!({}); let mut nudge_count: u32 = 0; let mut consecutive_errors: u32 = 0; let mut compaction_count: u32 = 0; @@ -423,7 +427,7 @@ impl ExecutionLoop { step_id: step.id, }; - // Execute via Monty + // Execute via Monty with persisted state from prior steps let code_result = crate::executor::scripting::execute_code( &code, &self.thread, @@ -433,6 +437,7 @@ impl ExecutionLoop { &self.policy, &exec_ctx, &[], + &persisted_state, ) .await?; @@ -484,6 +489,17 @@ impl ExecutionLoop { step.action_results = code_result.action_results; + // Accumulate state for next step: return value + tool results. + // This makes variables "persist" across code steps via `state`. + if code_result.return_value != serde_json::Value::Null { + persisted_state[format!("step_{}_return", self.thread.step_count)] = + code_result.return_value.clone(); + persisted_state["last_return"] = code_result.return_value.clone(); + } + for result in &step.action_results { + persisted_state[&result.action_name] = result.output.clone(); + } + // Build comprehensive output for the LLM to see what happened. // Include stdout, tool results, and return value so the model // can reason about the outputs in the next iteration. diff --git a/crates/ironclaw_engine/src/executor/prompt.rs b/crates/ironclaw_engine/src/executor/prompt.rs index eab256e6..397a466e 100644 --- a/crates/ironclaw_engine/src/executor/prompt.rs +++ b/crates/ironclaw_engine/src/executor/prompt.rs @@ -61,7 +61,8 @@ You can write multiple code blocks across turns. Variables persist between block - `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 +- `state` — Dict of persisted data from previous steps. Contains tool results keyed by tool name (e.g. `state['web_search']`) and return values (`state['last_return']`, `state['step_0_return']`). Use this to access data from previous steps without re-calling tools. +- `previous_results` — Dict of prior tool call results (from ActionResult messages) ## Important rules diff --git a/crates/ironclaw_engine/src/executor/scripting.rs b/crates/ironclaw_engine/src/executor/scripting.rs index 34762a5b..32e9e5e6 100644 --- a/crates/ironclaw_engine/src/executor/scripting.rs +++ b/crates/ironclaw_engine/src/executor/scripting.rs @@ -151,7 +151,13 @@ pub fn build_orientation_preamble(thread: &Thread) -> String { // ── Context injection (RLM 3.4) ──────────────────────────── /// Build Monty input variables from thread state. -fn build_context_inputs(thread: &Thread) -> (Vec, Vec) { +/// +/// `persisted_state` carries variables from previous code steps so the +/// REPL feels persistent even though each step creates a fresh MontyRun. +fn build_context_inputs( + thread: &Thread, + persisted_state: &serde_json::Value, +) -> (Vec, Vec) { let mut names = Vec::new(); let mut values = Vec::new(); @@ -190,6 +196,12 @@ fn build_context_inputs(thread: &Thread) -> (Vec, Vec) { names.push("step_number".into()); values.push(MontyObject::Int(thread.step_count as i64)); + // `state` — persisted variables from previous code steps. + // This is a dict that accumulates: return values, tool results, etc. + // The model can read `state["results"]`, `state["prev_return"]`, etc. + names.push("state".into()); + values.push(json_to_monty(persisted_state)); + // `previous_results` — dict of {call_id: result_json} from prior steps let result_pairs: Vec<(MontyObject, MontyObject)> = thread .messages @@ -226,6 +238,7 @@ pub async fn execute_code( policy: &PolicyEngine, context: &ThreadExecutionContext, capability_policies: &[crate::types::capability::PolicyRule], + persisted_state: &serde_json::Value, ) -> Result { let mut stdout = String::new(); let mut action_results = Vec::new(); @@ -234,8 +247,8 @@ pub async fn execute_code( let mut final_answer: Option = None; let mut had_error = false; - // Build context variables (RLM 3.4) - let (input_names, input_values) = build_context_inputs(thread); + // Build context variables including persisted state from prior steps + let (input_names, input_values) = build_context_inputs(thread, persisted_state); // Parse and compile (wrap in catch_unwind — Monty 0.0.x can panic) let runner = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {