From d2d93f98fe07be7a20b975cc508ead1425fb626c Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Sun, 22 Mar 2026 23:16:41 -0700 Subject: [PATCH] feat(engine): persist variables across code steps via `state` dict Monty creates a fresh runtime per code step, so variables are lost between steps. This caused the model to re-paste tool results from system messages, wasting tokens. Fix: maintain a `persisted_state` JSON dict in the ExecutionLoop that accumulates across steps: - Tool results stored by tool name: state["web_search"] = {results...} - Return values stored: state["last_return"], state["step_0_return"] - Injected as a `state` Python variable in each new MontyRun Now the model can do: Step 1: results = web_search(query="...") # tool result saved in state Step 2: data = state["web_search"] # access previous result summary = llm_query("summarize", str(data)) FINAL(summary) System prompt updated to document the `state` variable. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/executor/loop_engine.rs | 18 +++++++++++++++++- crates/ironclaw_engine/src/executor/prompt.rs | 3 ++- .../ironclaw_engine/src/executor/scripting.rs | 19 ++++++++++++++++--- 3 files changed, 35 insertions(+), 5 deletions(-) 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(|| {