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) <[email protected]>
This commit is contained in:
2026-03-22 23:16:41 -07:00
co-authored by Claude Opus 4.6
parent e8c0d3df52
commit d2d93f98fe
3 changed files with 35 additions and 5 deletions
@@ -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.
@@ -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
@@ -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<String>, Vec<MontyObject>) {
///
/// `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<String>, Vec<MontyObject>) {
let mut names = Vec::new();
let mut values = Vec::new();
@@ -190,6 +196,12 @@ fn build_context_inputs(thread: &Thread) -> (Vec<String>, Vec<MontyObject>) {
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<CodeExecutionResult, EngineError> {
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<String> = 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(|| {