1use areev_run_core::{EffectOutcome, FailCause, JournalKey, NodeExecutor};
9use serde_json::Value;
10use std::sync::mpsc;
11use std::sync::Arc;
12
13pub enum ExecResult {
16 Ok(Value),
17 Err { cause: FailCause, detail: String },
18}
19
20pub 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#[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
70pub 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 #[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
162pub type TokenSink = Box<dyn FnMut(&str) + Send>;
164
165pub struct PreparedLlm {
169 pub messages: Vec<areev_llm::ChatMessage>,
170 pub tools: Vec<areev_core::types::Tool>,
171 pub max_tokens: u32,
172 pub on_token: Option<TokenSink>,
176}
177
178pub struct DispatchJob {
180 pub key: JournalKey,
181 pub executor: NodeExecutor,
182 pub input: Value,
183 pub idempotency_key: String,
184 pub llm: Option<PreparedLlm>,
186}
187
188pub struct DispatchDone {
193 pub key: JournalKey,
194 pub executor: NodeExecutor,
195 pub outcome: EffectOutcome,
196}
197
198pub 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 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 ("mg:subgraph".to_string(), workflow_hash.clone())
265 }
266 NodeExecutor::Abstract { .. } => {
267 ("mg:llm".to_string(), String::new())
268 }
269 };
270 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
329fn 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 "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 cause: if e.retryable { FailCause::ExecutorError } else { FailCause::Unknown },
401 journal_bytes: e.message.len() as u64,
402 detail: e.message,
403 },
404 }
405}