Make delegate an LLM tool, not a slash command.

f1dd8c526210 · AtlantisPleb · · parent bcbde9153f74

Make delegate an LLM tool, not a slash command.

- `runtime.rs` now advertises a `delegate` function tool to the model with the
  discovered ACP agent IDs as the `agent` enum and `prompt` as the task.
- When the model calls `delegate`, the runtime runs the ACP harness, streams
  `Control::Tool` and `Control::ToolText` events, appends the
  `FunctionCallOutput`, and continues the turn.
- `interactive.rs` no longer exposes `/delegate` to the user; it simply hands
  the prompt to `CoderRuntimeSession` after populating `session.agents`.
- The TUI still renders delegate calls as a one-line `  ⏺` header and a 5-line
  `  │` output box.
- `tests/smoke.rs` and `tests/tool_box.rs` both pass.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/coder-lite/src/interactive.rs
  • modified crates/coder-lite/src/runtime.rs
  • modified crates/coder-lite/tests/smoke.rs

Diff

3 files changed, +198 -158

crates/coder-lite/src/interactive.rs modified +45 -119

@@ -5,11 +5,9 @@

5 5
//! `modifiers` so control chords do not fall through to plain character input.
6 6
7 7
use crate::acp;
8
use crate::acp_harness::{AcpEvent, AcpHarness};
9 8
use crate::runtime::{CoderRuntimeSession, Control};
10 9
use crate::tui::{CoderUi, Entry, Role};
11 10
use std::sync::mpsc;
12
use std::path::PathBuf;
13 11
use crossterm::{
14 12
    event::{
15 13
        self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,

@@ -23,14 +21,6 @@ use ratatui::Terminal;

23 21
use std::io::{stderr, stdout};
24 22
use std::time::Duration;
25 23
26
#[derive(Debug, Clone)]
27
pub enum DelegateControl {
28
    Tool { agent: String, title: String },
29
    Text(String),
30
    Done,
31
    Error(String),
32
}
33
34 24
pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {
35 25
    if !atty_is_terminal() {
36 26
        println!("Non-interactive terminal detected. Run coder-lite from a TTY.");

@@ -52,7 +42,6 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

52 42
    terminal.show_cursor()?;
53 43
54 44
    let (tx, rx) = mpsc::channel::<Control>();
55
    let (delegate_tx, delegate_rx) = mpsc::channel::<DelegateControl>();
56 45
    let mut ui = CoderUi::new();
57 46
58 47
    match acp::find_agents().await {

@@ -82,26 +71,39 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

82 71
        while let Ok(control) = rx.try_recv() {
83 72
            match control {
84 73
                Control::Chunk(chunk) => {
85
                    if let Some(last) = ui.entries.last_mut() {
74
                    // Append text to the current assistant entry.
75
                    if let Some(last) = ui
76
                        .entries
77
                        .iter_mut()
78
                        .rfind(|e| e.role == Role::Assistant)
79
                    {
86 80
                        last.text.push_str(&chunk);
87 81
                        ui.scroll_override = None;
88 82
                    }
89 83
                }
90 84
                Control::Done => ui.loading = false,
91
            }
92
        }
93
94
        while let Ok(delegate) = delegate_rx.try_recv() {
95
            match delegate {
96
                DelegateControl::Tool { agent, title } => {
97
                    ui.entries.push(Entry {
98
                        role: Role::Tool,
99
                        text: format!("delegate {}: {}", agent, title),
100
                        output: Some(Vec::new()),
101
                    });
85
                Control::Tool { agent, title } => {
86
                    if let Some(last) = ui.entries.last_mut() {
87
                        if last.role == Role::Tool {
88
                            last.text = format!("delegate {}: {}", agent, title);
89
                            last.output = Some(Vec::new());
90
                        } else {
91
                            ui.entries.push(Entry {
92
                                role: Role::Tool,
93
                                text: format!("delegate {}: {}", agent, title),
94
                                output: Some(Vec::new()),
95
                            });
96
                        }
97
                    } else {
98
                        ui.entries.push(Entry {
99
                            role: Role::Tool,
100
                            text: format!("delegate {}: {}", agent, title),
101
                            output: Some(Vec::new()),
102
                        });
103
                    }
102 104
                    ui.scroll_override = None;
103 105
                }
104
                DelegateControl::Text(chunk) => {
106
                Control::ToolText(chunk) => {
105 107
                    if let Some(last) = ui.entries.last_mut() {
106 108
                        if last.role == Role::Tool {
107 109
                            last.output.get_or_insert_with(Vec::new).push(chunk);

@@ -109,15 +111,7 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

109 111
                    }
110 112
                    ui.scroll_override = None;
111 113
                }
112
                DelegateControl::Done => {}
113
                DelegateControl::Error(message) => {
114
                    ui.entries.push(Entry {
115
                        role: Role::Notice,
116
                        text: format!("delegate error: {}", message),
117
                        output: None,
118
                    });
119
                    ui.scroll_override = None;
120
                }
114
                Control::ToolDone => {}
121 115
            }
122 116
        }
123 117

@@ -150,94 +144,26 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

150 144
                            ui.composer.push('\n');
151 145
                        } else if !ui.composer.trim().is_empty() {
152 146
                            let prompt = ui.composer.clone();
147
                            ui.entries.push(Entry {
148
                                role: Role::You,
149
                                text: prompt.clone(),
150
                                output: None,
151
                            });
152
                            ui.entries.push(Entry {
153
                                role: Role::Assistant,
154
                                text: String::new(),
155
                                output: None,
156
                            });
153 157
                            ui.composer.clear();
154 158
                            ui.scroll_override = None;
155
156
                            if let Some(rest) = prompt.strip_prefix("/delegate") {
157
                                let rest = rest.trim_start();
158
                                if let Some((agent_id, task)) = rest.split_once(' ') {
159
                                    let agent_id = agent_id.to_string();
160
                                    let task = task.trim().to_string();
161
                                    let agent = ui.agents.iter().find(|a| a.id == agent_id).cloned();
162
                                    ui.entries.push(Entry {
163
                                        role: Role::You,
164
                                        text: prompt,
165
                                        output: None,
166
                                    });
167
                                    match agent {
168
                                        Some(agent) => {
169
                                            ui.entries.push(Entry {
170
                                                role: Role::Tool,
171
                                                text: format!("delegate {}: starting", agent_id),
172
                                                output: Some(Vec::new()),
173
                                            });
174
                                            let cwd =
175
                                                std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
176
                                            let delegate_tx = delegate_tx.clone();
177
                                            tokio::spawn(async move {
178
                                                let harness = AcpHarness {
179
                                                    command: agent.command,
180
                                                    args: agent.args,
181
                                                };
182
                                                let result = harness
183
                                                    .run(&task, &cwd, |event| match event {
184
                                                        AcpEvent::Tool { kind: _, title } => {
185
                                                            let _ = delegate_tx.send(
186
                                                                DelegateControl::Tool {
187
                                                                    agent: agent_id.clone(),
188
                                                                    title,
189
                                                                },
190
                                                            );
191
                                                        }
192
                                                        AcpEvent::Text { chunk } => {
193
                                                            let _ = delegate_tx
194
                                                                .send(DelegateControl::Text(chunk));
195
                                                        }
196
                                                        _ => {}
197
                                                    })
198
                                                    .await;
199
                                                if let Err(e) = result {
200
                                                    let _ = delegate_tx
201
                                                        .send(DelegateControl::Error(e.to_string()));
202
                                                } else {
203
                                                    let _ = delegate_tx.send(DelegateControl::Done);
204
                                                }
205
                                            });
206
                                        }
207
                                        None => {
208
                                            ui.entries.push(Entry {
209
                                                role: Role::Notice,
210
                                                text: format!("unknown ACP agent: {}", agent_id),
211
                                                output: None,
212
                                            });
213
                                        }
214
                                    }
215
                                } else {
216
                                    ui.entries.push(Entry {
217
                                        role: Role::Notice,
218
                                        text: "usage: /delegate <agent> <prompt>".to_string(),
219
                                        output: None,
220
                                    });
221
                                }
222
                            } else {
223
                                ui.entries.push(Entry {
224
                                    role: Role::You,
225
                                    text: prompt.clone(),
226
                                    output: None,
227
                                });
228
                                ui.entries.push(Entry {
229
                                    role: Role::Assistant,
230
                                    text: String::new(),
231
                                    output: None,
232
                                });
233
                                ui.loading = true;
234
235
                                let mut session = CoderRuntimeSession::new();
236
                                let tx = tx.clone();
237
                                tokio::spawn(async move {
238
                                    let _ = session.execute_turn(&prompt, tx).await;
239
                                });
240
                            }
159
                            ui.loading = true;
160
161
                            let mut session = CoderRuntimeSession::new();
162
                            session.agents = ui.agents.clone();
163
                            let tx = tx.clone();
164
                            tokio::spawn(async move {
165
                                let _ = session.execute_turn(&prompt, tx).await;
166
                            });
241 167
                        }
242 168
                    }
243 169
                    KeyEvent {
crates/coder-lite/src/runtime.rs modified +152 -39

@@ -1,21 +1,31 @@

1 1
//! Open Responses streaming client for coder-lite.
2 2
3 3
use futures::StreamExt;
4
use openresponses_rust::{CreateResponseBody, Input, Item, StreamingClient, StreamingEvent};
4
use openresponses_rust::{
5
    CreateResponseBody, FunctionOutput, Input, Item, StreamingClient, StreamingEvent, Tool,
6
};
5 7
use std::env;
8
use std::path::PathBuf;
6 9
use std::sync::mpsc::Sender;
7 10
11
use crate::acp::Agent;
12
use crate::acp_harness::{AcpEvent, AcpHarness};
13
8 14
const SYSTEM_INSTRUCTIONS: &str = "You are OpenAgents Coder. Do not say you are from Google, Anthropic, OpenAI, or any other company. Do not mention your model, training, or architecture. Respond as a neutral, terse terminal: no greetings, no \"As an AI\", no explanations of your role, and no unnecessary padding. Use short sentences and dense, factual output. Answer questions directly. Output only code and minimal context when asked for code.";
9 15
10 16
pub enum Control {
11 17
    Chunk(String),
12 18
    Done,
19
    Tool { agent: String, title: String },
20
    ToolText(String),
21
    ToolDone,
13 22
}
14 23
15 24
pub struct CoderRuntimeSession {
16 25
    pub api_key: String,
17 26
    pub base_url: String,
18 27
    pub history: Vec<Item>,
28
    pub agents: Vec<Agent>,
19 29
}
20 30
21 31
impl CoderRuntimeSession {

@@ -25,6 +35,7 @@ impl CoderRuntimeSession {

25 35
            base_url: env::var("OPENAGENTS_BASE_URL")
26 36
                .unwrap_or_else(|_| "https://openagents.com/api/v1".to_string()),
27 37
            history: vec![Item::system_message(SYSTEM_INSTRUCTIONS)],
38
            agents: Vec::new(),
28 39
        }
29 40
    }
30 41

@@ -44,56 +55,158 @@ impl CoderRuntimeSession {

44 55
        self.history.push(Item::user_message(prompt));
45 56
46 57
        let client = StreamingClient::with_base_url(&self.api_key, &self.base_url);
58
        let tools = self.delegate_tool();
47 59
48
        let request = CreateResponseBody {
49
            model: env::var("OPENAGENTS_MODEL").ok(),
50
            input: Some(Input::Items(self.history.clone())),
51
            stream: Some(true),
52
            ..Default::default()
53
        };
54
55
        let mut stream = match client.stream_response(request).await {
56
            Ok(s) => s,
57
            Err(e) => {
58
                let _ = tx.send(Control::Chunk(format!("[error: {}]", e)));
59
                let _ = tx.send(Control::Done);
60
                return Err(e.into());
61
            }
62
        };
63
64
        let mut collected = String::new();
60
        loop {
61
            let request = CreateResponseBody {
62
                model: env::var("OPENAGENTS_MODEL").ok(),
63
                input: Some(Input::Items(self.history.clone())),
64
                tools: tools.clone(),
65
                stream: Some(true),
66
                ..Default::default()
67
            };
65 68
66
        while let Some(event) = stream.next().await {
67
            match event {
68
                Ok(StreamingEvent::OutputTextDelta { delta, .. }) => {
69
                    collected.push_str(&delta);
70
                    let _ = tx.send(Control::Chunk(delta));
71
                }
72
                Ok(StreamingEvent::ReasoningDelta { delta, .. }) => {
73
                    collected.push_str(&delta);
74
                    let _ = tx.send(Control::Chunk(delta));
75
                }
76
                Ok(StreamingEvent::RefusalDelta { delta, .. }) => {
77
                    let _ = tx.send(Control::Chunk(delta));
78
                }
79
                Ok(StreamingEvent::Error { error, .. }) => {
80
                    let msg = format!("[error: {:?}]", error);
81
                    let _ = tx.send(Control::Chunk(msg));
82
                }
83
                Ok(_) => {}
69
            let mut stream = match client.stream_response(request).await {
70
                Ok(s) => s,
84 71
                Err(e) => {
85 72
                    let _ = tx.send(Control::Chunk(format!("[error: {}]", e)));
86 73
                    let _ = tx.send(Control::Done);
87 74
                    return Err(e.into());
88 75
                }
76
            };
77
78
            let mut collected = String::new();
79
            let mut pending_tool: Option<(String, String, String)> = None;
80
81
            while let Some(event) = stream.next().await {
82
                match event {
83
                    Ok(StreamingEvent::OutputTextDelta { delta, .. }) => {
84
                        collected.push_str(&delta);
85
                        let _ = tx.send(Control::Chunk(delta));
86
                    }
87
                    Ok(StreamingEvent::ReasoningDelta { delta, .. }) => {
88
                        let _ = tx.send(Control::Chunk(delta));
89
                    }
90
                    Ok(StreamingEvent::OutputItemDone {
91
                        item: Some(Item::FunctionCall {
92
                            call_id,
93
                            name,
94
                            arguments,
95
                            ..
96
                        }),
97
                        ..
98
                    }) if name == "delegate" => {
99
                        let args = serde_json::from_str::<serde_json::Value>(&arguments)
100
                            .unwrap_or(serde_json::json!({}));
101
                        let agent = args
102
                            .get("agent")
103
                            .and_then(|v| v.as_str())
104
                            .unwrap_or("")
105
                            .to_string();
106
                        let task = args
107
                            .get("prompt")
108
                            .and_then(|v| v.as_str())
109
                            .unwrap_or("")
110
                            .to_string();
111
                        pending_tool = Some((call_id, agent, task));
112
                    }
113
                    Ok(StreamingEvent::Error { error, .. }) => {
114
                        let msg = format!("[error: {:?}]", error);
115
                        let _ = tx.send(Control::Chunk(msg));
116
                    }
117
                    Ok(_) => {}
118
                    Err(e) => {
119
                        let _ = tx.send(Control::Chunk(format!("[error: {}]", e)));
120
                        let _ = tx.send(Control::Done);
121
                        return Err(e.into());
122
                    }
123
                }
124
            }
125
126
            if let Some((call_id, agent_id, task)) = pending_tool.take() {
127
                if let Some(agent) = self.agents.iter().find(|a| a.id == agent_id).cloned() {
128
                    let title = task.chars().take(80).collect::<String>();
129
                    let _ = tx.send(Control::Tool {
130
                        agent: agent_id.clone(),
131
                        title: title.clone(),
132
                    });
133
134
                    let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
135
                    let result = {
136
                        let tx = tx.clone();
137
                        AcpHarness {
138
                            command: agent.command,
139
                            args: agent.args,
140
                        }
141
                        .run(&task, &cwd, move |event| match event {
142
                            AcpEvent::Tool { title, .. } => {
143
                                let _ = tx.send(Control::Tool {
144
                                    agent: agent_id.clone(),
145
                                    title,
146
                                });
147
                            }
148
                            AcpEvent::Text { chunk } => {
149
                                let _ = tx.send(Control::ToolText(chunk));
150
                            }
151
                            _ => {}
152
                        })
153
                        .await
154
                    };
155
156
                    let _ = tx.send(Control::ToolDone);
157
                    let output = result.unwrap_or_else(|e| e.to_string());
158
                    self.history.push(Item::FunctionCallOutput {
159
                        id: None,
160
                        call_id,
161
                        output: FunctionOutput::Text(output),
162
                        status: None,
163
                    });
164
                    continue;
165
                } else {
166
                    let msg = format!("unknown ACP agent: {}", agent_id);
167
                    let _ = tx.send(Control::Chunk(msg.clone()));
168
                    self.history.push(Item::FunctionCallOutput {
169
                        id: None,
170
                        call_id,
171
                        output: FunctionOutput::Text(msg),
172
                        status: None,
173
                    });
174
                    continue;
175
                }
89 176
            }
90
        }
91 177
92
        if !collected.is_empty() {
93
            self.history.push(Item::assistant_message(collected));
178
            if !collected.is_empty() {
179
                self.history.push(Item::assistant_message(collected));
180
            }
181
            break;
94 182
        }
95 183
96 184
        let _ = tx.send(Control::Done);
97 185
        Ok(())
98 186
    }
187
188
    fn delegate_tool(&self) -> Option<Vec<Tool>> {
189
        if self.agents.is_empty() {
190
            return None;
191
        }
192
        let ids: Vec<String> = self.agents.iter().map(|a| a.id.clone()).collect();
193
        let tool = Tool::function("delegate")
194
            .with_description("Delegate a coding task to an ACP agent on this machine.")
195
            .with_parameters(serde_json::json!({
196
                "type": "object",
197
                "properties": {
198
                    "agent": {
199
                        "type": "string",
200
                        "enum": ids,
201
                        "description": "the ACP agent to delegate to"
202
                    },
203
                    "prompt": {
204
                        "type": "string",
205
                        "description": "the task for the child agent"
206
                    }
207
                },
208
                "required": ["agent", "prompt"]
209
            }));
210
        Some(vec![tool])
211
    }
99 212
}
crates/coder-lite/tests/smoke.rs modified +1

@@ -29,6 +29,7 @@ async fn smoke_openresponses_stream() {

29 29
                collected.push_str(&c);
30 30
            }
31 31
            Ok(Control::Done) => done = true,
32
            Ok(_) => {}
32 33
            Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
33 34
        }
34 35
    }

This page updates live while a promote is in flight · changelog