1use 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
39pub const CLEAR_INTERRUPT: &str = "confirm-clear";
41
42#[derive(Debug, Default)]
44pub struct TaskBoard {
45 voice: Option<Voice>,
47}
48
49impl TaskBoard {
50 pub fn scripted() -> Self {
52 Self { voice: None }
53 }
54
55 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 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 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 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
128struct Report {
130 reply: String,
132 render: bool,
134}
135
136fn 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 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 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 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 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 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
305fn 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
322const 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
326fn 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
346fn 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
360fn 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
379fn 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
388fn 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
405fn 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#[derive(Clone, Debug, PartialEq, Eq)]
419pub enum Command {
420 Add(Vec<String>),
422 Complete(String),
424 Estimate {
426 task: String,
428 minutes: u32,
430 },
431 Research(String),
433 List,
435 Clear,
437 Help,
439}
440
441impl Command {
442 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 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}