1use crate::core::agent_context::AgentContextView;
54use crate::core::ctx::Ctx;
55use crate::core::engine::Engine;
56use crate::types::{CapToken, StepId, WorkerId};
57use crate::worker::adapter::{SpawnError, SpawnerAdapter, WorkerError, WorkerResult};
58use crate::worker::output::{ContentRef, OutputEvent};
59use crate::worker::{Worker, WorkerJoinHandler};
60use async_trait::async_trait;
61use mlua_swarm_schema::SubprocessOutput;
62use serde_json::Value;
63use std::collections::BTreeMap;
64use std::process::Stdio;
65use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
66use tokio::process::Command;
67use tokio::sync::oneshot;
68use tokio_util::sync::CancellationToken;
69
70pub const EMBED_PLACEHOLDERS: [&str; 8] = [
77 "system",
78 "system_file",
79 "prompt",
80 "model",
81 "tools_csv",
82 "work_dir",
83 "task_id",
84 "attempt",
85];
86
87#[derive(Debug, Clone, Default)]
98pub struct EmbedTemplate {
99 pub argv: Vec<String>,
102 pub stdin: Option<String>,
105 pub env: BTreeMap<String, String>,
108 pub cwd: Option<String>,
114 pub output: Option<SubprocessOutput>,
116 pub system_prompt: Option<String>,
120 pub model: Option<String>,
122 pub tools_csv: String,
125}
126
127#[derive(Debug, Clone)]
131pub enum StreamMode {
132 NdjsonLines,
134 SseEvents,
140 LengthPrefixed,
144}
145
146#[derive(Debug)]
150pub struct ProcessSpawner {
151 pub program: String,
154 pub args: Vec<String>,
156 pub use_stdin: bool,
160 pub stream_mode: Option<StreamMode>,
162 pub embed: Option<EmbedTemplate>,
166}
167
168impl ProcessSpawner {
169 pub fn new(program: impl Into<String>) -> Self {
172 Self {
173 program: program.into(),
174 args: Vec::new(),
175 use_stdin: true,
176 stream_mode: None,
177 embed: None,
178 }
179 }
180
181 pub fn arg(mut self, a: impl Into<String>) -> Self {
183 self.args.push(a.into());
184 self
185 }
186
187 pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
189 self.args.extend(args.into_iter().map(|a| a.into()));
190 self
191 }
192
193 pub fn use_stdin(mut self, v: bool) -> Self {
196 self.use_stdin = v;
197 self
198 }
199
200 pub fn stream_mode(mut self, mode: StreamMode) -> Self {
202 self.stream_mode = Some(mode);
203 self
204 }
205
206 pub fn plain(mut self) -> Self {
208 self.stream_mode = None;
209 self
210 }
211
212 pub fn ndjson(mut self, v: bool) -> Self {
216 self.stream_mode = if v {
217 Some(StreamMode::NdjsonLines)
218 } else {
219 None
220 };
221 self
222 }
223
224 pub fn run(cmd: impl Into<String>) -> Self {
226 Self {
227 program: "sh".into(),
228 args: vec!["-c".into(), cmd.into()],
229 use_stdin: true,
230 stream_mode: None,
231 embed: None,
232 }
233 }
234
235 pub fn cmd(program: impl Into<String>) -> Self {
238 Self {
239 program: program.into(),
240 args: Vec::new(),
241 use_stdin: true,
242 stream_mode: None,
243 embed: None,
244 }
245 }
246
247 pub fn embed(mut self, template: EmbedTemplate) -> Self {
249 self.embed = Some(template);
250 self
251 }
252}
253
254#[async_trait]
255impl SpawnerAdapter for ProcessSpawner {
256 async fn spawn(
257 &self,
258 engine: &Engine,
259 ctx: &Ctx,
260 task_id: StepId,
261 attempt: u32,
262 token: CapToken,
263 ) -> Result<Box<dyn Worker>, SpawnError> {
264 if let Some(embed) = &self.embed {
268 return self
269 .spawn_embed(embed, engine, ctx, task_id, attempt, token)
270 .await;
271 }
272 let directive = engine
280 .fetch_prompt(&token, &task_id)
281 .await
282 .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
283 let directive = crate::core::engine::render_directive_to_string(&directive);
286
287 let mut cmd = Command::new(&self.program);
288 cmd.args(&self.args)
289 .env("MSE_TOKEN_AGENT_ID", &token.agent_id)
290 .env("MSE_TOKEN_NONCE", &token.nonce)
291 .env("MSE_TASK_ID", task_id.as_str())
292 .env("MSE_ATTEMPT", attempt.to_string())
293 .env("MSE_CTX_AGENT", &ctx.agent)
294 .stdin(Stdio::piped())
295 .stdout(Stdio::piped())
296 .stderr(Stdio::piped());
297
298 if !self.use_stdin {
299 cmd.arg(&directive);
300 }
301
302 let mut child = cmd
303 .spawn()
304 .map_err(|e| SpawnError::Internal(format!("spawn failed: {e}")))?;
305
306 if self.use_stdin {
307 if let Some(mut stdin) = child.stdin.take() {
308 stdin
309 .write_all(directive.as_bytes())
310 .await
311 .map_err(|e| SpawnError::Internal(format!("stdin write: {e}")))?;
312 drop(stdin); }
314 }
315
316 let cancel = CancellationToken::new();
317 let cancel_inner = cancel.clone();
318 let worker_id = WorkerId::new();
319 tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (subprocess)");
321 let (tx, rx) = oneshot::channel();
322 let engine_for_emit = engine.clone();
326 let token_for_emit = token.clone();
327 let task_id_for_emit = task_id.clone();
328 let stream_mode = self.stream_mode.clone();
329
330 tokio::spawn(async move {
331 let result: Result<WorkerResult, WorkerError> = if let Some(mode) = stream_mode {
332 run_streaming_mode(
336 mode,
337 child,
338 &engine_for_emit,
339 &token_for_emit,
340 &task_id_for_emit,
341 attempt,
342 cancel_inner,
343 )
344 .await
345 } else {
346 let result = tokio::select! {
349 output = child.wait_with_output() => {
350 match output {
351 Ok(out) => {
352 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
353 let value: Value = serde_json::from_str(stdout.trim())
354 .unwrap_or_else(|_| serde_json::json!({
355 "raw": stdout.trim_end(),
356 "stderr": String::from_utf8_lossy(&out.stderr).to_string(),
357 }));
358 Ok(WorkerResult { value, ok: out.status.success() })
359 }
360 Err(e) => Err(WorkerError::Failed(format!("wait_with_output: {e}"))),
361 }
362 }
363 _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
364 };
365 if let Ok(wr) = &result {
366 let ev = OutputEvent::Final {
367 content: ContentRef::Inline {
368 value: wr.value.clone(),
369 },
370 ok: wr.ok,
371 };
372 let _ = engine_for_emit
373 .submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
374 .await;
375 }
376 result
377 };
378 let signal: Result<(), WorkerError> = result.map(|_| ());
380 let _ = tx.send(signal);
381 });
382
383 Ok(Box::new(ProcessWorker {
384 handler: WorkerJoinHandler {
385 worker_id,
386 cancel,
387 completion: rx,
388 },
389 }))
390 }
391}
392
393struct EmbedVars {
399 system: String,
400 system_file: Option<String>,
401 prompt: String,
402 model: Option<String>,
403 tools_csv: String,
404 work_dir: Option<String>,
405 task_id: String,
406 attempt: String,
407}
408
409impl EmbedVars {
410 fn lookup(&self, token: &str) -> Result<Option<&str>, SpawnError> {
415 let (value, cause): (Option<&str>, &str) = match token {
416 "system" => (Some(self.system.as_str()), ""),
417 "system_file" => (
418 self.system_file.as_deref(),
419 "no system prompt was baked for this attempt (agent has no profile.system_prompt?)",
420 ),
421 "prompt" => (Some(self.prompt.as_str()), ""),
422 "model" => (
423 self.model.as_deref(),
424 "no model declared (set profile.model or Runner::Subprocess overrides.model)",
425 ),
426 "tools_csv" => (Some(self.tools_csv.as_str()), ""),
427 "work_dir" => (
428 self.work_dir.as_deref(),
429 "no work_dir/project_root in the agent context view and no overrides.cwd",
430 ),
431 "task_id" => (Some(self.task_id.as_str()), ""),
432 "attempt" => (Some(self.attempt.as_str()), ""),
433 _ => return Ok(None),
434 };
435 match value {
436 Some(v) => Ok(Some(v)),
437 None => Err(SpawnError::Internal(format!(
438 "placeholder {{{token}}}: {cause}"
439 ))),
440 }
441 }
442
443 fn render(&self, tmpl: &str) -> Result<String, SpawnError> {
452 let mut out = String::with_capacity(tmpl.len());
453 let mut rest = tmpl;
454 while let Some(start) = rest.find('{') {
455 out.push_str(&rest[..start]);
456 let after = &rest[start + 1..];
457 let Some(end) = after.find('}') else {
458 out.push_str(&rest[start..]);
460 return Ok(out);
461 };
462 let token = &after[..end];
463 match self.lookup(token)? {
464 Some(value) => {
465 out.push_str(value);
466 rest = &after[end + 1..];
467 }
468 None => {
469 out.push('{');
474 rest = after;
475 }
476 }
477 }
478 out.push_str(rest);
479 Ok(out)
480 }
481}
482
483fn embed_references(embed: &EmbedTemplate, token: &str) -> bool {
485 embed.argv.iter().any(|a| a.contains(token))
486 || embed.stdin.as_deref().is_some_and(|s| s.contains(token))
487 || embed.env.values().any(|v| v.contains(token))
488 || embed.cwd.as_deref().is_some_and(|c| c.contains(token))
489}
490
491fn normalize_plain_output(
496 out: &std::process::Output,
497 decl: Option<&SubprocessOutput>,
498) -> WorkerResult {
499 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
500 let stderr = || String::from_utf8_lossy(&out.stderr).to_string();
501 let exit_ok = out.status.success();
502
503 let Some(decl) = decl else {
504 let value: Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|_| {
505 serde_json::json!({
506 "raw": stdout.trim_end(),
507 "stderr": stderr(),
508 })
509 });
510 return WorkerResult { value, ok: exit_ok };
511 };
512
513 let parsed: Result<Value, _> = serde_json::from_str(stdout.trim());
514 let parsed = match parsed {
515 Ok(v) => v,
516 Err(e) => {
517 if decl.format.as_deref() == Some("json") {
518 return WorkerResult {
521 value: serde_json::json!({
522 "raw": stdout.trim_end(),
523 "stderr": stderr(),
524 "parse_error": e.to_string(),
525 }),
526 ok: false,
527 };
528 }
529 return WorkerResult {
532 value: serde_json::json!({
533 "raw": stdout.trim_end(),
534 "stderr": stderr(),
535 }),
536 ok: exit_ok,
537 };
538 }
539 };
540
541 let value = match decl.result_ptr.as_deref() {
542 Some(ptr) => match parsed.pointer(ptr) {
543 Some(v) => v.clone(),
544 None => {
545 return WorkerResult {
546 value: serde_json::json!({
547 "error": format!("result_ptr '{ptr}' not found in stdout JSON"),
548 "raw": parsed,
549 "stderr": stderr(),
550 }),
551 ok: false,
552 }
553 }
554 },
555 None => parsed.clone(),
556 };
557
558 let ok = match decl.ok_from.as_deref() {
559 None | Some("exit_code") => exit_ok,
560 Some(ptr) => parsed.pointer(ptr) == Some(&Value::Bool(true)),
561 };
562
563 WorkerResult { value, ok }
564}
565
566impl ProcessSpawner {
567 async fn spawn_embed(
573 &self,
574 embed: &EmbedTemplate,
575 engine: &Engine,
576 ctx: &Ctx,
577 task_id: StepId,
578 attempt: u32,
579 token: CapToken,
580 ) -> Result<Box<dyn Worker>, SpawnError> {
581 let prompt_value = engine
585 .fetch_prompt(&token, &task_id)
586 .await
587 .map_err(|e| SpawnError::Internal(format!("fetch_prompt: {e}")))?;
588 let system = match embed.system_prompt.as_deref() {
589 Some(tmpl) => {
590 let slots = crate::operator::render::slots_from_prompt(&prompt_value);
591 let rendered = crate::operator::render::render_system(tmpl, &slots)
592 .map_err(|e| SpawnError::Internal(format!("render system_prompt: {e}")))?;
593 Some(rendered)
594 }
595 None => None,
596 };
597 engine
598 .bake_worker_system_prompt(&task_id, attempt, system.clone())
599 .await
600 .map_err(|e| SpawnError::Internal(format!("bake system_prompt: {e}")))?;
601
602 let payload = engine
608 .fetch_worker_payload(&token, &task_id)
609 .await
610 .map_err(|e| SpawnError::Internal(format!("fetch_worker_payload: {e}")))?;
611
612 let system_file = if embed_references(embed, "{system_file}") {
615 match engine
616 .materialize_system_file(&task_id, attempt)
617 .await
618 .map_err(|e| SpawnError::Internal(format!("materialize system_file: {e}")))?
619 {
620 Some(path) => Some(path.display().to_string()),
621 None => {
622 return Err(SpawnError::Internal(
623 "placeholder {system_file}: no system prompt was baked for this \
624 attempt (agent has no profile.system_prompt?)"
625 .into(),
626 ))
627 }
628 }
629 } else {
630 None
631 };
632
633 let view = AgentContextView::materialized_or_from_ctx(ctx);
636 let work_dir = view.work_dir.clone().or_else(|| view.project_root.clone());
637
638 let vars = EmbedVars {
639 system: system.unwrap_or_default(),
640 system_file,
641 prompt: payload.prompt.clone(),
642 model: embed.model.clone(),
643 tools_csv: embed.tools_csv.clone(),
644 work_dir,
645 task_id: task_id.to_string(),
646 attempt: attempt.to_string(),
647 };
648
649 if embed.argv.is_empty() {
651 return Err(SpawnError::Internal(
652 "embed template: argv must not be empty".into(),
653 ));
654 }
655 let mut rendered_argv = Vec::with_capacity(embed.argv.len());
656 for a in &embed.argv {
657 rendered_argv.push(vars.render(a)?);
658 }
659 let mut rendered_env = Vec::with_capacity(embed.env.len());
660 for (k, v) in &embed.env {
661 rendered_env.push((k.clone(), vars.render(v)?));
662 }
663 let rendered_cwd = match embed.cwd.as_deref() {
664 Some(c) => Some(vars.render(c)?),
665 None => None,
666 };
667 let rendered_stdin = match embed.stdin.as_deref() {
668 Some(s) => Some(vars.render(s)?),
669 None => None,
670 };
671
672 let mut cmd = Command::new(&rendered_argv[0]);
673 cmd.args(&rendered_argv[1..])
674 .env("MSE_TOKEN_AGENT_ID", &token.agent_id)
675 .env("MSE_TOKEN_NONCE", &token.nonce)
676 .env("MSE_TASK_ID", task_id.as_str())
677 .env("MSE_ATTEMPT", attempt.to_string())
678 .env("MSE_CTX_AGENT", &ctx.agent)
679 .stdin(Stdio::piped())
680 .stdout(Stdio::piped())
681 .stderr(Stdio::piped());
682 for (k, v) in &rendered_env {
683 cmd.env(k, v);
684 }
685 if let Some(cwd) = &rendered_cwd {
686 cmd.current_dir(cwd);
687 }
688
689 let mut child = cmd
690 .spawn()
691 .map_err(|e| SpawnError::Internal(format!("spawn failed: {e}")))?;
692
693 if let Some(stdin_body) = rendered_stdin {
694 if let Some(mut stdin) = child.stdin.take() {
695 stdin
696 .write_all(stdin_body.as_bytes())
697 .await
698 .map_err(|e| SpawnError::Internal(format!("stdin write: {e}")))?;
699 drop(stdin); }
701 } else {
702 drop(child.stdin.take());
704 }
705
706 let cancel = CancellationToken::new();
707 let cancel_inner = cancel.clone();
708 let worker_id = WorkerId::new();
709 tracing::debug!(worker_id = %worker_id, step_id = %task_id, "worker spawned (subprocess embed)");
710 let (tx, rx) = oneshot::channel();
711 let engine_for_emit = engine.clone();
712 let token_for_emit = token.clone();
713 let task_id_for_emit = task_id.clone();
714 let stream_mode = self.stream_mode.clone();
715 let output_decl = embed.output.clone();
716
717 tokio::spawn(async move {
718 let result: Result<WorkerResult, WorkerError> = if let Some(mode) = stream_mode {
719 run_streaming_mode(
722 mode,
723 child,
724 &engine_for_emit,
725 &token_for_emit,
726 &task_id_for_emit,
727 attempt,
728 cancel_inner,
729 )
730 .await
731 } else {
732 let result = tokio::select! {
733 output = child.wait_with_output() => {
734 match output {
735 Ok(out) => Ok(normalize_plain_output(&out, output_decl.as_ref())),
736 Err(e) => Err(WorkerError::Failed(format!("wait_with_output: {e}"))),
737 }
738 }
739 _ = cancel_inner.cancelled() => Err(WorkerError::Cancelled),
740 };
741 if let Ok(wr) = &result {
742 let ev = OutputEvent::Final {
743 content: ContentRef::Inline {
744 value: wr.value.clone(),
745 },
746 ok: wr.ok,
747 };
748 let _ = engine_for_emit
749 .submit_output(&token_for_emit, &task_id_for_emit, attempt, ev)
750 .await;
751 }
752 result
753 };
754 let signal: Result<(), WorkerError> = result.map(|_| ());
755 let _ = tx.send(signal);
756 });
757
758 Ok(Box::new(ProcessWorker {
759 handler: WorkerJoinHandler {
760 worker_id,
761 cancel,
762 completion: rx,
763 },
764 }))
765 }
766}
767
768pub struct ProcessWorker {
772 pub handler: WorkerJoinHandler,
775}
776
777#[async_trait]
778impl Worker for ProcessWorker {
779 fn id(&self) -> &WorkerId {
780 &self.handler.worker_id
781 }
782 fn cancel_token(&self) -> CancellationToken {
783 self.handler.cancel.clone()
784 }
785 async fn join(self: Box<Self>) -> Result<(), WorkerError> {
786 self.handler.await_completion().await
787 }
788}
789
790async fn run_streaming_mode(
796 mode: StreamMode,
797 mut child: tokio::process::Child,
798 engine: &Engine,
799 token: &CapToken,
800 task_id: &StepId,
801 attempt: u32,
802 cancel: CancellationToken,
803) -> Result<WorkerResult, WorkerError> {
804 let stdout = child
805 .stdout
806 .take()
807 .ok_or_else(|| WorkerError::Failed("streaming: stdout pipe missing".into()))?;
808
809 let last_final = match mode {
810 StreamMode::NdjsonLines => {
811 read_ndjson(stdout, engine, token, task_id, attempt, cancel.clone()).await?
812 }
813 StreamMode::SseEvents => {
814 read_sse(stdout, engine, token, task_id, attempt, cancel.clone()).await?
815 }
816 StreamMode::LengthPrefixed => {
817 read_length_prefixed(stdout, engine, token, task_id, attempt, cancel.clone()).await?
818 }
819 };
820
821 let status = child
822 .wait()
823 .await
824 .map_err(|e| WorkerError::Failed(format!("streaming wait: {e}")))?;
825
826 match last_final {
827 Some((value, ok)) => Ok(WorkerResult {
828 value,
829 ok: ok && status.success(),
830 }),
831 None => {
832 let value = serde_json::json!({
834 "raw": "",
835 "note": "streaming mode: no Final event received",
836 "exit_success": status.success(),
837 });
838 let _ = engine
839 .submit_output(
840 token,
841 task_id,
842 attempt,
843 OutputEvent::Final {
844 content: ContentRef::Inline {
845 value: value.clone(),
846 },
847 ok: false,
848 },
849 )
850 .await;
851 Ok(WorkerResult { value, ok: false })
852 }
853 }
854}
855
856async fn forward_event(
859 engine: &Engine,
860 token: &CapToken,
861 task_id: &StepId,
862 attempt: u32,
863 ev: OutputEvent,
864 last_final: &mut Option<(Value, bool)>,
865) {
866 if let OutputEvent::Final { content, ok } = &ev {
867 let value = match content {
868 ContentRef::Inline { value } => value.clone(),
869 ContentRef::FileRef {
870 path,
871 mime,
872 size_hint,
873 } => serde_json::json!({
874 "file_ref": path.to_string_lossy(),
875 "mime": mime,
876 "size_hint": size_hint,
877 }),
878 };
879 *last_final = Some((value, *ok));
880 }
881 let _ = engine.submit_output(token, task_id, attempt, ev).await;
882}
883
884async fn read_ndjson(
887 stdout: tokio::process::ChildStdout,
888 engine: &Engine,
889 token: &CapToken,
890 task_id: &StepId,
891 attempt: u32,
892 cancel: CancellationToken,
893) -> Result<Option<(Value, bool)>, WorkerError> {
894 let mut reader = BufReader::new(stdout).lines();
895 let mut last_final = None;
896 loop {
897 tokio::select! {
898 line_res = reader.next_line() => match line_res {
899 Ok(Some(line)) => {
900 let trimmed = line.trim();
901 if trimmed.is_empty() { continue; }
902 if let Ok(ev) = serde_json::from_str::<OutputEvent>(trimmed) {
903 forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
904 }
905 }
906 Ok(None) => break,
907 Err(e) => return Err(WorkerError::Failed(format!("ndjson read: {e}"))),
908 },
909 _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
910 }
911 }
912 Ok(last_final)
913}
914
915async fn read_sse(
919 stdout: tokio::process::ChildStdout,
920 engine: &Engine,
921 token: &CapToken,
922 task_id: &StepId,
923 attempt: u32,
924 cancel: CancellationToken,
925) -> Result<Option<(Value, bool)>, WorkerError> {
926 let mut reader = BufReader::new(stdout).lines();
927 let mut last_final = None;
928 let mut data_buf = String::new();
929 loop {
930 tokio::select! {
931 line_res = reader.next_line() => match line_res {
932 Ok(Some(line)) => {
933 if line.is_empty() {
934 if !data_buf.is_empty() {
936 if let Ok(ev) = serde_json::from_str::<OutputEvent>(data_buf.trim()) {
937 forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
938 }
939 data_buf.clear();
940 }
941 } else if let Some(rest) = line.strip_prefix("data:") {
942 let payload = rest.strip_prefix(' ').unwrap_or(rest);
944 if !data_buf.is_empty() {
945 data_buf.push('\n');
946 }
947 data_buf.push_str(payload);
948 }
949 }
951 Ok(None) => {
952 if !data_buf.is_empty() {
954 if let Ok(ev) = serde_json::from_str::<OutputEvent>(data_buf.trim()) {
955 forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
956 }
957 }
958 break;
959 }
960 Err(e) => return Err(WorkerError::Failed(format!("sse read: {e}"))),
961 },
962 _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
963 }
964 }
965 Ok(last_final)
966}
967
968async fn read_length_prefixed(
971 mut stdout: tokio::process::ChildStdout,
972 engine: &Engine,
973 token: &CapToken,
974 task_id: &StepId,
975 attempt: u32,
976 cancel: CancellationToken,
977) -> Result<Option<(Value, bool)>, WorkerError> {
978 use tokio::io::AsyncReadExt;
979 let mut last_final = None;
980 loop {
981 let mut len_buf = [0u8; 4];
983 let read_fut = stdout.read_exact(&mut len_buf);
984 let read_res = tokio::select! {
985 r = read_fut => r,
986 _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
987 };
988 match read_res {
989 Ok(_) => {}
990 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(e) => return Err(WorkerError::Failed(format!("len read: {e}"))),
992 }
993 let len = u32::from_be_bytes(len_buf) as usize;
994 if len == 0 || len > 16 * 1024 * 1024 {
995 break;
997 }
998 let mut payload = vec![0u8; len];
999 let read_fut = stdout.read_exact(&mut payload);
1000 let read_res = tokio::select! {
1001 r = read_fut => r,
1002 _ = cancel.cancelled() => return Err(WorkerError::Cancelled),
1003 };
1004 if read_res.is_err() {
1005 break;
1006 }
1007 if let Ok(ev) = serde_json::from_slice::<OutputEvent>(&payload) {
1008 forward_event(engine, token, task_id, attempt, ev, &mut last_final).await;
1009 }
1010 }
1011 Ok(last_final)
1012}
1013
1014#[cfg(test)]
1015mod embed_tests {
1016 use super::*;
1017
1018 fn vars() -> EmbedVars {
1019 EmbedVars {
1020 system: "SYS".to_string(),
1021 system_file: Some("/tmp/sys.md".to_string()),
1022 prompt: "do the task".to_string(),
1023 model: Some("small".to_string()),
1024 tools_csv: "Read,Grep".to_string(),
1025 work_dir: Some("/tmp/wd".to_string()),
1026 task_id: "ST-1".to_string(),
1027 attempt: "1".to_string(),
1028 }
1029 }
1030
1031 #[test]
1032 fn render_substitutes_every_closed_set_token() {
1033 let v = vars();
1034 let out = v
1035 .render("{system}|{system_file}|{prompt}|{model}|{tools_csv}|{work_dir}|{task_id}|{attempt}")
1036 .expect("render");
1037 assert_eq!(
1038 out,
1039 "SYS|/tmp/sys.md|do the task|small|Read,Grep|/tmp/wd|ST-1|1"
1040 );
1041 }
1042
1043 #[test]
1044 fn render_leaves_non_placeholder_braces_alone() {
1045 let v = vars();
1046 let out = v
1047 .render(r#"echo '{"result": "{prompt}"}'"#)
1048 .expect("render");
1049 assert_eq!(out, r#"echo '{"result": "do the task"}'"#);
1050 }
1051
1052 #[test]
1053 fn render_fails_loud_when_model_referenced_but_absent() {
1054 let mut v = vars();
1055 v.model = None;
1056 let err = v.render("--model {model}").unwrap_err();
1057 let msg = format!("{err:?}");
1058 assert!(msg.contains("{model}"), "actionable token in error: {msg}");
1059 assert!(msg.contains("profile.model"), "actionable cause: {msg}");
1060 }
1061
1062 #[test]
1063 fn render_fails_loud_when_work_dir_referenced_but_absent() {
1064 let mut v = vars();
1065 v.work_dir = None;
1066 let err = v.render("{work_dir}").unwrap_err();
1067 let msg = format!("{err:?}");
1068 assert!(msg.contains("{work_dir}"), "actionable token: {msg}");
1069 }
1070
1071 #[test]
1072 fn render_empty_system_is_legal() {
1073 let mut v = vars();
1074 v.system = String::new();
1075 assert_eq!(v.render("[{system}]").expect("render"), "[]");
1076 }
1077
1078 #[test]
1083 fn render_never_substitutes_inside_substituted_values() {
1084 let mut v = vars();
1085 v.prompt = "please mention {model} and {work_dir} literally".to_string();
1086 v.model = None; v.work_dir = None;
1088 let out = v.render("task: {prompt}").expect("render");
1089 assert_eq!(out, "task: please mention {model} and {work_dir} literally");
1090 }
1091
1092 #[test]
1095 fn render_substitutes_placeholder_nested_in_literal_braces() {
1096 let v = vars();
1097 let out = v.render(r#"{"task": "{prompt}", "n": 1}"#).expect("render");
1098 assert_eq!(out, r#"{"task": "do the task", "n": 1}"#);
1099 }
1100
1101 #[test]
1102 fn embed_references_scans_argv_stdin_env_cwd() {
1103 let mut t = EmbedTemplate {
1104 argv: vec!["cat".to_string()],
1105 ..Default::default()
1106 };
1107 assert!(!embed_references(&t, "{system_file}"));
1108 t.stdin = Some("{system_file}".to_string());
1109 assert!(embed_references(&t, "{system_file}"));
1110 t.stdin = None;
1111 t.env.insert("X".to_string(), "{system_file}".to_string());
1112 assert!(embed_references(&t, "{system_file}"));
1113 t.env.clear();
1114 t.cwd = Some("{system_file}".to_string());
1115 assert!(embed_references(&t, "{system_file}"));
1116 }
1117
1118 fn fake_output(stdout: &str, stderr: &str, code: i32) -> std::process::Output {
1119 #[cfg(unix)]
1120 use std::os::unix::process::ExitStatusExt;
1121 #[cfg(windows)]
1122 use std::os::windows::process::ExitStatusExt;
1123 use std::process::ExitStatus;
1124 #[cfg(unix)]
1125 let status = ExitStatus::from_raw(code << 8);
1126 #[cfg(windows)]
1127 let status = ExitStatus::from_raw(code as u32);
1128 std::process::Output {
1129 status,
1130 stdout: stdout.as_bytes().to_vec(),
1131 stderr: stderr.as_bytes().to_vec(),
1132 }
1133 }
1134
1135 #[test]
1139 fn normalize_without_decl_is_historical_json_or_raw() {
1140 let out = fake_output(r#"{"a": 1}"#, "", 0);
1141 let wr = normalize_plain_output(&out, None);
1142 assert_eq!(wr.value, serde_json::json!({"a": 1}));
1143 assert!(wr.ok);
1144
1145 let out = fake_output("plain text\n", "warned", 0);
1146 let wr = normalize_plain_output(&out, None);
1147 assert_eq!(
1148 wr.value,
1149 serde_json::json!({"raw": "plain text", "stderr": "warned"})
1150 );
1151 assert!(wr.ok);
1152
1153 let out = fake_output("boom", "", 1);
1154 let wr = normalize_plain_output(&out, None);
1155 assert!(!wr.ok, "non-zero exit is a failed step");
1156 }
1157
1158 #[test]
1159 fn normalize_result_ptr_extracts_declared_value() {
1160 let decl = SubprocessOutput {
1161 format: Some("json".to_string()),
1162 result_ptr: Some("/result".to_string()),
1163 ok_from: Some("exit_code".to_string()),
1164 };
1165 let out = fake_output(r#"{"result": {"answer": 42}, "noise": true}"#, "", 0);
1166 let wr = normalize_plain_output(&out, Some(&decl));
1167 assert_eq!(wr.value, serde_json::json!({"answer": 42}));
1168 assert!(wr.ok);
1169 }
1170
1171 #[test]
1172 fn normalize_declared_json_unparsable_stdout_fails_loud() {
1173 let decl = SubprocessOutput {
1174 format: Some("json".to_string()),
1175 result_ptr: None,
1176 ok_from: None,
1177 };
1178 let out = fake_output("not json at all", "stderr text", 0);
1179 let wr = normalize_plain_output(&out, Some(&decl));
1180 assert!(!wr.ok, "declared-JSON unparsable stdout is a failed step");
1181 assert_eq!(wr.value["raw"], "not json at all");
1182 assert_eq!(wr.value["stderr"], "stderr text");
1183 assert!(wr.value["parse_error"].is_string());
1184 }
1185
1186 #[test]
1187 fn normalize_missing_result_ptr_fails_loud_with_actionable_value() {
1188 let decl = SubprocessOutput {
1189 format: Some("json".to_string()),
1190 result_ptr: Some("/missing".to_string()),
1191 ok_from: None,
1192 };
1193 let out = fake_output(r#"{"present": 1}"#, "", 0);
1194 let wr = normalize_plain_output(&out, Some(&decl));
1195 assert!(!wr.ok);
1196 assert!(wr.value["error"]
1197 .as_str()
1198 .expect("actionable error message")
1199 .contains("/missing"));
1200 }
1201
1202 #[test]
1203 fn normalize_ok_from_pointer_reads_boolean() {
1204 let decl = SubprocessOutput {
1205 format: Some("json".to_string()),
1206 result_ptr: Some("/result".to_string()),
1207 ok_from: Some("/ok".to_string()),
1208 };
1209 let out = fake_output(r#"{"result": "r", "ok": true}"#, "", 0);
1211 let wr = normalize_plain_output(&out, Some(&decl));
1212 assert!(wr.ok);
1213 let out = fake_output(r#"{"result": "r", "ok": false}"#, "", 0);
1215 let wr = normalize_plain_output(&out, Some(&decl));
1216 assert!(!wr.ok);
1217 let out = fake_output(r#"{"result": "r", "ok": "yes"}"#, "", 0);
1219 let wr = normalize_plain_output(&out, Some(&decl));
1220 assert!(!wr.ok);
1221 }
1222}