agentmux_srv/drone/executor/blocks/agent.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent block — one-shot Claude Code spawn driven by an `AgentRef`
5//! and a per-call task template.
6//!
7//! Phase 1.5 PR 2: replaces the original stub
8//! (`{ response: "[stub]" }`) with a real invocation of
9//! `agents::runner::run_agent`. The runner spawns
10//! `claude --print --output-format=stream-json`, drains its stdout
11//! through `ClaudeTranslator`, and produces a structured
12//! `AgentRunResult` that this function flattens into the
13//! snake_case drone-block output shape.
14//!
15//! Block config (`node.data`):
16//! * `task` — required. Mustache-style template resolved
17//! against `scope.outputs + scope.vars` before
18//! spawning.
19//! * `agent_ref` — optional. Object matching `AgentRef` shape
20//! (camelCase keys): identityId, memoryId,
21//! instanceName, workingDirectory. All fields
22//! optional; missing = blank agent.
23//! * `max_turns` — optional. Hard cap on claude turns.
24//!
25//! Output (snake_case to match other drone blocks — see spec
26//! §4.5):
27//!
28//! ```json
29//! {
30//! "response": "<text>",
31//! "tokens": { "input": .., "output": .., "cache_creation": .., "cache_read": .. },
32//! "cost_usd": 0.001,
33//! "status": "done"
34//! }
35//! ```
36//!
37//! Downstream blocks read `{{<this_block_id>.response}}` for the
38//! agent's reply and `{{<this_block_id>.cost_usd}}` for accounting.
39
40use serde_json::{json, Value};
41
42use crate::agents::runner::{run_agent, AgentError};
43use crate::agents::types::{AgentRef, AgentTask};
44use crate::drone::data_flow::ExecutionScope;
45use crate::drone::types::FlowNode;
46
47pub async fn run(node: &FlowNode, scope: &ExecutionScope) -> Result<Value, String> {
48 let task_raw = node
49 .data
50 .get("task")
51 .and_then(|v| v.as_str())
52 .ok_or_else(|| "agent block missing `task`".to_string())?;
53 let prompt = scope.resolve(task_raw);
54
55 let agent_ref: AgentRef = match node.data.get("agent_ref") {
56 Some(v) => serde_json::from_value(v.clone())
57 .map_err(|e| format!("agent block: invalid agent_ref: {e}"))?,
58 None => {
59 // Pre-Phase-1.5 nodes persisted a single `forge_agent_id`
60 // string; the runner can't honor it because identity and
61 // memory are now separate bundles. Surface the legacy data
62 // in the log so the user knows why the agent launches
63 // blank (#835).
64 if let Some(legacy) = node.data.get("forge_agent_id").and_then(|v| v.as_str()) {
65 if !legacy.is_empty() {
66 tracing::warn!(
67 block_id = %node.id,
68 legacy_forge_agent_id = %legacy,
69 "agent block: legacy `forge_agent_id` ignored — re-pick identity/memory after Phase 1.5 PR 3"
70 );
71 }
72 }
73 AgentRef::default()
74 }
75 };
76
77 let max_turns = node
78 .data
79 .get("max_turns")
80 .and_then(|v| v.as_u64())
81 .map(|n| n as u32);
82
83 let task = AgentTask {
84 prompt,
85 // The runner doesn't currently use the `context` map (claude
86 // takes the prompt on argv); leave it empty. Phase 2 may
87 // surface scope vars as a `system` message section.
88 context: serde_json::Map::new(),
89 max_turns,
90 };
91
92 // Forward AgentEvents into a local channel and discard for now.
93 // Phase 1.5 PR 3 will re-emit them on the `dronerun:<id>`
94 // broker so the inspector pane can render the live stream.
95 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
96 let handle = run_agent(agent_ref, task, tx).await.map_err(|e| match e {
97 AgentError::Spawn(msg) => format!("agent block: spawn failed: {msg}"),
98 AgentError::CommitPressure { avail_gb, reserve_gb } => format!(
99 "agent block: memory full — {avail_gb:.1} GB commit free, need {reserve_gb:.1} GB; try again when memory frees"
100 ),
101 })?;
102
103 // Drain the event channel concurrently so the runner's sender
104 // can make progress. Drop the events for now — the captured
105 // accumulator from `final_result` is the authoritative output.
106 tokio::spawn(async move {
107 while rx.recv().await.is_some() {}
108 });
109
110 let result = handle
111 .final_result
112 .await
113 .map_err(|e| format!("agent block: runner cancelled: {e}"))?
114 .map_err(|e| format!("agent block: agent run failed: {e}"))?;
115
116 // Manually flatten to snake_case to match other drone block
117 // outputs (the AgentRunResult's serde camelCase is for the IPC
118 // seam with the frontend, NOT for drone templates — see spec
119 // §4.5 NOTE).
120 Ok(json!({
121 "response": result.response,
122 "tokens": {
123 "input": result.tokens.input,
124 "output": result.tokens.output,
125 "cache_creation": result.tokens.cache_creation,
126 "cache_read": result.tokens.cache_read,
127 },
128 "cost_usd": result.cost_usd,
129 "status": "done",
130 }))
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::drone::types::NodePosition;
137
138 fn mk_node(data: Value) -> FlowNode {
139 FlowNode {
140 id: "a1".to_string(),
141 position: NodePosition::default(),
142 data,
143 node_type: None,
144 }
145 }
146
147 #[tokio::test]
148 async fn rejects_missing_task() {
149 let node = mk_node(json!({ "kind": "agent" }));
150 let scope = ExecutionScope::new();
151 let err = run(&node, &scope).await.expect_err("must error");
152 assert!(err.contains("missing `task`"), "got: {err}");
153 }
154
155 #[tokio::test]
156 async fn rejects_malformed_agent_ref() {
157 let node = mk_node(json!({
158 "kind": "agent",
159 "task": "hi",
160 "agent_ref": "not an object"
161 }));
162 let scope = ExecutionScope::new();
163 let err = run(&node, &scope).await.expect_err("must error");
164 assert!(err.contains("invalid agent_ref"), "got: {err}");
165 }
166
167 // The spawn-failure → "agent block: spawn failed: ..." mapping is
168 // covered by `agents::runner::tests::run_agent_with_bin_surfaces_spawn_failure`,
169 // which injects a nonexistent binary path via the internal
170 // `run_agent_with_bin` entry point instead of `std::env::set_var`
171 // (unsound under concurrent test execution in Rust 1.81+).
172 // Reagent P2 on PR #834.
173
174 /// Reproduces the parse-only path of `run()` for a node carrying
175 /// legacy `forge_agent_id` (no `agent_ref`). The runner shim isn't
176 /// invoked — the assertion is that we accept the node and fall back
177 /// to a default `AgentRef`. The deprecation warning fires as a side
178 /// effect; we keep this test free of `tracing` plumbing.
179 #[test]
180 fn legacy_forge_agent_id_falls_back_to_default_ref() {
181 let data = json!({
182 "kind": "agent",
183 "task": "hi",
184 "forge_agent_id": "legacy-id-123"
185 });
186 let agent_ref: AgentRef = match data.get("agent_ref") {
187 Some(v) => serde_json::from_value(v.clone()).unwrap(),
188 None => AgentRef::default(),
189 };
190 assert_eq!(agent_ref, AgentRef::default());
191 // Confirms the legacy field is still present in node.data — the
192 // production path reads it for the warn-log; tests don't.
193 assert_eq!(
194 data.get("forge_agent_id").and_then(|v| v.as_str()),
195 Some("legacy-id-123")
196 );
197 }
198}