oxi_agent/advisor/types.rs
1//! Advisor core types — ported from omp `advise-tool.ts`.
2//!
3//! Pure data: severity, note, delivery channel. No host/runtime dependency.
4//!
5//! # Attribution
6//!
7//! Translated to Rust from omp (oh-my-pi), which is MIT licensed
8//! (Copyright (c) 2025 Mario Zechner; Copyright (c) 2025-2026 Can Bölük).
9//! oxi's translation remains under oxi's own MIT license.
10
11use serde::{Deserialize, Serialize};
12
13/// Advisor note severity. omp `AdvisorSeverity`. Omitting it (in the tool
14/// schema) is treated as a plain `nit`.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum AdvisorSeverity {
18 #[default]
19 /// Non-urgent cleanup, refactor, missed opportunity. Rides the aside queue.
20 Nit,
21 /// Agent might be heading wrong or missed something material. Interrupts.
22 Concern,
23 /// Stop and reconsider. Interrupts.
24 Blocker,
25}
26
27impl AdvisorSeverity {
28 /// Lowercase id matching omp's schema strings.
29 #[must_use]
30 pub const fn as_str(self) -> &'static str {
31 match self {
32 AdvisorSeverity::Nit => "nit",
33 AdvisorSeverity::Concern => "concern",
34 AdvisorSeverity::Blocker => "blocker",
35 }
36 }
37
38 /// Parse a lowercase id. `None` for anything else.
39 #[must_use]
40 pub fn from_id(s: &str) -> Option<Self> {
41 Some(match s {
42 "nit" => AdvisorSeverity::Nit,
43 "concern" => AdvisorSeverity::Concern,
44 "blocker" => AdvisorSeverity::Blocker,
45 _ => return None,
46 })
47 }
48
49 /// Dedupe rank — `nit < concern < blocker`. A new call passes the dedupe
50 /// gate only when its rank strictly exceeds the recorded one (a real
51 /// escalation). omp `ADVISOR_SEVERITY_RANK`.
52 #[must_use]
53 pub const fn rank(self) -> u8 {
54 match self {
55 AdvisorSeverity::Nit => 1,
56 AdvisorSeverity::Concern => 2,
57 AdvisorSeverity::Blocker => 3,
58 }
59 }
60}
61
62/// One queued advice note. omp `AdvisorNote`.
63#[derive(Debug, Clone)]
64pub struct AdvisorNote {
65 /// The advice text, terse and specific.
66 pub note: String,
67 /// Severity; `None` means a plain nit (omp treats an omitted severity as nit).
68 pub severity: Option<AdvisorSeverity>,
69}
70
71impl AdvisorNote {
72 /// Rank for dedupe. `None` severity defers to `nit` (omp `advisorSeverityRank`).
73 #[must_use]
74 pub fn rank(&self) -> u8 {
75 self.severity.unwrap_or_default().rank()
76 }
77}
78
79/// How an advisor note reaches the primary agent. omp `AdvisorDeliveryChannel`.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum AdvisorDeliveryChannel {
82 /// Non-interrupting `<advisory>` card rendered in the transcript; the run
83 /// continues. Used for `nit`, and for `concern`/`blocker` during the
84 /// post-interrupt immune-turn window.
85 Aside,
86 /// Injected into the primary agent's steering queue — acted on at the next
87 /// turn boundary, or (while streaming) mid-turn.
88 Steer,
89 /// Visible card, but does not resume a stopped run. Used after a deliberate
90 /// user interrupt while the agent is idle/aborting.
91 Preserve,
92}
93
94/// Inputs to [`crate::advisor::channels::resolve_delivery_channel`].
95/// omp `resolveAdvisorDeliveryChannel` opts.
96#[derive(Debug, Clone, Copy, Default)]
97pub struct DeliveryOpts {
98 /// The note's severity (`None` = nit).
99 pub severity: Option<AdvisorSeverity>,
100 /// Latched `true` when the user deliberately interrupted; suppresses
101 /// `concern`/`blocker` auto-resume until the user next resumes.
102 pub auto_resume_suppressed: bool,
103 /// Whether the primary agent is currently streaming a turn.
104 pub streaming: bool,
105 /// Whether an interrupted turn is still being torn down.
106 pub aborting: bool,
107 /// Whether the post-interrupt immune-turn cooldown window is active.
108 pub interrupt_immune_turn_active: bool,
109}
110
111/// omp `ADVISOR_GUIDANCE` — behavioral framing carried as a tag attribute on
112/// every rendered `<advisory>` block. The primary agent's system prompt never
113/// mentions advisories, so this is its only cue for how to treat them.
114pub const ADVISOR_GUIDANCE: &str = "weigh, don't blindly obey";
115
116/// Dedupe key for an advisor note — trim + collapse internal whitespace.
117/// omp `advisorNoteDedupeKey`. (Phonetic/content normalization for the
118/// *emission* gate lives in [`crate::advisor::emission_guard`].)
119#[must_use]
120pub(crate) fn note_dedupe_key(note: &str) -> String {
121 note.split_whitespace().collect::<Vec<_>>().join(" ")
122}