Skip to main content

task_board/
agent.rs

1//! The agent: one `impl Agent`, and the command parser it runs on.
2//!
3//! Deterministic by default. The board moves because the user typed `add` and
4//! not because a model decided to call a tool, which is what makes the
5//! transcripts in `README.md` and the tests under `tests/` assertable to the
6//! character. `--llm` swaps only the *phrasing* of the reply — see
7//! [`crate::llm`].
8//!
9//! What one run emits, in order:
10//!
11//! ```text
12//! STEP_STARTED board
13//!   REASONING_*                       what the agent made of the message
14//!   TOOL_CALL_START/ARGS              once per mutation
15//!     STATE_SNAPSHOT | STATE_DELTA    the board moving, with the call open
16//!   TOOL_CALL_END/RESULT
17//!   TEXT_MESSAGE_START/CONTENT*/END   the reply, a word per delta
18//!   TOOL_CALL_* render_a2ui           the board as an A2UI surface
19//! STEP_FINISHED board
20//! ```
21//!
22//! `research` is the one command that delegates. Inside the step it opens two
23//! subagents in turn, and everything each one emits — its sentence, its
24//! `add_task` call, the state it publishes — goes out tagged with that
25//! invocation's `subagentRunId`, bracketed by `SUBAGENT_STARTED` and
26//! `SUBAGENT_FINISHED`. The supervisor's own reply follows, untagged.
27
28use ag_ui::server::{Agent, Error, Result, RunContext};
29use ag_ui::{Interrupt, JsonObject, Message, ResumeStatus, RunOutcome};
30use ag_ui_a2ui::constants::RENDER_A2UI_TOOL_NAME;
31use ag_ui_a2ui::find_prior_surface_in;
32use ag_ui_a2ui::toolkit::envelope::wrap_as_operations_envelope;
33use ag_ui_a2ui::toolkit::ops::{Intent, assemble_ops};
34use serde_json::json;
35
36use crate::board::{self, Board};
37use crate::llm::Voice;
38
39/// The interrupt id the clear confirmation round trips on.
40pub const CLEAR_INTERRUPT: &str = "confirm-clear";
41
42/// The workshop assistant.
43#[derive(Debug, Default)]
44pub struct TaskBoard {
45    /// Absent unless `--llm` found a key. The board never depends on it.
46    voice: Option<Voice>,
47}
48
49impl TaskBoard {
50    /// The deterministic agent. No network, no key, no model.
51    pub fn scripted() -> Self {
52        Self { voice: None }
53    }
54
55    /// The same agent, with the reply text phrased by a model.
56    pub fn with_voice(voice: Voice) -> Self {
57        Self { voice: Some(voice) }
58    }
59}
60
61impl Agent for TaskBoard {
62    type State = Board;
63
64    async fn run(&self, ctx: &mut RunContext<Board>) -> Result<RunOutcome> {
65        let said = ctx.last_user_text().unwrap_or_default();
66        let command = Command::parse(&said);
67        let intent = surface_intent(ctx.messages());
68
69        // Only this request's answer counts, so the pending decision is read
70        // before anything is emitted. `Copy`ing the status out ends the borrow
71        // of `ctx` that `resume_for` takes — the emitters below all want it
72        // mutably.
73        let answer = ctx.resume_for(CLEAR_INTERRUPT).map(|entry| entry.status);
74
75        if command == Command::Clear && answer.is_none() {
76            return ask_before_clearing(ctx);
77        }
78
79        let mut step = ctx.step("board")?;
80        step.think(command.plan(answer))?;
81
82        let report = apply(&mut step, &command, answer)?;
83        let reply = self.phrase(&mut step, &said, report.reply).await?;
84        stream(&mut step, &reply)?;
85
86        if report.render {
87            render(&mut step, intent)?;
88        }
89        Ok(RunOutcome::Success)
90    }
91}
92
93impl TaskBoard {
94    /// The reply text, phrased by the model when there is one.
95    ///
96    /// A model that fails does not fail the run: the scripted sentence is
97    /// already correct, and the failure is said out loud as reasoning rather
98    /// than swallowed.
99    async fn phrase(
100        &self,
101        ctx: &mut RunContext<Board>,
102        said: &str,
103        scripted: String,
104    ) -> Result<String> {
105        let Some(voice) = &self.voice else {
106            return Ok(scripted);
107        };
108        // Racing the model against cancellation is what makes closing the
109        // terminal stop the run rather than pay for a completion nobody reads.
110        let phrased = ctx
111            .until_cancelled(voice.phrase(said, &scripted))
112            .await
113            .ok_or(Error::Cancelled)?;
114
115        match phrased {
116            Ok(text) if !text.trim().is_empty() => Ok(text),
117            Ok(_) => Ok(scripted),
118            Err(error) => {
119                ctx.think(format!(
120                    "the model did not answer ({error}); saying it plainly"
121                ))?;
122                Ok(scripted)
123            }
124        }
125    }
126}
127
128/// What one command did, once it has been done.
129struct Report {
130    /// The sentence the agent says about it.
131    reply: String,
132    /// Whether the board changed enough to be worth redrawing.
133    render: bool,
134}
135
136/// Runs the command: one tool call and one state publish per mutation.
137///
138/// # Why every branch works while its call is open
139///
140/// A [`ToolCallHandle`](ag_ui::server::ToolCallHandle) reaches the run state, so
141/// each branch announces the call, moves the board under it, and only then
142/// reports the result. That is the order a client sees: the call in flight, the
143/// board changing, the result closing it — which for a slow tool is the reason
144/// to stream a call at all.
145///
146/// The protocol allows it because `STATE_*` is unordered, so a publish between
147/// `TOOL_CALL_START` and `TOOL_CALL_END` is a well-formed stream. The server's
148/// verifier agrees, and `tests/flows.rs` pins the resulting order.
149fn apply(
150    ctx: &mut RunContext<Board>,
151    command: &Command,
152    answer: Option<ResumeStatus>,
153) -> Result<Report> {
154    match command {
155        Command::Add(titles) => {
156            let mut added = Vec::new();
157            for title in titles {
158                let mut call = offered(ctx, board::ADD_TASK)?;
159                call.args_json(&json!({"title": title}))?;
160
161                let task = call.state_mut().add(title).clone();
162                // One publish per task, so a two-task message makes the server
163                // choose an encoding twice: the first publish is a snapshot,
164                // and the second is a STATE_DELTA only if the patch comes out
165                // smaller than the board. A client mirroring this has to
166                // survive both, and `tests/flows.rs` pins both.
167                call.publish_state()?;
168
169                call.result_json(&json!({"id": task.id, "title": task.title}))?;
170                added.push(format!("#{} {}", task.id, task.title));
171            }
172            Ok(Report {
173                reply: format!("Added {}. {}", added.join(", "), ctx.state().summary()),
174                render: true,
175            })
176        }
177
178        Command::Complete(needle) => {
179            let mut call = offered(ctx, board::COMPLETE_TASK)?;
180            call.args_json(&json!({"task": needle}))?;
181
182            let done = call.state_mut().complete(needle).cloned();
183            match done {
184                Some(task) => {
185                    call.publish_state()?;
186                    call.result_json(&json!({"id": task.id, "title": task.title, "done": true}))?;
187                    Ok(Report {
188                        reply: format!(
189                            "Done: #{} {}. {}",
190                            task.id,
191                            task.title,
192                            ctx.state().summary()
193                        ),
194                        render: true,
195                    })
196                }
197                None => {
198                    call.result_json(&json!({"error": "no such task", "task": needle}))?;
199                    Ok(Report {
200                        reply: format!("Nothing on the board matches \"{needle}\"."),
201                        render: false,
202                    })
203                }
204            }
205        }
206
207        Command::Estimate { task, minutes } => {
208            let mut call = offered(ctx, board::ESTIMATE)?;
209            call.args_json(&json!({"task": task, "minutes": minutes}))?;
210
211            let estimated = call.state_mut().estimate(task, *minutes).cloned();
212            match estimated {
213                Some(estimated) => {
214                    call.publish_state()?;
215                    call.result_json(&json!({"id": estimated.id, "minutes": minutes}))?;
216                    Ok(Report {
217                        reply: format!(
218                            "#{} is {minutes}m. {}",
219                            estimated.id,
220                            ctx.state().summary()
221                        ),
222                        render: true,
223                    })
224                }
225                None => {
226                    call.result_json(&json!({"error": "no such task", "task": task}))?;
227                    Ok(Report {
228                        reply: format!("Nothing on the board matches \"{task}\"."),
229                        render: false,
230                    })
231                }
232            }
233        }
234
235        // Reached only on a resumed run: the unanswered case returned an
236        // interrupt before any of this.
237        Command::Clear => match answer {
238            Some(ResumeStatus::Resolved) => {
239                let mut call = offered(ctx, board::CLEAR_BOARD)?;
240                call.args_json(&json!({}))?;
241
242                let removed = call.state_mut().clear();
243                call.publish_state()?;
244
245                call.result_json(&json!({"removed": removed}))?;
246                Ok(Report {
247                    reply: format!("Cleared {removed} task(s). The board is empty."),
248                    render: true,
249                })
250            }
251            _ => Ok(Report {
252                reply: format!("Left the board alone. {}", ctx.state().summary()),
253                render: true,
254            }),
255        },
256
257        Command::Research(topic) => {
258            let mut added = Vec::new();
259            for (name, finding, title) in briefs(topic) {
260                // A subagent is a scope, like the step around it: the handle
261                // dereferences to the run context, so the sentence, the tool
262                // call and the state publish below are the same code the
263                // other commands run — they merely come out attributed.
264                let mut delegate = ctx.subagent(name)?;
265                stream(&mut delegate, &finding)?;
266
267                let mut call = offered(&mut delegate, board::ADD_TASK)?;
268                call.args_json(&json!({"title": title}))?;
269                let task = call.state_mut().add(&title).clone();
270                call.publish_state()?;
271                call.result_json(&json!({"id": task.id, "title": task.title}))?;
272
273                added.push(format!("#{} {}", task.id, task.title));
274                // `finish_with` is the subagent's `RUN_FINISHED.result`; a
275                // handle that merely drops finishes with no payload.
276                delegate.finish_with(json!({"added": task.id}))?;
277            }
278            Ok(Report {
279                reply: format!(
280                    "Research on \"{topic}\" added {}. {}",
281                    added.join(", "),
282                    ctx.state().summary()
283                ),
284                render: true,
285            })
286        }
287
288        // The reply stays a sentence and the surface does the drawing. That
289        // split is the whole reason A2UI rides alongside the text.
290        Command::List => Ok(Report {
291            reply: match ctx.state().tasks.len() {
292                0 => "The board is empty. Try: add draft the agenda".to_owned(),
293                count => format!("{count} task(s) on the board — {}.", ctx.state().summary()),
294            },
295            render: true,
296        }),
297
298        Command::Help => Ok(Report {
299            reply: HELP.to_owned(),
300            render: false,
301        }),
302    }
303}
304
305/// Opens a tool call, having checked the client actually offered that tool.
306///
307/// This is a rule this agent adopts, not one the protocol imposes: the offered
308/// list says what the client *can execute*, so calling something absent from it
309/// is legal and `render_a2ui` below does exactly that. But these four tools move
310/// the board on the client's behalf, so one the client cannot run is a bug it
311/// would otherwise discover as a widget it cannot draw.
312fn offered<'a>(
313    ctx: &'a mut RunContext<Board>,
314    name: &str,
315) -> Result<ag_ui::server::ToolCallHandle<'a, Board>> {
316    if ctx.tool(name).is_none() {
317        return Err(Error::agent(format!("the client offered no {name} tool")));
318    }
319    ctx.tool_call(name)
320}
321
322/// What the agent says when it does not understand.
323const HELP: &str = "I keep a task board. Try: add draft the agenda, book the room · \
324complete 1 · estimate 2 45 · research onboarding · list · clear";
325
326/// The two delegates `research` runs, in order: a name, the one sentence
327/// each says, and the task each adds.
328///
329/// Deterministic on purpose, like every other sentence here: the transcript
330/// in `README.md` is asserted to the character.
331fn briefs(topic: &str) -> [(&'static str, String, String); 2] {
332    [
333        (
334            "scope",
335            format!("Scoping \"{topic}\": one deliverable, one owner."),
336            format!("scope {topic}"),
337        ),
338        (
339            "risks",
340            format!("One risk for \"{topic}\": nobody owns the follow-up."),
341            format!("name a follow-up owner for {topic}"),
342        ),
343    ]
344}
345
346/// Pauses the run on the one destructive command.
347fn ask_before_clearing(ctx: &mut RunContext<Board>) -> Result<RunOutcome> {
348    let count = ctx.state().tasks.len();
349    let mut step = ctx.step("confirm")?;
350    step.think("clearing cannot be undone, so a human decides")?;
351    stream(
352        &mut step,
353        &format!("Clearing drops {count} task(s) and cannot be undone."),
354    )?;
355    drop(step);
356
357    Ok(RunOutcome::interrupt(vec![clear_interrupt(count)]))
358}
359
360/// The question the client renders, with the schema its answer must satisfy.
361fn clear_interrupt(count: usize) -> Interrupt {
362    let mut schema = JsonObject::new();
363    schema.insert("type".to_owned(), json!("object"));
364    schema.insert(
365        "properties".to_owned(),
366        json!({"confirm": {"type": "boolean"}}),
367    );
368    schema.insert("required".to_owned(), json!(["confirm"]));
369
370    Interrupt {
371        id: CLEAR_INTERRUPT.to_owned(),
372        reason: "tool_approval".to_owned(),
373        message: Some(format!("Clear the board? {count} task(s) will be removed.")),
374        response_schema: Some(schema),
375        ..Default::default()
376    }
377}
378
379/// Streams one assistant message, a word per `TEXT_MESSAGE_CONTENT`.
380fn stream(ctx: &mut RunContext<Board>, text: &str) -> Result<()> {
381    let mut message = ctx.assistant_message()?;
382    for word in text.split_inclusive(' ') {
383        message.delta(word)?;
384    }
385    message.end()
386}
387
388/// Ships the board as an A2UI surface, in a tool result envelope.
389///
390/// The one call that does not go through [`offered`]: `render_a2ui` is the
391/// carrier the A2UI toolkits agreed on, not a tool a frontend offers, and
392/// neither AG-UI nor this SDK says whether an agent may call a tool that was
393/// never offered. It does here, as `e2e/tests/a2ui_surface.rs` does.
394fn render(ctx: &mut RunContext<Board>, intent: Intent) -> Result<()> {
395    let spec = board::surface(ctx.state());
396    let envelope =
397        wrap_as_operations_envelope(&assemble_ops(intent, &spec)).map_err(Error::agent)?;
398
399    let mut call = ctx.tool_call(RENDER_A2UI_TOOL_NAME)?;
400    call.args_json(&json!({"surfaceId": board::SURFACE_ID}))?;
401    call.result(envelope)?;
402    Ok(())
403}
404
405/// `Create` the first time the thread renders a surface, `Update` afterwards.
406///
407/// The agent stores nothing between runs, so the answer comes from the
408/// conversation the client sent: the toolkit replays the operations already in
409/// history and reports what the user is looking at.
410fn surface_intent(messages: &[Message]) -> Intent {
411    match find_prior_surface_in(messages) {
412        Some(prior) if !prior.deleted => Intent::Update,
413        _ => Intent::Create,
414    }
415}
416
417/// What the user asked for.
418#[derive(Clone, Debug, PartialEq, Eq)]
419pub enum Command {
420    /// `add draft the agenda, book the room` — one task per comma.
421    Add(Vec<String>),
422    /// `complete 1`, `complete agenda`.
423    Complete(String),
424    /// `estimate 2 45`.
425    Estimate {
426        /// Id or title fragment.
427        task: String,
428        /// Minutes.
429        minutes: u32,
430    },
431    /// `research onboarding` — the one that delegates to subagents.
432    Research(String),
433    /// `list`, `board`, `show`.
434    List,
435    /// `clear`, `reset` — the destructive one.
436    Clear,
437    /// Anything else.
438    Help,
439}
440
441impl Command {
442    /// Reads one line of chat.
443    pub fn parse(said: &str) -> Self {
444        let said = said.trim();
445        let (verb, rest) = match said.split_once(char::is_whitespace) {
446            Some((verb, rest)) => (verb, rest.trim()),
447            None => (said, ""),
448        };
449
450        match verb.to_lowercase().as_str() {
451            "add" | "todo" if !rest.is_empty() => Self::Add(
452                rest.split(',')
453                    .map(str::trim)
454                    .filter(|title| !title.is_empty())
455                    .map(str::to_owned)
456                    .collect(),
457            ),
458            "complete" | "done" | "finish" if !rest.is_empty() => Self::Complete(rest.to_owned()),
459            "estimate" | "est" => match rest.rsplit_once(char::is_whitespace) {
460                Some((task, minutes)) => match minutes.trim_end_matches('m').parse() {
461                    Ok(minutes) => Self::Estimate {
462                        task: task.trim().to_owned(),
463                        minutes,
464                    },
465                    Err(_) => Self::Help,
466                },
467                None => Self::Help,
468            },
469            "research" | "investigate" if !rest.is_empty() => Self::Research(rest.to_owned()),
470            "list" | "board" | "show" => Self::List,
471            "clear" | "reset" => Self::Clear,
472            _ => Self::Help,
473        }
474    }
475
476    /// The one line of reasoning the run publishes about it.
477    ///
478    /// `answer` is what came back from the interrupt, when this run is a
479    /// resumed one — the only command whose plan depends on it is [`Self::Clear`].
480    fn plan(&self, answer: Option<ResumeStatus>) -> String {
481        match self {
482            Self::Add(titles) => format!("adding {} task(s)", titles.len()),
483            Self::Complete(needle) => format!("looking for the task matching \"{needle}\""),
484            Self::Estimate { task, minutes } => format!("putting {minutes}m on \"{task}\""),
485            Self::Research(topic) => format!("delegating \"{topic}\" to two subagents"),
486            Self::List => "reading the board back".to_owned(),
487            Self::Clear => match answer {
488                Some(ResumeStatus::Resolved) => "a human approved clearing the board".to_owned(),
489                _ => "a human declined, so the board stays".to_owned(),
490            },
491            Self::Help => "that is not a command I know".to_owned(),
492        }
493    }
494}