Add the orchestrator infrastructure for replacing the Rust execution loop with versioned Python code. This commit adds the module and host functions without switching over — the existing Rust loop is unchanged. New files: - orchestrator/default.py: v0 Python orchestrator (run_loop + helpers) - executor/orchestrator.rs: host function dispatch, orchestrator loading from Store with version selection, OrchestratorResult parsing Host functions exposed to orchestrator Python via Monty suspension: __llm_complete__, __execute_code_step__ (nested Monty VM), __execute_action__, __check_signals__, __emit_event__, __add_message__, __save_checkpoint__, __transition_to__, __retrieve_docs__, __check_budget__, __get_actions__ Also makes json_to_monty, monty_to_json, monty_to_string pub(crate) in scripting.rs for cross-module use. Design doc: docs/plans/2026-03-25-python-orchestrator.md Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
18 KiB
Python Orchestrator: Move the Engine Loop to CodeAct
Date: 2026-03-25 Status: Design Context: The engine's Rust loop has frequent bugs in the glue layer (tool dispatch, output formatting, state management, truncation). The LLM can't fix Rust at runtime. Moving the loop to Python via CodeAct makes the orchestration layer self-modifiable by the self-improvement Mission.
Architecture
Before (current)
ExecutionLoop::run() [Rust, 900 lines]
├── Build system prompt
├── for iteration in 0..max:
│ ├── Check signals
│ ├── Check budgets
│ ├── Build context (messages + actions)
│ ├── Call LLM
│ ├── Match response:
│ │ ├── Text → extract FINAL(), check nudge
│ │ ├── ActionCalls → execute_action_calls()
│ │ └── Code → execute_code() via Monty
│ ├── Format output metadata
│ ├── Update persisted state
│ └── Persist checkpoint
└── Return ThreadOutcome
After (proposed)
ExecutionLoop::run() [Rust, ~50 lines — bootstrap only]
├── Load orchestrator code from Store (versioned MemoryDoc)
├── If missing, use compiled-in default
├── Set up Monty VM with host functions
├── Execute orchestrator Python code
└── Return ThreadOutcome from Python's return value
Host functions [Rust, exposed to Python via Monty suspension]:
├── llm_complete(messages, actions, config) → response
├── execute_action(name, params) → result (lease + policy + safety)
├── check_signals() → signal or None
├── save_checkpoint(state) → persist thread/step/events
├── emit_event(kind) → broadcast + record
├── transition_to(state, reason) → validated state change
├── retrieve_docs(goal, max) → memory docs
├── get_actions() → available ActionDefs
└── check_budget() → remaining tokens/time/usd
Orchestrator [Python, versioned, self-modifiable]:
└── run_loop(context, goal, actions, state, config) → outcome
├── Tool dispatch + name resolution
├── Output formatting + truncation
├── State management (persisted_state dict)
├── FINAL() extraction
├── Tool intent nudge detection
├── Context compaction decisions
└── The step loop itself
Versioned Orchestrator Code
The orchestrator Python source is stored as a MemoryDoc:
DocType: Note
Title: "orchestrator:main"
Tag: "orchestrator_code"
Content: <Python source code>
Metadata: {
"version": 3,
"parent_version": 2,
"source_thread_id": "...", // which self-improvement thread created this
"created_at": "2026-03-25T10:00:00Z"
}
Version lifecycle
v0 (compiled-in) → v1 (self-improvement fix) → v2 (another fix) → ...
↑
auto-rollback if v2 causes
3 consecutive thread failures
Operations
- Load: Query Store for
orchestrator:maindocs, pick highest version - Update: Self-improvement Mission saves a new version with
parent_versionpointing to current - Rollback: On consecutive failures, load the
parent_versiondoc instead - Reset: Delete all runtime versions, fall back to compiled-in v0
Auto-rollback logic
Tracked per-version in mission metadata or thread config:
# Pseudo-logic in the bootstrap (Rust side)
consecutive_failures = count_recent_failures(orchestrator_version)
if consecutive_failures >= 3:
orchestrator = load_version(parent_version)
emit_event(SelfImprovementRollback { from: current, to: parent })
Host Functions
These replace direct Rust calls with Python-callable suspension points, using the same mechanism Monty already uses for tool calls.
llm_complete(messages, actions=None, config=None)
# Python side
response = llm_complete(
messages=[{"role": "user", "content": "search for AI news"}],
actions=get_actions(),
config={"force_text": False}
)
# response = {"type": "text", "content": "..."}
# | {"type": "actions", "calls": [...]}
# | {"type": "code", "code": "..."}
# Also: response["usage"] = {"input_tokens": N, "output_tokens": M}
Rust side: calls LlmBackend::complete(), converts LlmOutput to JSON dict.
execute_action(name, params)
result = execute_action("web_search", {"query": "AI news", "count": 5})
# result = {"output": {...}, "is_error": false, "duration_ms": 123}
# Includes: lease check, policy evaluation, safety sanitization, hooks
Rust side: full EffectExecutor::execute_action() pipeline with all v1 security controls.
check_signals()
signal = check_signals()
# signal = None | "stop" | {"inject": "new message"} | "suspend"
Rust side: signal_rx.try_recv() on the tokio channel.
save_checkpoint(state, step=None)
save_checkpoint(state={"last_return": result, "web_search": data})
Rust side: serializes to thread metadata, optionally saves Step + events to Store.
emit_event(kind, **kwargs)
emit_event("action_executed", action_name="web_search", duration_ms=123)
emit_event("step_completed", tokens={"input": 500, "output": 200})
Rust side: constructs EventKind variant, broadcasts + records.
transition_to(state, reason=None)
transition_to("completed", reason="FINAL() called")
# Raises error if transition is invalid (state machine enforcement stays in Rust)
retrieve_docs(goal, max_docs=5)
docs = retrieve_docs("search for AI news", max_docs=5)
# docs = [{"type": "LESSON", "title": "...", "content": "..."}, ...]
check_budget()
budget = check_budget()
# budget = {"tokens_remaining": 50000, "time_remaining_ms": 25000, "usd_remaining": 0.45}
get_actions()
actions = get_actions()
# actions = [{"name": "web_search", "description": "...", "params": {...}}, ...]
Default Orchestrator (v0)
The compiled-in Python code that ships with the binary. This is what include_str! loads as the seed version. It replicates the current Rust loop logic:
def run_loop(context, goal, actions, state, config):
"""Engine v2 orchestrator — the self-modifiable execution loop."""
max_iterations = config.get("max_iterations", 30)
max_nudges = config.get("max_tool_intent_nudges", 2)
nudge_count = 0
consecutive_errors = 0
for step in range(max_iterations):
# 1. Check signals
signal = check_signals()
if signal == "stop":
transition_to("completed", "stopped by signal")
return {"type": "stopped"}
if signal and "inject" in signal:
context.append({"role": "user", "content": signal["inject"]})
# 2. Check budget
budget = check_budget()
if budget["tokens_remaining"] <= 0:
transition_to("completed", "token budget exhausted")
return {"type": "completed", "response": "Token budget exhausted."}
# 3. Build messages for LLM
messages = list(context) # copy
# 4. Inject prior knowledge on first step
if step == 0:
docs = retrieve_docs(goal)
if docs:
knowledge = format_docs(docs)
if messages and messages[0]["role"] == "system":
messages[0]["content"] += "\n\n" + knowledge
# 5. Call LLM
emit_event("step_started")
response = llm_complete(messages, actions)
emit_event("step_completed", tokens=response["usage"])
# 6. Handle response
if response["type"] == "text":
text = response["content"]
# Check for FINAL()
final = extract_final(text)
if final is not None:
context.append({"role": "assistant", "content": text})
transition_to("completed", "FINAL() called")
return {"type": "completed", "response": final}
# Check for tool intent nudge
if nudge_count < max_nudges and signals_tool_intent(text):
nudge_count += 1
context.append({"role": "assistant", "content": text})
context.append({"role": "user", "content":
"You described what you'd do but didn't write code. "
"Please write a ```repl code block to execute your plan."})
continue
# Plain text response — done
context.append({"role": "assistant", "content": text})
transition_to("completed", "text response")
return {"type": "completed", "response": text}
elif response["type"] == "code":
code = response["code"]
nudge_count = 0
context.append({"role": "assistant", "content": f"```repl\n{code}\n```"})
# Code is executed by the Monty VM outside this function.
# We receive results via state dict after execution.
# The host handles code execution and resumes us with results.
result = execute_code_step(code, state)
# Update state with results
state[f"step_{step}_return"] = result.get("return_value")
state["last_return"] = result.get("return_value")
for r in result.get("action_results", []):
state[r["action_name"]] = r["output"]
# Format output for next iteration
output = format_output(result)
context.append({"role": "user", "content": output})
# Check for FINAL() in code output
if result.get("final_answer") is not None:
transition_to("completed", "FINAL() in code")
return {"type": "completed", "response": result["final_answer"]}
# Track errors
if result.get("had_error"):
consecutive_errors += 1
if consecutive_errors >= 5:
transition_to("failed", "too many consecutive errors")
return {"type": "failed", "error": "5 consecutive code errors"}
else:
consecutive_errors = 0
save_checkpoint(state)
elif response["type"] == "actions":
# Tier 0: structured tool calls
nudge_count = 0
results = []
for call in response["calls"]:
r = execute_action(call["name"], call.get("params", {}))
results.append(r)
if r.get("need_approval"):
save_checkpoint(state)
return {"type": "need_approval",
"action_name": call["name"],
"call_id": call.get("call_id", ""),
"parameters": call.get("params", {})}
# Add results to context
for r in results:
context.append({"role": "tool", "content": format_action_result(r)})
save_checkpoint(state)
# Max iterations reached
transition_to("completed", "max iterations")
return {"type": "max_iterations"}
# ── Helper functions (the self-modifiable glue) ──────────────
def extract_final(text):
"""Extract FINAL() content from text. Returns None if not found."""
idx = text.find("FINAL(")
if idx < 0:
return None
after = text[idx + 6:]
# Handle triple-quoted strings
if after.startswith('"""'):
end = after.find('"""', 3)
if end >= 0:
return after[3:end]
# Handle quoted strings
if after.startswith('"') or after.startswith("'"):
quote = after[0]
end = after.find(quote, 1)
if end >= 0:
return after[1:end]
# Handle balanced parens
depth = 1
for i, ch in enumerate(after):
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
if depth == 0:
return after[:i]
return None
def signals_tool_intent(text):
"""Check if text describes tool usage without actually using tools."""
lower = text.lower()
intent_phrases = ["i will", "i'll", "let me", "i would", "i should",
"i can", "i need to", "we should", "we can"]
tool_phrases = ["search", "fetch", "call", "run", "execute", "use the"]
has_intent = any(p in lower for p in intent_phrases)
has_tool = any(p in lower for p in tool_phrases)
return has_intent and has_tool
def format_output(result, max_chars=8000):
"""Format code execution result for the next LLM context message."""
parts = []
stdout = result.get("stdout", "")
if stdout:
parts.append(f"[stdout]\n{stdout}")
for r in result.get("action_results", []):
name = r.get("action_name", "?")
output = str(r.get("output", ""))
if r.get("is_error"):
parts.append(f"[{name} ERROR] {output}")
else:
preview = output[:500] + "..." if len(output) > 500 else output
parts.append(f"[{name}] {preview}")
ret = result.get("return_value")
if ret is not None:
parts.append(f"[return] {ret}")
text = "\n\n".join(parts)
# Truncate from the front (keep the tail, which has the most recent results)
if len(text) > max_chars:
text = "... (truncated) ...\n" + text[-max_chars:]
return text
def format_docs(docs):
"""Format memory docs for context injection."""
parts = ["## Prior Knowledge (from completed threads)\n"]
for doc in docs:
label = doc["type"].upper()
content = doc["content"][:500]
truncated = "..." if len(doc["content"]) > 500 else ""
parts.append(f"### [{label}] {doc['title']}\n{content}{truncated}\n")
return "\n".join(parts)
def format_action_result(result):
"""Format a single action result for the LLM context."""
name = result.get("action_name", "unknown")
output = result.get("output", {})
if result.get("is_error"):
return f"Tool '{name}' failed: {output}"
return str(output)
Implementation Steps
Step 1: Expose host functions in scripting.rs
Add new FunctionCall handlers alongside the existing tool dispatch:
__llm_complete__→ callsLlmBackend::complete()__check_signals__→ callssignal_rx.try_recv()__save_checkpoint__→ persists thread state__emit_event__→ broadcasts event__transition_to__→ validates + transitions thread state__retrieve_docs__→ queries RetrievalEngine__check_budget__→ reads remaining tokens/time/usd__get_actions__→ enumerates available ActionDefs from leases
These use __dunder__ names to avoid collision with user tools.
Step 2: Create the bootstrap in loop_engine.rs
Replace ExecutionLoop::run() body with:
- Load orchestrator code from Store (
orchestrator:mainMemoryDoc, highest version) - If no runtime version, use
include_str!("../../orchestrator/default.py") - Inject context variables:
context,goal,actions,state,config - Execute via Monty with the orchestrator code
- Parse the return value as
ThreadOutcome - Handle auto-rollback if execution fails
Step 3: Write the default orchestrator
Create crates/ironclaw_engine/orchestrator/default.py with the v0 code shown above.
Step 4: Wire versioning into the self-improvement Mission
Update the Mission goal prompt to include:
- How to read the current orchestrator:
memory_search("orchestrator:main") - How to update it:
memory_writewith title="orchestrator:main", tag="orchestrator_code", metadata with version++ - The constraint: changes must be minimal, one fix at a time
Step 5: Add auto-rollback
In the bootstrap (Step 2), after orchestrator execution fails:
- Increment a failure counter in thread metadata
- If counter >= 3, load
parent_versioninstead - Emit
SelfImprovementRollbackevent - Reset failure counter
Step 6: Add execute_code_step host function
This is the interesting one — the orchestrator needs to run user Python code (the CodeAct step). Two options:
Option A: Nested Monty execution — The orchestrator Python calls execute_code_step(code, state) which suspends to Rust, Rust creates a nested Monty VM for the user code, runs it with tool dispatch, returns results. Clean but complex.
Option B: Host-managed code execution — The orchestrator returns a {"type": "execute_code", "code": "...", "state": {...}} action, Rust runs the code in the existing Monty pipeline, then re-enters the orchestrator with results. Simpler but requires the orchestrator to yield/resume.
Recommend Option A for clean separation. The orchestrator is a management layer; user code runs in a sandboxed sub-VM.
What This Enables
- Self-improvement Mission fixes glue bugs at runtime — no Rust rebuild
- Format_output bug? Mission patches
format_output()in the orchestrator - Tool name mismatch? Mission adds an alias in the orchestrator's dispatch
- State persistence bug? Mission fixes
save_checkpoint()call - New feature? Mission adds a new helper function
- Bad fix? Auto-rollback to previous version after 3 failures
The Rust layer becomes an OS kernel — stable, provides capabilities. The Python orchestrator is userspace — where iteration happens fast.
Safety
| Concern | Mitigation |
|---|---|
| Orchestrator loops forever | Rust-enforced timeout (existing 30s per code step, plus thread-level budget) |
| Orchestrator skips safety checks | execute_action() enforces lease + policy in Rust regardless |
Orchestrator calls transition_to("failed") inappropriately |
State machine validation stays in Rust |
| Bad version breaks all threads | Auto-rollback after 3 consecutive failures |
| Orchestrator tries to escape sandbox | Monty blocks OS calls, network, filesystem |
| Self-improvement Mission writes bad code | Versioning allows instant rollback; compiled v0 always available |
Migration Path
- Phase 1: Add host functions, keep Rust loop as-is. Test that Python can call
llm_complete()etc. - Phase 2: Write default orchestrator in Python. Run it alongside Rust loop, compare outcomes.
- Phase 3: Switch to Python orchestrator as primary. Remove Rust loop code.
- Phase 4: Wire versioning + self-improvement Mission + auto-rollback.