Skip to main content

areev_run/
executor.rs

1//! Host tool execution (§4's concurrency rule): a bounded thread pool runs
2//! effects in parallel; ALL store writes stay on the driver thread. The
3//! executor is a host-supplied trait — Areev never executes anything
4//! itself (Tool grains are data; execution is the host's job), it hands the
5//! host a name, a pinned definition hash, the input, and the deterministic
6//! idempotency key the crash-redelivery contract depends on (§5.3).
7
8use areev_run_core::{EffectOutcome, FailCause, JournalKey, NodeExecutor};
9use serde_json::Value;
10use std::sync::mpsc;
11use std::sync::Arc;
12
13/// What a host tool run produced. The driver turns this into an
14/// [`EffectOutcome`] with deterministic storage accounting.
15pub enum ExecResult {
16    Ok(Value),
17    Err { cause: FailCause, detail: String },
18}
19
20/// The host's executor. `idempotency_key` is stable across crash
21/// redeliveries of the same attempt — an executor that performs external
22/// side effects should deduplicate on it; one that cannot should be run
23/// with `on_dangling = fail`.
24pub trait HostToolExecutor: Send + Sync {
25    fn execute(
26        &self,
27        tool_name: &str,
28        tool_hash: &str,
29        input: &Value,
30        idempotency_key: &str,
31    ) -> ExecResult;
32}
33
34/// A registry-backed executor: tools dispatch by name; unknown names fail
35/// with `ExecutorError` (never a panic — a plan naming a tool the host
36/// didn't register is the host's configuration error, reported per attempt).
37#[derive(Default)]
38pub struct ExecutorRegistry {
39    tools: std::collections::BTreeMap<String, Box<dyn HostToolExecutor>>,
40}
41
42impl ExecutorRegistry {
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    pub fn register(&mut self, tool_name: &str, exec: Box<dyn HostToolExecutor>) {
48        self.tools.insert(tool_name.to_string(), exec);
49    }
50}
51
52impl HostToolExecutor for ExecutorRegistry {
53    fn execute(
54        &self,
55        tool_name: &str,
56        tool_hash: &str,
57        input: &Value,
58        idempotency_key: &str,
59    ) -> ExecResult {
60        match self.tools.get(tool_name) {
61            Some(t) => t.execute(tool_name, tool_hash, input, idempotency_key),
62            None => ExecResult::Err {
63                cause: FailCause::ExecutorError,
64                detail: format!("no executor registered for tool '{tool_name}'"),
65            },
66        }
67    }
68}
69
70/// A subprocess executor — the CLI's zero-dependency host-tool seam, the
71/// same pattern as `--embed-cmd` / `--llm-cmd`: the command receives the
72/// input JSON on stdin and `AREEV_TOOL_NAME` / `AREEV_TOOL_HASH` /
73/// `AREEV_IDEMPOTENCY_KEY` in the environment, and prints the result JSON on
74/// stdout. Non-zero exit or unparseable output is an `ExecutorError`
75/// (retryable per the §6.3 table). The subprocess NEVER opens the memory
76/// file — the parent journals on its behalf (§4's single-writer rule, and
77/// the process-wide open-path registry would refuse it anyway).
78pub struct CommandExecutor {
79    cmd: String,
80}
81
82impl CommandExecutor {
83    pub fn new(cmd: &str) -> Self {
84        CommandExecutor { cmd: cmd.to_string() }
85    }
86}
87
88impl HostToolExecutor for CommandExecutor {
89    fn execute(
90        &self,
91        tool_name: &str,
92        tool_hash: &str,
93        input: &Value,
94        idempotency_key: &str,
95    ) -> ExecResult {
96        use std::io::Write;
97        use std::process::{Command, Stdio};
98        // The platform shell: /bin/sh -c on unix, cmd /C on Windows. The
99        // Windows command string must go through raw_arg — Command::arg
100        // MSVC-quotes embedded quotes, which cmd.exe does not parse.
101        #[cfg(not(windows))]
102        let mut shell = Command::new("/bin/sh");
103        #[cfg(not(windows))]
104        shell.arg("-c").arg(&self.cmd);
105        #[cfg(windows)]
106        let mut shell = Command::new("cmd");
107        #[cfg(windows)]
108        {
109            use std::os::windows::process::CommandExt;
110            shell.raw_arg("/C").raw_arg(&self.cmd);
111        }
112        let mut child = match shell
113            .env("AREEV_TOOL_NAME", tool_name)
114            .env("AREEV_TOOL_HASH", tool_hash)
115            .env("AREEV_IDEMPOTENCY_KEY", idempotency_key)
116            .stdin(Stdio::piped())
117            .stdout(Stdio::piped())
118            .stderr(Stdio::piped())
119            .spawn()
120        {
121            Ok(c) => c,
122            Err(e) => {
123                return ExecResult::Err {
124                    cause: FailCause::ExecutorError,
125                    detail: format!("spawn '{}': {e}", self.cmd),
126                }
127            }
128        };
129        if let Some(stdin) = child.stdin.as_mut() {
130            let _ = stdin.write_all(input.to_string().as_bytes());
131        }
132        drop(child.stdin.take());
133        let out = match child.wait_with_output() {
134            Ok(o) => o,
135            Err(e) => {
136                return ExecResult::Err {
137                    cause: FailCause::ExecutorError,
138                    detail: format!("wait: {e}"),
139                }
140            }
141        };
142        if !out.status.success() {
143            return ExecResult::Err {
144                cause: FailCause::ExecutorError,
145                detail: format!(
146                    "tool command exited {}: {}",
147                    out.status,
148                    String::from_utf8_lossy(&out.stderr).trim()
149                ),
150            };
151        }
152        match serde_json::from_slice::<Value>(&out.stdout) {
153            Ok(v) => ExecResult::Ok(v),
154            Err(e) => ExecResult::Err {
155                cause: FailCause::ExecutorError,
156                detail: format!("tool output is not JSON: {e}"),
157            },
158        }
159    }
160}
161
162/// A §6.10 TokenChunk sink: text deltas stream into it as they arrive.
163pub type TokenSink = Box<dyn FnMut(&str) + Send>;
164
165/// A prepared LLM request, built ON THE DRIVER THREAD (the pool never
166/// touches the store): the transcript translated to seam messages plus the
167/// pinned tool Definitions already fetched.
168pub struct PreparedLlm {
169    pub messages: Vec<areev_llm::ChatMessage>,
170    pub tools: Vec<areev_core::types::Tool>,
171    pub max_tokens: u32,
172    /// §6.10 TokenChunk sink. None (no observer) uses the plain
173    /// non-streaming call. Purely observational: the journaled result is
174    /// the same either way.
175    pub on_token: Option<TokenSink>,
176}
177
178/// One in-flight dispatch handed to the pool.
179pub struct DispatchJob {
180    pub key: JournalKey,
181    pub executor: NodeExecutor,
182    pub input: Value,
183    pub idempotency_key: String,
184    /// Present exactly when this is an LLM effect of an abstract node.
185    pub llm: Option<PreparedLlm>,
186}
187
188/// A completed dispatch coming back to the driver thread. Carries the
189/// executor the job DISPATCHED under — a flow tool inside an abstract node
190/// runs as a Host tool while the node-level executor says Abstract, and the
191/// result grain must re-state what actually ran (§5.1).
192pub struct DispatchDone {
193    pub key: JournalKey,
194    pub executor: NodeExecutor,
195    pub outcome: EffectOutcome,
196}
197
198/// The bounded pool: N worker threads, jobs in, completions out. Workers
199/// touch NO store handle — parallelism lives here and only here. LLM
200/// effects route to the tool-calling seam; everything else to the host
201/// executor.
202pub struct Pool {
203    tx: mpsc::Sender<DispatchJob>,
204    pub done_rx: mpsc::Receiver<DispatchDone>,
205    _workers: Vec<std::thread::JoinHandle<()>>,
206}
207
208impl Pool {
209    pub fn new(
210        executor: Arc<dyn HostToolExecutor>,
211        llm: Option<Arc<dyn areev_llm::ToolCallLlm>>,
212        workers: usize,
213    ) -> Self {
214        let (tx, rx) = mpsc::channel::<DispatchJob>();
215        let rx = Arc::new(std::sync::Mutex::new(rx));
216        let (done_tx, done_rx) = mpsc::channel::<DispatchDone>();
217        let mut handles = Vec::new();
218        for _ in 0..workers.max(1) {
219            let rx = Arc::clone(&rx);
220            let done_tx = done_tx.clone();
221            let executor = Arc::clone(&executor);
222            let llm = llm.clone();
223            handles.push(std::thread::spawn(move || loop {
224                let job = {
225                    let guard = rx.lock().expect("pool queue");
226                    guard.recv()
227                };
228                let Ok(job) = job else { return };
229                if let Some(prepared) = job.llm {
230                    // Same catch_unwind rule as the tool branch below: a
231                    // panicking provider (SSE parser, token sink) must be a
232                    // Failed effect, never a dead worker holding the pool
233                    // half-alive.
234                    let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
235                        || run_llm_effect(llm.as_deref(), prepared),
236                    ))
237                    .unwrap_or_else(|p| {
238                        let msg = p
239                            .downcast_ref::<&str>()
240                            .map(|s| s.to_string())
241                            .or_else(|| p.downcast_ref::<String>().cloned())
242                            .unwrap_or_else(|| "llm executor panicked".into());
243                        EffectOutcome::Failed {
244                            cause: FailCause::ExecutorError,
245                            detail: format!("llm panicked: {msg}"),
246                            journal_bytes: 0,
247                        }
248                    });
249                    let done = DispatchDone { key: job.key, executor: job.executor, outcome };
250                    if done_tx.send(done).is_err() {
251                        return;
252                    }
253                    continue;
254                }
255                let (tool_name, tool_hash) = match &job.executor {
256                    NodeExecutor::Host { tool_name, tool_hash }
257                    | NodeExecutor::Client { tool_name, tool_hash } => {
258                        (tool_name.clone(), tool_hash.clone())
259                    }
260                    NodeExecutor::Subgraph { workflow_hash } => {
261                        // Subgraphs never reach the pool — the driver runs
262                        // the child inline (it needs the store). A job here
263                        // is a driver bug, reported as a failed effect.
264                        ("mg:subgraph".to_string(), workflow_hash.clone())
265                    }
266                    NodeExecutor::Abstract { .. } => {
267                        ("mg:llm".to_string(), String::new())
268                    }
269                };
270                // A panicking executor is a FAILED EFFECT, never a dead
271                // worker: a worker that dies would leave the pool half-alive
272                // and the driver blocked on `done_rx.recv()` forever (the
273                // idle workers keep the channel open) — the hang the first
274                // integration run of this crate actually produced. The
275                // panic message survives in the failure detail; retryability
276                // follows the normal §6.3 table (ExecutorError → retryable).
277                let executed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
278                    executor.execute(&tool_name, &tool_hash, &job.input, &job.idempotency_key)
279                }))
280                .unwrap_or_else(|p| {
281                    let msg = p
282                        .downcast_ref::<&str>()
283                        .map(|s| s.to_string())
284                        .or_else(|| p.downcast_ref::<String>().cloned())
285                        .unwrap_or_else(|| "executor panicked".into());
286                    ExecResult::Err {
287                        cause: FailCause::ExecutorError,
288                        detail: format!("executor panicked: {msg}"),
289                    }
290                });
291                let outcome = match executed {
292                    ExecResult::Ok(result) => {
293                        let bytes = crate::journal::outcome_journal_bytes(
294                            &EffectOutcome::Completed {
295                                result: result.clone(),
296                                journal_bytes: 0,
297                                input_tokens: 0,
298                                output_tokens: 0,
299                                usd_micros: 0,
300                            },
301                        );
302                        EffectOutcome::Completed {
303                            result,
304                            journal_bytes: bytes,
305                            input_tokens: 0,
306                            output_tokens: 0,
307                            usd_micros: 0,
308                        }
309                    }
310                    ExecResult::Err { cause, detail } => {
311                        let bytes = detail.len() as u64;
312                        EffectOutcome::Failed { cause, detail, journal_bytes: bytes }
313                    }
314                };
315                let done = DispatchDone { key: job.key, executor: job.executor, outcome };
316                if done_tx.send(done).is_err() {
317                    return;
318                }
319            }));
320        }
321        Pool { tx, done_rx, _workers: handles }
322    }
323
324    pub fn submit(&self, job: DispatchJob) {
325        let _ = self.tx.send(job);
326    }
327}
328
329/// Execute one LLM turn through the tool-calling seam, converting the
330/// response into the journal-shaped outcome the scheduler consumes
331/// (`{"text", "tool_calls", "stop_reason"}` + usage figures).
332fn run_llm_effect(
333    llm: Option<&dyn areev_llm::ToolCallLlm>,
334    prepared: crate::executor::PreparedLlm,
335) -> EffectOutcome {
336    let Some(llm) = llm else {
337        return EffectOutcome::Failed {
338            cause: FailCause::Unknown,
339            detail: "no ToolCallLlm configured (RUN-E006 should have caught this at load)"
340                .into(),
341            journal_bytes: 0,
342        };
343    };
344    let request = areev_llm::ToolCallRequest {
345        system: Some(
346            "You are executing one node of a governed workflow. Use the \
347             offered tools when needed; when done, answer with the node's \
348             final result (a JSON object when structured output is wanted)."
349                .into(),
350        ),
351        messages: prepared.messages,
352        tools: &prepared.tools,
353        tool_choice: areev_llm::ToolChoice::Auto,
354        max_tokens: prepared.max_tokens,
355        temperature: 0.0,
356    };
357    let result = match prepared.on_token {
358        Some(mut sink) => llm.call_streaming(&request, &mut *sink),
359        None => llm.call(&request),
360    };
361    match result {
362        Ok(resp) => {
363            let result = serde_json::json!({
364                "text": resp.text,
365                "tool_calls": resp.tool_calls.iter().map(|c| serde_json::json!({
366                    "id": c.id,
367                    "name": c.name,
368                    // Unparseable provider arguments surface raw so the
369                    // scheduler's re-prompt path sees the garbage (§6.11).
370                    "arguments": c.arguments_raw.clone()
371                        .map(Value::String)
372                        .unwrap_or_else(|| c.arguments.clone()),
373                })).collect::<Vec<_>>(),
374                "stop_reason": match resp.stop_reason {
375                    areev_llm::StopReason::EndTurn => "end_turn",
376                    areev_llm::StopReason::ToolUse => "tool_use",
377                    areev_llm::StopReason::MaxTokens => "max_tokens",
378                    areev_llm::StopReason::Other(_) => "other",
379                },
380            });
381            let bytes = crate::journal::outcome_journal_bytes(&EffectOutcome::Completed {
382                result: result.clone(),
383                journal_bytes: 0,
384                input_tokens: 0,
385                output_tokens: 0,
386                usd_micros: 0,
387            });
388            EffectOutcome::Completed {
389                result,
390                journal_bytes: bytes,
391                input_tokens: resp.usage.input_tokens,
392                output_tokens: resp.usage.output_tokens,
393                usd_micros: 0,
394            }
395        }
396        Err(e) => EffectOutcome::Failed {
397            // §6.3 mapping: retryable transport/5xx → ExecutorError (the
398            // scheduler retries the TURN); terminal → Unknown (never
399            // retried).
400            cause: if e.retryable { FailCause::ExecutorError } else { FailCause::Unknown },
401            journal_bytes: e.message.len() as u64,
402            detail: e.message,
403        },
404    }
405}