1use super::{Delivery, DeliveryKind, DeliveryState, Event, Kind};
4use rig_core::message::{
5 AssistantContent, Message, ToolCall, ToolResult, ToolResultContent, UserContent,
6};
7use std::collections::{BTreeMap, BTreeSet};
8
9const HEADER_CHARS: usize = 180;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub struct BlockId(pub usize);
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum State {
17 Running,
18 Succeeded,
19 Failed(String),
20 CompletedUnknown,
21 Unfinished,
22}
23impl State {
24 fn label(&self) -> &str {
25 match self {
26 Self::Running => "running",
27 Self::Succeeded => "done",
28 Self::Failed(_) => "failed",
29 Self::CompletedUnknown => "completed (status not recorded)",
30 Self::Unfinished => "not completed",
31 }
32 }
33}
34#[derive(Debug, Clone)]
35pub struct Activity {
36 pub name: String,
37 pub target: String,
38 pub state: State,
39 pub orphan: bool,
40}
41#[derive(Debug, Default)]
42pub struct Group {
43 pub activities: Vec<Activity>,
44}
45impl Group {
46 pub fn text(&self, expanded: bool) -> String {
47 let active = self
48 .activities
49 .iter()
50 .find(|activity| activity.state == State::Running);
51 let failed = self
52 .activities
53 .iter()
54 .filter(|activity| matches!(activity.state, State::Failed(_)))
55 .collect::<Vec<_>>();
56 let mut text = match active {
57 Some(activity) => format!(
58 "{} {} {} — running{}",
59 if expanded { "▾" } else { "▸" },
60 activity_label(&activity.name),
61 activity.target,
62 if self.activities.len() > 1 {
63 format!(" · {} activities", self.activities.len())
64 } else {
65 String::new()
66 }
67 ),
68 None => {
69 let label = self
70 .activities
71 .first()
72 .map(|activity| activity_label(&activity.name))
73 .unwrap_or("Tools");
74 let target = self
75 .activities
76 .first()
77 .map(|activity| activity.target.as_str())
78 .unwrap_or_default();
79 format!(
80 "{} {label} {target} — {}{}",
81 if expanded { "▾" } else { "▸" },
82 if failed.is_empty() {
83 "completed"
84 } else {
85 "failed"
86 },
87 if self.activities.len() > 1 {
88 format!(" · {} activities", self.activities.len())
89 } else {
90 String::new()
91 }
92 )
93 }
94 };
95 for activity in failed {
97 let State::Failed(reason) = &activity.state else {
98 unreachable!("failed activities filtered");
99 };
100 text.push_str(&format!(
101 "{}{} {} — failed: {}",
102 if expanded { "\n " } else { " · " },
103 activity.name,
104 activity.target,
105 reason
106 ));
107 }
108 if expanded {
109 for activity in &self.activities {
110 if matches!(activity.state, State::Failed(_)) {
111 continue;
112 }
113 text.push_str(&format!(
114 "\n {} {} — {}{}",
115 activity.name,
116 activity.target,
117 activity.state.label(),
118 if activity.orphan {
119 " (unmatched recorded result)"
120 } else {
121 ""
122 }
123 ));
124 }
125 } else if self.activities.iter().any(|activity| {
126 activity.orphan || matches!(activity.state, State::CompletedUnknown | State::Unfinished)
127 }) {
128 text.push_str(" · incomplete/status unreported");
129 }
130 text.push_str("\n\n");
131 text
132 }
133}
134
135pub struct Patch {
138 pub id: BlockId,
139 pub text: String,
140}
141
142struct LiveBlock {
143 id: BlockId,
144 text: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
150struct Correlation(String);
151
152impl Correlation {
153 fn of(id: &rig_core::message::CallId) -> Self {
154 Self(id.wire().into_owned())
155 }
156}
157
158#[derive(Default)]
159pub struct Transcript {
160 blocks: usize,
161 groups: BTreeMap<BlockId, Group>,
162 calls: BTreeMap<(Option<usize>, Correlation), (BlockId, usize)>,
163 active: Option<BlockId>,
164 expanded: BTreeSet<BlockId>,
165 assistant: Option<crate::types::Sha256Bytes>,
166 question: Option<(Option<usize>, crate::types::Sha256Bytes)>,
167 result_candidate: Option<(BlockId, serde_json::Value)>,
169 deliveries: BTreeMap<
170 (
171 Option<usize>,
172 usize,
173 usize,
174 DeliveryKind,
175 crate::types::DeliveryBlock,
176 ),
177 LiveBlock,
178 >,
179 delivered_turns: BTreeSet<(Option<usize>, usize)>,
180 thinking: BTreeSet<BlockId>,
181}
182fn candidate(text: &str) -> Option<serde_json::Value> {
185 if text.len() > crate::agent::verdict::MAX_RESPONSE_BYTES {
186 return None;
187 }
188 let mut value: serde_json::Value = serde_json::from_str(text).ok()?;
189 if value.get("verdict").is_none() && value.as_object()?.len() == 1 {
191 value = value.get("result")?.clone();
192 if let Some(text) = value.as_str() {
193 value = serde_json::from_str(text).ok()?;
194 }
195 }
196 if value.as_object()?.len() == 2 && value.get("verdictFields").is_some() {
197 let verdict = value.get("verdict")?.clone();
198 let mut fields = value.get("verdictFields")?.clone();
199 if let Some(text) = fields.as_str() {
200 fields = serde_json::from_str(text).ok()?;
201 }
202 let object = fields.as_object_mut()?;
203 if object.get("verdict").is_some_and(|value| value != &verdict) {
204 return None;
205 }
206 object.insert("verdict".into(), verdict);
207 value = fields;
208 }
209 matches!(value.get("verdict")?.as_str()?, "GREEN" | "RED").then_some(value)
210}
211
212fn same_result(wire: &serde_json::Value, result: &serde_json::Value) -> bool {
213 if wire == result {
214 return true;
215 }
216 match (wire, result) {
217 (serde_json::Value::String(text), _) => {
218 serde_json::from_str(text).is_ok_and(|decoded| same_result(&decoded, result))
219 }
220 (serde_json::Value::Object(wire), serde_json::Value::Object(result)) => {
221 result
222 .iter()
223 .all(|(key, value)| wire.get(key).is_some_and(|wire| same_result(wire, value)))
224 && wire
225 .iter()
226 .all(|(key, value)| result.contains_key(key) || value.is_null())
227 }
228 (serde_json::Value::Array(wire), serde_json::Value::Array(result)) => {
229 wire.len() == result.len()
230 && wire
231 .iter()
232 .zip(result)
233 .all(|(wire, result)| same_result(wire, result))
234 }
235 _ => false,
236 }
237}
238
239impl Transcript {
240 fn slot(&mut self) -> BlockId {
241 let id = BlockId(self.blocks);
242 self.blocks += 1;
243 id
244 }
245 fn prose(&mut self, text: &str, user: bool, patches: &mut Vec<Patch>) {
246 if text.trim().is_empty() {
247 return;
248 }
249 self.active = None;
250 let candidate = if !user { candidate(text) } else { None };
251 let text = markdown(text);
252 if !user {
253 self.assistant = Some(crate::types::Sha256Bytes::of(text.trim().as_bytes()));
254 }
255 let id = self.slot();
256 self.result_candidate = candidate.map(|value| (id, value));
257 patches.push(Patch {
258 id,
259 text: format!("{}{text}\n\n", if user { "You\n" } else { "" }),
260 });
261 }
262 fn group(&mut self) -> BlockId {
263 if let Some(id) = self.active {
264 return id;
265 }
266 let id = self.slot();
267 self.groups.insert(id, Group::default());
268 self.active = Some(id);
269 id
270 }
271 fn call(&mut self, send: Option<usize>, call: &ToolCall, dirty: &mut BTreeSet<BlockId>) {
272 let key = (send, Correlation::of(&call.id));
273 if self.calls.contains_key(&key) {
275 return;
276 }
277 let id = self.group();
278 let group = self.groups.get_mut(&id).expect("active group exists");
279 let index = group.activities.len();
280 group.activities.push(Activity {
281 name: header(call.function.name.as_str()),
282 target: target(call),
283 state: State::Running,
284 orphan: false,
285 });
286 self.calls.insert(key, (id, index));
287 dirty.insert(id);
288 }
289 fn result(
290 &mut self,
291 send: Option<usize>,
292 result: &ToolResult,
293 failed: Option<bool>,
294 dirty: &mut BTreeSet<BlockId>,
295 ) {
296 let key = (send, Correlation::of(&result.call));
297 let state = match failed {
298 Some(true) => State::Failed(failure_reason(result)),
299 Some(false) => State::Succeeded,
300 None => State::CompletedUnknown,
301 };
302 let (id, index) = match self.calls.get(&key).copied() {
303 Some(link) => link,
304 None => {
305 let id = self.group();
306 let group = self.groups.get_mut(&id).expect("active group exists");
307 let index = group.activities.len();
308 group.activities.push(Activity {
309 name: header(result.name.as_str()),
310 target: "target not recorded".into(),
311 state: state.clone(),
312 orphan: true,
313 });
314 self.calls.insert(key, (id, index));
315 (id, index)
316 }
317 };
318 self.groups
319 .get_mut(&id)
320 .expect("linked group exists")
321 .activities[index]
322 .state = state;
323 dirty.insert(id);
324 }
325 fn stopped(&mut self, dirty: &mut BTreeSet<BlockId>) {
326 for (id, group) in &mut self.groups {
327 for activity in &mut group.activities {
328 if activity.state == State::Running {
329 activity.state = State::Unfinished;
330 dirty.insert(*id);
331 }
332 }
333 }
334 self.active = None;
335 }
336 fn delivery(&mut self, send: Option<usize>, delivery: &Delivery) -> Vec<Patch> {
337 let mut patches = Vec::new();
338 if delivery.block.is_attempt() && delivery.state == DeliveryState::Interrupted {
339 for ((follow, turn, attempt, kind, _), block) in &self.deliveries {
340 if (*follow, *turn, *attempt) == (send, delivery.turn, delivery.attempt)
341 && !block.text.is_empty()
342 {
343 let label = if *kind == DeliveryKind::Summary {
344 "Thinking · partial"
345 } else {
346 "Partial response"
347 };
348 patches.push(Patch {
349 id: block.id,
350 text: format!("{label} (interrupted)\n{}\n\n", block.text),
351 });
352 }
353 }
354 return patches;
355 }
356 self.active = None;
357 let key = (
358 send,
359 delivery.turn,
360 delivery.attempt,
361 delivery.kind,
362 delivery.block.clone(),
363 );
364 let id = if let Some(block) = self.deliveries.get(&key) {
365 block.id
366 } else {
367 let id = self.slot();
368 self.deliveries.insert(
369 key.clone(),
370 LiveBlock {
371 id,
372 text: String::new(),
373 },
374 );
375 id
376 };
377 let block = self
378 .deliveries
379 .get_mut(&key)
380 .expect("delivery block exists");
381 match delivery.state {
382 DeliveryState::Delta => block.text.push_str(&delivery.text),
383 DeliveryState::Complete | DeliveryState::Interrupted => {
384 block.text = delivery.text.clone()
385 }
386 }
387 let text = block.text.clone();
388 if delivery.kind == DeliveryKind::Summary {
389 self.thinking.insert(id);
390 }
391 if delivery.kind == DeliveryKind::Text {
392 self.delivered_turns.insert((send, delivery.turn));
393 }
394 if delivery.kind == DeliveryKind::Text && delivery.state == DeliveryState::Complete {
395 self.result_candidate = candidate(&text).map(|value| (id, value));
396 self.assistant = Some(crate::types::Sha256Bytes::of(
397 markdown(&text).trim().as_bytes(),
398 ));
399 }
400 if delivery.state == DeliveryState::Complete {
401 self.deliveries
402 .get_mut(&key)
403 .expect("delivery exists")
404 .text
405 .clear();
406 }
407 patches.push(Patch {
408 id,
409 text: format!(
410 "{}{}{}\n\n",
411 if delivery.kind == DeliveryKind::Summary {
412 "Thinking\n"
413 } else {
414 ""
415 },
416 markdown(&text),
417 if delivery.state == DeliveryState::Interrupted {
418 "\n[partial, interrupted]"
419 } else {
420 ""
421 }
422 ),
423 });
424 patches
425 }
426 pub fn is_thinking(&self, id: BlockId) -> bool {
427 self.thinking.contains(&id)
428 }
429 pub fn apply(&mut self, event: &Event) -> Vec<Patch> {
430 let mut patches = Vec::new();
431 let mut dirty = BTreeSet::new();
432 match &event.kind {
433 Kind::Delivery(delivery) => return self.delivery(event.send, delivery),
434 Kind::Review(_) | Kind::Attempt(_) => {}
435 Kind::Send(send) => {
436 self.active = None;
437 self.assistant = None;
438 if let Some(text) = &send.text {
439 self.prose(text, true, &mut patches);
440 self.question = Some((
441 event.send,
442 crate::types::Sha256Bytes::of(text.trim().as_bytes()),
443 ));
444 }
445 }
446 Kind::Message(message) => {
447 if let Some(question) = &message.question {
448 let identity = (
449 event.send,
450 crate::types::Sha256Bytes::of(question.trim().as_bytes()),
451 );
452 if self.question.as_ref() != Some(&identity) {
453 self.prose(question, true, &mut patches);
454 }
455 self.question = None;
456 } else {
457 match &message.message {
458 Message::System { .. } => {}
459 Message::Assistant { content, .. } => {
460 for part in content {
461 match part {
462 AssistantContent::Text(text) => {
463 if !self
464 .delivered_turns
465 .contains(&(event.send, message.turn))
466 {
467 self.prose(&text.text, false, &mut patches);
468 }
469 }
470 AssistantContent::ToolCall(call) => {
471 self.call(event.send, call, &mut dirty)
472 }
473 AssistantContent::Reasoning(_) | AssistantContent::Image(_) => {
474 }
475 }
476 }
477 }
478 Message::User { content } => {
479 let mut result_index = 0;
480 for part in content {
481 match part {
482 UserContent::Text(text) => {
483 self.prose(&text.text, true, &mut patches)
484 }
485 UserContent::ToolResult(result) => {
486 self.result(
487 event.send,
488 result,
489 message.is_error.get(result_index).copied(),
490 &mut dirty,
491 );
492 result_index += 1;
493 }
494 _ => {}
495 }
496 }
497 }
498 }
499 }
500 }
501 Kind::End(end) => {
502 self.stopped(&mut dirty);
503 if let Some(result) = end.result() {
504 let value = result.to_json();
505 let text = result_text(result);
506 if let Some((id, candidate)) = self.result_candidate.take()
507 && same_result(&candidate, &value)
508 {
509 patches.push(Patch {
510 id,
511 text: format!("{text}\n\n"),
512 });
513 } else {
514 self.prose(&text, false, &mut patches);
515 }
516 }
517 if let Some((code, error)) = end.failure() {
518 self.prose(
519 &format!(
520 "Review failed: {}\n{error}",
521 code.map(crate::agent::error::Code::as_str)
522 .unwrap_or_default()
523 ),
524 false,
525 &mut patches,
526 );
527 }
528 }
529 Kind::Answer(answer) => {
530 if let Some(text) = answer.text() {
531 let digest = crate::types::Sha256Bytes::of(markdown(text).trim().as_bytes());
532 if self.assistant != Some(digest) {
533 self.prose(text, false, &mut patches);
534 }
535 }
536 if let Some((code, error)) = answer.failure() {
537 self.stopped(&mut dirty);
538 self.prose(
539 &format!(
540 "Follow-up failed: {}\n{error}",
541 code.map(crate::agent::error::Code::as_str)
542 .unwrap_or_default()
543 ),
544 false,
545 &mut patches,
546 );
547 }
548 }
549 }
550 for id in dirty {
551 patches.push(Patch {
552 id,
553 text: self.groups[&id].text(self.expanded.contains(&id)),
554 });
555 }
556 patches.sort_by_key(|patch| patch.id);
557 patches
558 }
559 pub fn expansion(&mut self, expanded: &BTreeSet<BlockId>) -> Vec<Patch> {
560 let changed = self
561 .expanded
562 .symmetric_difference(expanded)
563 .copied()
564 .collect::<Vec<_>>();
565 self.expanded = expanded.clone();
566 changed
567 .into_iter()
568 .filter_map(|id| {
569 self.groups.get(&id).map(|group| Patch {
570 id,
571 text: group.text(self.expanded.contains(&id)),
572 })
573 })
574 .collect()
575 }
576 pub fn is_group(&self, id: BlockId) -> bool {
577 self.groups.contains_key(&id)
578 }
579}
580
581fn header(text: &str) -> String {
582 text.chars()
583 .map(|character| {
584 if character.is_control() {
585 ' '
586 } else {
587 character
588 }
589 })
590 .take(HEADER_CHARS)
591 .collect()
592}
593fn activity_label(name: &str) -> &str {
594 if name == "read" || name.starts_with("read_") {
595 "Read"
596 } else if name == "grep"
597 || name == "glob"
598 || name.starts_with("grep_")
599 || name.starts_with("glob_")
600 {
601 "Search"
602 } else if name == "list" || name.starts_with("list_") {
603 "List"
604 } else if name == "view_image" || name.starts_with("view_image_") {
605 "View image"
606 } else {
607 name
608 }
609}
610fn target(call: &ToolCall) -> String {
611 let name = call.function.name.as_str();
612 let builtin = ["read", "list", "glob", "grep", "view_image"]
613 .iter()
614 .any(|base| {
615 name == *base
616 || name
617 .strip_prefix(base)
618 .is_some_and(|rest| rest.starts_with('_'))
619 });
620 if !builtin {
621 return "target not recorded".into();
622 }
623 let args = &call.function.arguments;
624 let path = args
625 .get("path")
626 .and_then(serde_json::Value::as_str)
627 .map(header)
628 .filter(|text| !text.is_empty())
629 .unwrap_or_else(|| ".".into());
630 match args.get("pattern").and_then(serde_json::Value::as_str) {
631 Some(pattern) => format!("{path} · {}", header(pattern)),
632 None => path,
633 }
634}
635fn failure_reason(result: &ToolResult) -> String {
636 result
637 .content
638 .iter()
639 .find_map(|part| match part {
640 ToolResultContent::Text(text) => Some(header(&text.text)),
641 _ => None,
642 })
643 .filter(|text| !text.is_empty())
644 .unwrap_or_else(|| "failure reason not recorded".into())
645}
646
647fn result_text(result: &crate::agent::verdict::ValidatedResult) -> String {
650 fn field(label: &str, value: &serde_json::Value, indent: usize, lines: &mut Vec<String>) {
651 let prefix = " ".repeat(indent);
652 match value {
653 serde_json::Value::Object(fields) => {
654 if !label.is_empty() {
655 lines.push(format!("{prefix}{label}"));
656 }
657 for (name, value) in fields {
658 field(name, value, indent + usize::from(!label.is_empty()), lines);
659 }
660 }
661 serde_json::Value::Array(items) => {
662 if !label.is_empty() {
663 lines.push(format!("{prefix}{label}"));
664 }
665 for value in items {
666 field("-", value, indent + 1, lines);
667 }
668 }
669 serde_json::Value::String(text) => lines.push(format!(
670 "{prefix}{}{}",
671 if label.is_empty() {
672 String::new()
673 } else {
674 format!("{label}: ")
675 },
676 markdown(text)
677 )),
678 value => lines.push(format!("{prefix}{label}: {value}")),
679 }
680 }
681 let mut lines = vec![format!("Verdict: {}", header(result.verdict.as_str()))];
682 for (name, value) in &result.fields {
683 field(name, value, 0, &mut lines);
684 }
685 lines.join("\n")
686}
687
688pub fn markdown(text: &str) -> String {
691 let mut code = false;
692 text.lines()
693 .map(|line| {
694 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
695 code = !code;
696 return String::new();
697 }
698 if code {
699 return format!(" {line}");
700 }
701 let heading = line.trim_start_matches('#');
702 if heading.len() != line.len() && heading.starts_with(' ') {
703 heading.trim_start().to_owned()
704 } else {
705 line.to_owned()
706 }
707 })
708 .collect::<Vec<_>>()
709 .join("\n")
710}