ag_ui/server/verify.rs
1//! The ordering state machine.
2//!
3//! `TEXT_MESSAGE_CONTENT` without a preceding `TEXT_MESSAGE_START` is a bug
4//! that currently surfaces as a confused frontend, three network hops from
5//! where it was caused. Neither the TypeScript SDK (which verifies on the
6//! client) nor the .NET one (which does not verify) catches it on the server.
7//! This crate does, on by default.
8//!
9//! # What it rejects
10//!
11//! | Rule | Rejected |
12//! | --- | --- |
13//! | [`RunEnded`] | anything after `RUN_FINISHED` / `RUN_ERROR` |
14//! | [`DuplicateRunStarted`] | a second `RUN_STARTED` |
15//! | [`DuplicateStart`] | opening a message, reasoning block, tool call, step or subagent whose id is already open — or a subagent id that already finished in this run |
16//! | [`NotOpen`] | content or a terminator for something that was never opened, including `SUBAGENT_FINISHED` / `SUBAGENT_ERROR` for a subagent that is not active |
17//! | [`UnknownId`] | `TOOL_CALL_RESULT` for a call id that was never introduced, or a `parentSubagentRunId` that was never started |
18//! | [`OutOfOrder`] | `TOOL_CALL_RESULT` before the call's `TOOL_CALL_END` |
19//! | [`OpenAtFinish`] | `RUN_FINISHED` while a message, reasoning block, tool call, step or subagent is open |
20//! | [`OwnerMismatch`] | a tagged continuation, terminator or re-open whose `subagentRunId` is not the one that opened the entity — a message, reasoning block, tool call, activity, or the entity a `REASONING_ENCRYPTED_VALUE` names; a tool call tagged with one subagent whose parent message belongs to another |
21//!
22//! `RUN_ERROR` is exempt from [`OpenAtFinish`]: a run that blew up mid-message
23//! could not have closed it.
24//!
25//! # Subagents
26//!
27//! Every entity is opened *by someone* — a subagent, or the parent agent when
28//! the opener carries no `subagentRunId` — and the verifier remembers who.
29//! A later event that *names* a different owner is rejected; one that names
30//! none is accepted, because attribution is optional on every event and a
31//! bare continuation is what a pre-subagent producer sends — and it does not
32//! hand the entity to the parent either: the first writer stays the owner, as
33//! upstream records it. Steps are keyed by owner as well as name, so a
34//! subagent cannot close the parent's step, or a sibling's, and two agents may
35//! run a step of the same name at once. A `MESSAGES_SNAPSHOT` seeds ownership
36//! from the messages it carries and is authoritative; the `RUN_STARTED` input
37//! echo seeds it too, for ids not yet recorded; a `TOOL_CALL_RESULT` mints the
38//! tool message it names under its own attribution.
39//!
40//! What is deliberately *not* checked, because the protocol does not require
41//! it: that an attributing `subagentRunId` was announced by `SUBAGENT_STARTED`
42//! (attribution without lifecycle events is a supported mode), that a
43//! subagent's own messages are closed before its `SUBAGENT_FINISHED`, or that
44//! events stop after it — a `parentSubagentRunId` may even name a subagent
45//! that already finished, since a parent legitimately finishes before its
46//! child. What *is* required is that every started subagent is closed before
47//! `RUN_FINISHED`.
48//!
49//! [`RunEnded`]: crate::server::Rule::RunEnded
50//! [`DuplicateRunStarted`]: crate::server::Rule::DuplicateRunStarted
51//! [`DuplicateStart`]: crate::server::Rule::DuplicateStart
52//! [`NotOpen`]: crate::server::Rule::NotOpen
53//! [`UnknownId`]: crate::server::Rule::UnknownId
54//! [`OutOfOrder`]: crate::server::Rule::OutOfOrder
55//! [`OpenAtFinish`]: crate::server::Rule::OpenAtFinish
56//! [`OwnerMismatch`]: crate::server::Rule::OwnerMismatch
57//!
58//! # What it lets through
59//!
60//! The `*_CHUNK` events are self-contained by design, so a chunk carrying a new
61//! id registers that id rather than being rejected for having no start. The
62//! deprecated `THINKING_*` family is not tracked at all. State, activity, raw
63//! and custom events are unordered — though an activity delta, like any
64//! continuation, may not name an owner other than its activity's.
65//!
66//! # Cost
67//!
68//! A handful of maps and one lookup per event. Turning the `verify` feature
69//! off replaces the whole state machine with a zero-sized type whose
70//! `observe` is an inlined `Ok(())`. In debug builds a rejection additionally
71//! carries a dump of everything still open, which is the expensive part and is
72//! why it is debug-only.
73
74#[cfg(feature = "verify")]
75pub(crate) use enabled::Verifier;
76
77#[cfg(not(feature = "verify"))]
78pub(crate) use disabled::Verifier;
79
80#[cfg(feature = "verify")]
81mod enabled {
82 use std::collections::{HashMap, HashSet};
83 use std::fmt::Write as _;
84
85 use crate::{
86 Event, Message, MessageId, ReasoningEncryptedValueSubtype, StepName, SubagentRunId,
87 ToolCallId,
88 };
89
90 use crate::server::error::{Rule, VerificationError};
91
92 /// Who opened an entity: a subagent, or the parent agent when `None`.
93 type Owner = Option<SubagentRunId>;
94
95 /// Builds a rejection, appending the open-entity dump in debug builds.
96 fn reject(
97 event: &Event,
98 rule: Rule,
99 detail: impl Into<String>,
100 open: impl FnOnce() -> String,
101 ) -> VerificationError {
102 let mut detail = detail.into();
103 if cfg!(debug_assertions) {
104 detail.push_str(&open());
105 }
106 VerificationError::new(event.event_type(), rule, detail)
107 }
108
109 fn describe(owner: &Owner) -> String {
110 match owner {
111 None => "the parent agent".to_owned(),
112 Some(id) => format!("subagent {id:?}"),
113 }
114 }
115
116 /// Tracks what is open, and who opened it, so misordered events can be
117 /// named precisely.
118 #[derive(Debug, Default)]
119 pub(crate) struct Verifier {
120 started: bool,
121 ended: bool,
122 messages: HashMap<MessageId, Owner>,
123 reasoning: HashMap<MessageId, Owner>,
124 reasoning_messages: HashMap<MessageId, Owner>,
125 tool_calls: HashMap<ToolCallId, Owner>,
126 /// Every tool call ever introduced — by a start, a chunk or a
127 /// snapshot — and who owns it.
128 known_tool_calls: HashMap<ToolCallId, Owner>,
129 /// Every text message ever introduced and who owns it: the first
130 /// writer. A tool call belongs to the message its `parentMessageId`
131 /// names, so the owner has to outlive the message being open.
132 message_owners: HashMap<MessageId, Owner>,
133 /// The same for reasoning — the block and the message inside it share
134 /// an id and an owner. A bucket of its own, as upstream keeps it, so
135 /// a producer that reuses an id across the two kinds is not accused
136 /// of contradicting itself.
137 reasoning_owners: HashMap<MessageId, Owner>,
138 /// Every activity ever introduced and who owns it. Opened by a
139 /// snapshot, continued by deltas against the same id.
140 activity_owners: HashMap<MessageId, Owner>,
141 /// Open steps, keyed by owner as well as name: a subagent routinely
142 /// runs the same graph shape as its parent, and neither may close the
143 /// other's step.
144 steps: HashSet<(Owner, StepName)>,
145 active_subagents: HashSet<SubagentRunId>,
146 /// Ids closed in this run. An id names one invocation, so a second
147 /// `SUBAGENT_STARTED` for a closed one is a producer bug — but
148 /// attribution-only producers, which tag events and never announce,
149 /// are not required to have started anything.
150 closed_subagents: HashSet<SubagentRunId>,
151 }
152
153 impl Verifier {
154 pub(crate) fn new() -> Self {
155 Self::default()
156 }
157
158 /// Checks `event` against the state machine and folds it in.
159 pub(crate) fn observe(&mut self, event: &Event) -> Result<(), VerificationError> {
160 if self.ended {
161 return Err(self.fail(event, Rule::RunEnded, "the run already ended"));
162 }
163
164 match event {
165 Event::RunStarted(payload) => {
166 if self.started {
167 return Err(self.fail(
168 event,
169 Rule::DuplicateRunStarted,
170 "the run already started",
171 ));
172 }
173 self.started = true;
174 // The input echo replays history the consumer applies, so
175 // it seeds ownership like a snapshot does — for ids not
176 // yet recorded, since it is history rather than a rewrite.
177 if let Some(input) = &payload.input {
178 self.seed_owners(&input.messages, false);
179 }
180 }
181 Event::RunFinished(_) => {
182 if let Some(detail) = self.first_open() {
183 return Err(self.fail(event, Rule::OpenAtFinish, detail));
184 }
185 self.ended = true;
186 }
187 Event::RunError(_) => self.ended = true,
188
189 Event::TextMessageStart(payload) => {
190 self.open(
191 event,
192 Kind::Message,
193 &payload.message_id,
194 &payload.subagent_run_id,
195 )?;
196 }
197 Event::TextMessageContent(payload) => {
198 self.require(
199 event,
200 Kind::Message,
201 &payload.message_id,
202 &payload.subagent_run_id,
203 )?;
204 }
205 Event::TextMessageEnd(payload) => {
206 self.close(
207 event,
208 Kind::Message,
209 &payload.message_id,
210 &payload.subagent_run_id,
211 )?;
212 }
213 Event::TextMessageChunk(payload) => {
214 if let Some(id) = &payload.message_id {
215 // Self-contained: a chunk needs no bracketing events,
216 // but it still says who the message belongs to.
217 self.messages.remove(id);
218 self.claim(event, Kind::Message, id, &payload.subagent_run_id)?;
219 }
220 }
221
222 Event::ReasoningStart(payload) => {
223 self.open(
224 event,
225 Kind::Reasoning,
226 &payload.message_id,
227 &payload.subagent_run_id,
228 )?;
229 }
230 Event::ReasoningEnd(payload) => {
231 self.close(
232 event,
233 Kind::Reasoning,
234 &payload.message_id,
235 &payload.subagent_run_id,
236 )?;
237 }
238 Event::ReasoningMessageStart(payload) => {
239 self.open(
240 event,
241 Kind::ReasoningMessage,
242 &payload.message_id,
243 &payload.subagent_run_id,
244 )?;
245 }
246 Event::ReasoningMessageContent(payload) => {
247 self.require(
248 event,
249 Kind::ReasoningMessage,
250 &payload.message_id,
251 &payload.subagent_run_id,
252 )?;
253 }
254 Event::ReasoningMessageEnd(payload) => {
255 self.close(
256 event,
257 Kind::ReasoningMessage,
258 &payload.message_id,
259 &payload.subagent_run_id,
260 )?;
261 }
262 Event::ReasoningMessageChunk(payload) => {
263 if let Some(id) = &payload.message_id {
264 self.reasoning_messages.remove(id);
265 self.claim(event, Kind::ReasoningMessage, id, &payload.subagent_run_id)?;
266 }
267 }
268
269 Event::ToolCallStart(payload) => {
270 let id = &payload.tool_call_id;
271 let owner = self.resolve_tool_call_owner(
272 event,
273 id,
274 payload.parent_message_id.as_ref(),
275 &payload.subagent_run_id,
276 )?;
277 if self.tool_calls.contains_key(id) {
278 return Err(self.fail(
279 event,
280 Rule::DuplicateStart,
281 format!("tool call {id:?} is already open"),
282 ));
283 }
284 self.tool_calls.insert(id.clone(), owner.clone());
285 self.known_tool_calls.insert(id.clone(), owner);
286 }
287 Event::ToolCallArgs(payload) => {
288 self.require_tool_call(event, &payload.tool_call_id, &payload.subagent_run_id)?;
289 }
290 Event::ToolCallEnd(payload) => {
291 self.require_tool_call(event, &payload.tool_call_id, &payload.subagent_run_id)?;
292 self.tool_calls.remove(&payload.tool_call_id);
293 }
294 Event::ToolCallChunk(payload) => {
295 if let Some(id) = &payload.tool_call_id {
296 let owner = self.resolve_tool_call_owner(
297 event,
298 id,
299 payload.parent_message_id.as_ref(),
300 &payload.subagent_run_id,
301 )?;
302 self.tool_calls.remove(id);
303 self.known_tool_calls.insert(id.clone(), owner);
304 }
305 }
306 // A result's attribution is its own — the party that executes
307 // a call can differ from the one that requested it — so the
308 // owner is not checked here, only the call's state.
309 Event::ToolCallResult(payload) => {
310 let id = &payload.tool_call_id;
311 if !self.known_tool_calls.contains_key(id) {
312 return Err(self.fail(
313 event,
314 Rule::UnknownId,
315 format!("tool call {id:?} was never started"),
316 ));
317 }
318 if self.tool_calls.contains_key(id) {
319 return Err(self.fail(
320 event,
321 Rule::OutOfOrder,
322 format!("tool call {id:?} has no TOOL_CALL_END yet"),
323 ));
324 }
325 // A result mints the tool message it names, under its own
326 // attribution — so the newest mint wins, not the first
327 // writer, and a re-open of that message by someone else
328 // has an owner to disagree with.
329 self.message_owners
330 .insert(payload.message_id.clone(), payload.subagent_run_id.clone());
331 }
332
333 // An activity is opened by a snapshot and continued by deltas
334 // against the same id. Only a *replacing* snapshot re-mints
335 // it and so re-owns it; with `replace: false` the consumer
336 // leaves the existing message where it was, and so does the
337 // recorded owner.
338 Event::ActivitySnapshot(payload) => {
339 let id = &payload.message_id;
340 if payload.replace || !self.activity_owners.contains_key(id) {
341 self.activity_owners
342 .insert(id.clone(), payload.subagent_run_id.clone());
343 }
344 }
345 Event::ActivityDelta(payload) => {
346 if let Some(owner) = self.activity_owners.get(&payload.message_id) {
347 let tag = &payload.subagent_run_id;
348 if tag.is_some() && owner != tag {
349 return Err(self.fail(
350 event,
351 Rule::OwnerMismatch,
352 format!(
353 "activity {:?} belongs to {}, not {}",
354 payload.message_id,
355 describe(owner),
356 describe(tag)
357 ),
358 ));
359 }
360 }
361 }
362
363 // Continues an entity by id, and `subtype` says which kind —
364 // a tool call's owner lives in a different map from a
365 // message's.
366 Event::ReasoningEncryptedValue(payload) => {
367 let tag = &payload.subagent_run_id;
368 let (what, owner) = match payload.subtype {
369 ReasoningEncryptedValueSubtype::ToolCall => (
370 "tool call",
371 self.known_tool_calls
372 .get(&ToolCallId::new(payload.entity_id.clone())),
373 ),
374 // "message" spans both kinds; ids are unique per kind,
375 // so at most one bucket answers.
376 ReasoningEncryptedValueSubtype::Message => {
377 let id = MessageId::new(payload.entity_id.clone());
378 (
379 "message",
380 self.message_owners
381 .get(&id)
382 .or_else(|| self.reasoning_owners.get(&id)),
383 )
384 }
385 };
386 if let Some(owner) = owner {
387 if tag.is_some() && owner != tag {
388 return Err(self.fail(
389 event,
390 Rule::OwnerMismatch,
391 format!(
392 "{what} {:?} belongs to {}, not {}",
393 payload.entity_id,
394 describe(owner),
395 describe(tag)
396 ),
397 ));
398 }
399 }
400 }
401
402 Event::StepStarted(payload) => {
403 let key = (payload.subagent_run_id.clone(), payload.step_name.clone());
404 if self.steps.contains(&key) {
405 return Err(self.fail(
406 event,
407 Rule::DuplicateStart,
408 format!(
409 "step {:?} is already open under {}",
410 payload.step_name,
411 describe(&payload.subagent_run_id)
412 ),
413 ));
414 }
415 self.steps.insert(key);
416 }
417 Event::StepFinished(payload) => {
418 let key = (payload.subagent_run_id.clone(), payload.step_name.clone());
419 if !self.steps.remove(&key) {
420 return Err(self.fail(
421 event,
422 Rule::NotOpen,
423 format!(
424 "step {:?} is not open under {}",
425 payload.step_name,
426 describe(&payload.subagent_run_id)
427 ),
428 ));
429 }
430 }
431
432 Event::SubagentStarted(payload) => {
433 let id = &payload.subagent_run_id;
434 if self.active_subagents.contains(id) {
435 return Err(self.fail(
436 event,
437 Rule::DuplicateStart,
438 format!("subagent {id:?} is already active"),
439 ));
440 }
441 if self.closed_subagents.contains(id) {
442 return Err(self.fail(
443 event,
444 Rule::DuplicateStart,
445 format!(
446 "subagent {id:?} already finished in this run; an id names one invocation"
447 ),
448 ));
449 }
450 if let Some(parent) = &payload.parent_subagent_run_id {
451 if !self.active_subagents.contains(parent)
452 && !self.closed_subagents.contains(parent)
453 {
454 return Err(self.fail(
455 event,
456 Rule::UnknownId,
457 format!("parent subagent {parent:?} was never started"),
458 ));
459 }
460 }
461 self.active_subagents.insert(id.clone());
462 }
463 Event::SubagentFinished(payload) => {
464 self.close_subagent(event, &payload.subagent_run_id)?;
465 }
466 Event::SubagentError(payload) => {
467 self.close_subagent(event, &payload.subagent_run_id)?;
468 }
469
470 // Authoritative: the snapshot restates the conversation, so its
471 // owners replace whatever was recorded.
472 Event::MessagesSnapshot(payload) => {
473 self.seed_owners(&payload.messages, true);
474 }
475
476 _ => {}
477 }
478
479 Ok(())
480 }
481
482 /// Records the owners of replayed messages, and of the tool calls
483 /// they carry: authoritatively for a `MESSAGES_SNAPSHOT`, which
484 /// restates the conversation, and for ids not yet recorded when the
485 /// `RUN_STARTED` echo replays history.
486 fn seed_owners(&mut self, messages: &[Message], authoritative: bool) {
487 for message in messages {
488 let owner = message.subagent_run_id().cloned();
489 let bucket = match message {
490 Message::Activity(_) => &mut self.activity_owners,
491 Message::Reasoning(_) => &mut self.reasoning_owners,
492 _ => &mut self.message_owners,
493 };
494 if authoritative || !bucket.contains_key(message.id()) {
495 bucket.insert(message.id().clone(), owner.clone());
496 }
497 if let Message::Assistant(assistant) = message {
498 for call in assistant.tool_calls.iter().flatten() {
499 if authoritative || !self.known_tool_calls.contains_key(&call.id) {
500 self.known_tool_calls.insert(call.id.clone(), owner.clone());
501 }
502 }
503 }
504 }
505 }
506
507 /// Records who `id` belongs to — the first writer — or rejects a claim
508 /// that disagrees with the recorded owner, and returns the owner in
509 /// force.
510 ///
511 /// An untagged claim on an owned message is accepted and does *not*
512 /// hand the message to the parent: attribution is optional per
513 /// event, so an absent tag agrees with any owner, and the consumer
514 /// keeps the message where it was. Upstream's verifier records the
515 /// first writer for the same reason.
516 fn claim(
517 &mut self,
518 event: &Event,
519 kind: Kind,
520 id: &MessageId,
521 tag: &Owner,
522 ) -> Result<Owner, VerificationError> {
523 if let Some(owner) = self.owners(kind).get(id).cloned() {
524 if tag.is_some() && &owner != tag {
525 return Err(self.fail(
526 event,
527 Rule::OwnerMismatch,
528 format!(
529 "{} {id:?} belongs to {}, not {}",
530 kind.owner_noun(),
531 describe(&owner),
532 describe(tag)
533 ),
534 ));
535 }
536 return Ok(owner);
537 }
538 self.owners_mut(kind).insert(id.clone(), tag.clone());
539 Ok(tag.clone())
540 }
541
542 /// The owner bucket an id of this kind claims through.
543 fn owners(&self, kind: Kind) -> &HashMap<MessageId, Owner> {
544 match kind {
545 Kind::Message => &self.message_owners,
546 Kind::Reasoning | Kind::ReasoningMessage => &self.reasoning_owners,
547 }
548 }
549
550 fn owners_mut(&mut self, kind: Kind) -> &mut HashMap<MessageId, Owner> {
551 match kind {
552 Kind::Message => &mut self.message_owners,
553 Kind::Reasoning | Kind::ReasoningMessage => &mut self.reasoning_owners,
554 }
555 }
556
557 /// Who a tool call belongs to: the tag when it carries one, otherwise
558 /// the owner of the message that carries the call, otherwise whoever
559 /// introduced the call before, otherwise the parent agent.
560 ///
561 /// A tag that disagrees with the parent message's owner cannot be
562 /// represented faithfully — `ToolCall` carries no attribution of its
563 /// own — and is rejected, as is an asserted owner that disagrees with
564 /// a recorded one.
565 fn resolve_tool_call_owner(
566 &self,
567 event: &Event,
568 id: &ToolCallId,
569 parent_message_id: Option<&MessageId>,
570 tag: &Owner,
571 ) -> Result<Owner, VerificationError> {
572 let inherited = parent_message_id
573 .and_then(|parent| self.message_owners.get(parent).map(|owner| (parent, owner)));
574 if let Some((parent, owner)) = inherited {
575 if tag.is_some() && owner != tag {
576 return Err(self.fail(
577 event,
578 Rule::OwnerMismatch,
579 format!(
580 "tool call {id:?} is tagged {} but its parent message {parent:?} belongs to {}; \
581 a tool call belongs to the message that carries it",
582 describe(tag),
583 describe(owner)
584 ),
585 ));
586 }
587 }
588 let asserted: Option<Owner> = if tag.is_some() {
589 Some(tag.clone())
590 } else {
591 inherited.map(|(_, owner)| owner.clone())
592 };
593 if let (Some(asserted), Some(known)) = (&asserted, self.known_tool_calls.get(id)) {
594 if asserted != known {
595 return Err(self.fail(
596 event,
597 Rule::OwnerMismatch,
598 format!(
599 "tool call {id:?} belongs to {}, not {}",
600 describe(known),
601 describe(asserted)
602 ),
603 ));
604 }
605 }
606 Ok(asserted
607 .or_else(|| self.known_tool_calls.get(id).cloned())
608 .unwrap_or(None))
609 }
610
611 fn require_tool_call(
612 &self,
613 event: &Event,
614 id: &ToolCallId,
615 tag: &Owner,
616 ) -> Result<(), VerificationError> {
617 match self.tool_calls.get(id) {
618 None => Err(self.fail(
619 event,
620 Rule::NotOpen,
621 format!("tool call {id:?} is not open"),
622 )),
623 Some(owner) if tag.is_some() && owner != tag => Err(self.fail(
624 event,
625 Rule::OwnerMismatch,
626 format!(
627 "tool call {id:?} belongs to {}, not {}",
628 describe(owner),
629 describe(tag)
630 ),
631 )),
632 Some(_) => Ok(()),
633 }
634 }
635
636 fn close_subagent(
637 &mut self,
638 event: &Event,
639 id: &SubagentRunId,
640 ) -> Result<(), VerificationError> {
641 if !self.active_subagents.remove(id) {
642 return Err(self.fail(
643 event,
644 Rule::NotOpen,
645 format!("subagent {id:?} is not active"),
646 ));
647 }
648 self.closed_subagents.insert(id.clone());
649 Ok(())
650 }
651
652 /// Opens an entity under the owner in force for its id — the first
653 /// writer's, so an untagged re-open of a subagent's message keeps
654 /// checking its continuations against that subagent.
655 ///
656 /// A reasoning block and the message inside it share an id and
657 /// claim the same owner, so `REASONING_START` under one subagent and
658 /// `REASONING_MESSAGE_START` under another is a contradiction here,
659 /// as it is upstream.
660 fn open(
661 &mut self,
662 event: &Event,
663 kind: Kind,
664 id: &MessageId,
665 tag: &Owner,
666 ) -> Result<(), VerificationError> {
667 let owner = self.claim(event, kind, id, tag)?;
668 if self.map(kind).contains_key(id) {
669 return Err(self.fail(
670 event,
671 Rule::DuplicateStart,
672 format!("{} {id:?} is already open", kind.noun()),
673 ));
674 }
675 self.map_mut(kind).insert(id.clone(), owner);
676 Ok(())
677 }
678
679 fn require(
680 &self,
681 event: &Event,
682 kind: Kind,
683 id: &MessageId,
684 tag: &Owner,
685 ) -> Result<(), VerificationError> {
686 match self.map(kind).get(id) {
687 None => Err(self.fail(
688 event,
689 Rule::NotOpen,
690 format!("{} {id:?} is not open", kind.noun()),
691 )),
692 Some(owner) if tag.is_some() && owner != tag => Err(self.fail(
693 event,
694 Rule::OwnerMismatch,
695 format!(
696 "{} {id:?} belongs to {}, not {}",
697 kind.noun(),
698 describe(owner),
699 describe(tag)
700 ),
701 )),
702 Some(_) => Ok(()),
703 }
704 }
705
706 fn close(
707 &mut self,
708 event: &Event,
709 kind: Kind,
710 id: &MessageId,
711 tag: &Owner,
712 ) -> Result<(), VerificationError> {
713 self.require(event, kind, id, tag)?;
714 self.map_mut(kind).remove(id);
715 Ok(())
716 }
717
718 fn map(&self, kind: Kind) -> &HashMap<MessageId, Owner> {
719 match kind {
720 Kind::Message => &self.messages,
721 Kind::Reasoning => &self.reasoning,
722 Kind::ReasoningMessage => &self.reasoning_messages,
723 }
724 }
725
726 fn map_mut(&mut self, kind: Kind) -> &mut HashMap<MessageId, Owner> {
727 match kind {
728 Kind::Message => &mut self.messages,
729 Kind::Reasoning => &mut self.reasoning,
730 Kind::ReasoningMessage => &mut self.reasoning_messages,
731 }
732 }
733
734 fn fail(&self, event: &Event, rule: Rule, detail: impl Into<String>) -> VerificationError {
735 reject(event, rule, detail, || self.dump())
736 }
737
738 /// The first thing still open at `RUN_FINISHED`, if any.
739 fn first_open(&self) -> Option<String> {
740 if let Some(id) = self.messages.keys().next() {
741 return Some(format!("message {id:?} is still open"));
742 }
743 if let Some(id) = self.reasoning_messages.keys().next() {
744 return Some(format!("reasoning message {id:?} is still open"));
745 }
746 if let Some(id) = self.reasoning.keys().next() {
747 return Some(format!("reasoning block {id:?} is still open"));
748 }
749 if let Some(id) = self.tool_calls.keys().next() {
750 return Some(format!("tool call {id:?} is still open"));
751 }
752 if let Some((owner, name)) = self.steps.iter().next() {
753 return Some(format!(
754 "step {name:?} is still open under {}",
755 describe(owner)
756 ));
757 }
758 if let Some(id) = self.active_subagents.iter().next() {
759 return Some(format!("subagent {id:?} is still active"));
760 }
761 None
762 }
763
764 /// Debug-build-only dump of everything currently open.
765 fn dump(&self) -> String {
766 let mut out = String::new();
767 let mut push = |label: &str, values: Vec<String>| {
768 if !values.is_empty() {
769 let _ = write!(out, " {label}={:?}", values);
770 }
771 };
772 push("messages", strings(self.messages.keys()));
773 push("reasoning", strings(self.reasoning.keys()));
774 push(
775 "reasoning_messages",
776 strings(self.reasoning_messages.keys()),
777 );
778 push("tool_calls", strings(self.tool_calls.keys()));
779 push(
780 "steps",
781 strings(self.steps.iter().map(|(owner, name)| match owner {
782 None => name.to_string(),
783 Some(id) => format!("{id}/{name}"),
784 })),
785 );
786 push("subagents", strings(self.active_subagents.iter()));
787 if out.is_empty() {
788 " [nothing open]".to_owned()
789 } else {
790 format!(" [open:{out}]")
791 }
792 }
793 }
794
795 fn strings<T: ToString>(values: impl Iterator<Item = T>) -> Vec<String> {
796 let mut values: Vec<String> = values.map(|value| value.to_string()).collect();
797 values.sort();
798 values
799 }
800
801 /// The three id-keyed things a message id can open.
802 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
803 enum Kind {
804 Message,
805 Reasoning,
806 ReasoningMessage,
807 }
808
809 impl Kind {
810 const fn noun(self) -> &'static str {
811 match self {
812 Self::Message => "message",
813 Self::Reasoning => "reasoning block",
814 Self::ReasoningMessage => "reasoning message",
815 }
816 }
817
818 /// What an owner complaint calls the entity: the block and the
819 /// message inside it are one owned thing.
820 const fn owner_noun(self) -> &'static str {
821 match self {
822 Self::Message => "message",
823 Self::Reasoning | Self::ReasoningMessage => "reasoning message",
824 }
825 }
826 }
827}
828
829#[cfg(not(feature = "verify"))]
830mod disabled {
831 use crate::Event;
832
833 use crate::server::error::VerificationError;
834
835 /// The `verify` feature is off: every check compiles away.
836 #[derive(Debug, Default)]
837 pub(crate) struct Verifier;
838
839 impl Verifier {
840 #[inline]
841 pub(crate) fn new() -> Self {
842 Self
843 }
844
845 #[inline]
846 pub(crate) fn observe(&mut self, _event: &Event) -> Result<(), VerificationError> {
847 Ok(())
848 }
849 }
850}
851
852#[cfg(all(test, feature = "verify"))]
853mod tests {
854 use super::*;
855 use crate::{Event, TextMessageRole};
856
857 use crate::server::error::Rule;
858
859 fn verifier() -> Verifier {
860 let mut verifier = Verifier::new();
861 verifier
862 .observe(&Event::run_started("t", "r"))
863 .expect("RUN_STARTED must be accepted");
864 verifier
865 }
866
867 #[test]
868 fn a_well_formed_run_passes() {
869 let mut verifier = verifier();
870 for event in [
871 Event::step_started("plan"),
872 Event::text_message_start("m1", TextMessageRole::Assistant),
873 Event::text_message_content("m1", "hi"),
874 Event::text_message_end("m1"),
875 Event::tool_call_start("c1", "search"),
876 Event::tool_call_args("c1", "{}"),
877 Event::tool_call_end("c1"),
878 Event::tool_call_result("m2", "c1", "ok"),
879 Event::step_finished("plan"),
880 Event::run_finished_success("t", "r"),
881 ] {
882 verifier
883 .observe(&event)
884 .unwrap_or_else(|error| panic!("{event:?} should be accepted: {error}"));
885 }
886 }
887
888 #[test]
889 fn debug_builds_include_the_open_dump() {
890 let mut verifier = verifier();
891 verifier
892 .observe(&Event::text_message_start("m1", TextMessageRole::Assistant))
893 .expect("start");
894 let error = verifier
895 .observe(&Event::text_message_content("m2", "hi"))
896 .expect_err("m2 was never opened");
897 assert_eq!(error.rule, Rule::NotOpen);
898 assert!(
899 error.detail.contains("m1"),
900 "debug dump should name the open message: {}",
901 error.detail
902 );
903 }
904
905 #[test]
906 fn the_dump_names_open_subagents_and_owned_steps() {
907 let mut verifier = verifier();
908 verifier
909 .observe(&Event::subagent_started("s1", "researcher"))
910 .expect("start");
911 verifier
912 .observe(&Event::step_started("plan").with_subagent_run_id("s1"))
913 .expect("a subagent's step");
914 let error = verifier
915 .observe(&Event::run_finished_success("t", "r"))
916 .expect_err("the step and the subagent are open");
917 assert_eq!(error.rule, Rule::OpenAtFinish);
918 assert!(error.detail.contains("s1/plan"), "{}", error.detail);
919 assert!(error.detail.contains("subagents"), "{}", error.detail);
920 }
921}