Skip to main content

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}