Skip to main content

oxi_agent/agent_loop/
ttsr.rs

1//! TTSR (Time-Traveling Stream Rules) engine.
2//!
3//! Monitors streaming model output against project rules. When a rule is
4//! violated, the stream is aborted and the rule is injected as a system
5//! reminder so the model can correct itself.
6//!
7//! Ported from omp `packages/coding-agent/src/export/ttsr.ts` (TtsrManager).
8//!
9//! Only regex-based matching is implemented in the default build.
10//! AST-based matching (`astCondition`) requires an additional parser dependency
11//! and is deferred to a future feature gate.
12
13use parking_lot::RwLock;
14use std::collections::HashMap;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18
19// ── Local type definitions (mirrors oxi_sdk::ports to avoid a dependency cycle) ─
20
21/// Interrupt mode controlling which stream sources TTSR inspects.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum InterruptMode {
24    /// Never interrupt — the rule is informational only.
25    Never,
26    /// Interrupt only on assistant prose output.
27    #[default]
28    ProseOnly,
29    /// Interrupt only on tool-call arguments.
30    ToolOnly,
31    /// Interrupt on any source (text, thinking, tools).
32    Always,
33}
34
35/// Which source produced a TTSR match.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ScopeToken {
38    /// Assistant prose (text deltas).
39    Text,
40    /// Model reasoning (thinking blocks).
41    Thinking,
42    /// Tool argument payload.
43    Tool {
44        /// Name of the tool whose arguments are being built.
45        name: String,
46        /// Glob patterns matching affected file paths.
47        globs: Vec<String>,
48    },
49}
50
51/// Where a rule originated.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum RuleSource {
54    /// Shipped with oxi itself (e.g., rs-future-prelude).
55    BuiltinDefaults,
56    /// Project-local rule (`.oxi/rules/*.mdc`).
57    Project,
58    /// User-level rule (`~/.oxi/rules/*.mdc`).
59    User,
60}
61/// A single TTSR rule.
62#[derive(Debug, Clone)]
63pub struct Rule {
64    /// Human-readable name identifying this rule.
65    pub name: String,
66    /// The rule body — instructions injected as a system reminder on match.
67    pub content: String,
68    /// Optional short summary of what the rule governs.
69    pub description: Option<String>,
70    /// Regex patterns that, when found in the stream, trigger the rule.
71    pub condition: Vec<regex::Regex>,
72    /// Stream sources (and tools) this rule applies to.
73    pub scope: Vec<ScopeToken>,
74    /// When the rule is permitted to interrupt the stream.
75    pub interrupt_mode: InterruptMode,
76    /// Glob patterns restricting the rule to specific file paths.
77    pub globs: Vec<String>,
78    /// If `true`, the rule is always active regardless of conditions.
79    pub always_apply: bool,
80    /// Where the rule originated (builtin, project, or user).
81    pub source: RuleSource,
82}
83
84/// Registry of TTSR rules (supplied by the host).
85///
86/// This is a simplified version of `oxi_sdk::ports::RuleRegistry` that lives
87/// in oxi-agent to avoid a dependency cycle.
88pub trait RuleRegistry: Send + Sync + 'static {
89    /// Returns a future that resolves to the current set of registered rules.
90    fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>>;
91
92    /// Mark that a rule was injected at a given turn.
93    fn mark_injected(&self, _name: &str, _turn: u64) {}
94
95    /// Return all injection records for compaction survival.
96    fn injected_records(&self) -> Vec<(String, u64)> {
97        vec![]
98    }
99
100    /// Restore injection records after compaction.
101    fn restore(&self, _records: Vec<(String, u64)>) {}
102}
103/// Which stream source produced a delta.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
105pub enum MatchSource {
106    /// Assistant prose (the main response text).
107    Text,
108    /// Model reasoning (CoT / thinking blocks).
109    Thinking,
110    /// Tool argument payloads.
111    Tool,
112}
113
114/// Per-source buffer key.
115#[derive(Debug, Clone, Hash, PartialEq, Eq)]
116struct BufferKey {
117    source: MatchSource,
118    /// Only meaningful for Tool source; otherwise None.
119    tool_name: Option<String>,
120}
121
122// ── Match context ───────────────────────────────────────────────────────────
123
124/// Context passed to [`TtsrEngine::check_delta`] describing what is being
125/// generated right now.
126#[derive(Debug, Clone)]
127pub struct TtsrMatchContext {
128    /// Source stream (text / thinking / tool).
129    pub source: MatchSource,
130    /// Active file paths this delta may affect (for glob-scoped rules).
131    pub file_paths: Vec<String>,
132    /// Tool name when `source` is [`MatchSource::Tool`].
133    pub tool_name: Option<String>,
134}
135
136// ── Engine ──────────────────────────────────────────────────────────────────
137
138/// TTSR engine that buffers streaming deltas and checks them against
139/// registered rules.
140pub struct TtsrEngine {
141    rules: Arc<dyn RuleRegistry>,
142    /// Per-source accumulation buffers. Keys are source + optional tool name.
143    buffers: RwLock<HashMap<BufferKey, Vec<String>>>,
144    settings: TtsrSettings,
145}
146
147impl std::fmt::Debug for TtsrEngine {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("TtsrEngine")
150            .field("settings", &self.settings)
151            .finish_non_exhaustive()
152    }
153}
154
155/// Knobs for the TTSR engine.
156#[derive(Debug, Clone)]
157pub struct TtsrSettings {
158    /// Master on/off switch. When `false`, all checks are no-ops.
159    pub enabled: bool,
160    /// Default interrupt mode (overridden per-rule).
161    pub interrupt_mode: InterruptMode,
162    /// Whether the bundled builtin rules are activated.
163    pub builtin_rules: bool,
164    /// Safety cap: how many times a single turn can be interrupted.
165    pub max_retries_per_turn: u32,
166}
167
168impl Default for TtsrSettings {
169    fn default() -> Self {
170        Self {
171            enabled: false,
172            interrupt_mode: InterruptMode::ProseOnly,
173            builtin_rules: true,
174            max_retries_per_turn: 3,
175        }
176    }
177}
178
179impl TtsrEngine {
180    /// Create an engine backed by `rules`.
181    pub fn new(rules: Arc<dyn RuleRegistry>, settings: TtsrSettings) -> Self {
182        Self {
183            rules,
184            buffers: RwLock::new(HashMap::new()),
185            settings,
186        }
187    }
188
189    /// Clear all source buffers. Call at the start of each turn.
190    pub fn reset_buffers(&self) {
191        self.buffers.write().clear();
192    }
193
194    /// Append a streaming delta to the appropriate buffer and return any
195    /// rules whose conditions now match the accumulated text.
196    ///
197    /// This is called on every `ProviderEvent::Delta` while streaming.
198    pub fn check_delta(&self, delta: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
199        if !self.settings.enabled {
200            return vec![];
201        }
202
203        let key = self.buffer_key(ctx);
204        let mut buffers = self.buffers.write();
205        let buf = buffers.entry(key).or_default();
206        buf.push(delta.to_string());
207
208        // Join accumulated deltas into one string for matching.
209        let full: String = buf.concat();
210        self.match_buffer(&full, ctx).into_iter().collect()
211    }
212
213    /// Replace the buffer for `ctx` with a normalized snapshot (used when
214    /// tool output is available in a pre-parsed form).
215    pub fn check_snapshot(&self, snapshot: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
216        if !self.settings.enabled {
217            return vec![];
218        }
219
220        let key = self.buffer_key(ctx);
221        let mut buffers = self.buffers.write();
222        buffers.insert(key, vec![snapshot.to_string()]);
223
224        self.match_buffer(snapshot, ctx).into_iter().collect()
225    }
226
227    /// Return all injected rule records for compaction survival.
228    pub fn injected_records(&self) -> Vec<(String, u64)> {
229        self.rules.injected_records()
230    }
231
232    // ── Private ─────────────────────────────────────────────────────────
233
234    fn buffer_key(&self, ctx: &TtsrMatchContext) -> BufferKey {
235        BufferKey {
236            source: ctx.source,
237            tool_name: if matches!(ctx.source, MatchSource::Tool) {
238                ctx.tool_name.clone()
239            } else {
240                None
241            },
242        }
243    }
244
245    /// Walk every rule and return matching ones (the first match per rule
246    /// is sufficient to trigger an interrupt).
247    fn match_buffer(&self, buf: &str, ctx: &TtsrMatchContext) -> Vec<Rule> {
248        // Rules are cheap to clone (owned strings), but we collect all matches
249        // eagerly so the caller can inspect them without holding the lock.
250        let mut matched = Vec::new();
251
252        // Collect rules from the registry. We re-fetch each time because
253        // rules can be hot-reloaded at runtime.
254        let rules: Vec<Rule> = futures::executor::block_on(self.rules.rules());
255
256        for rule in rules {
257            // ── Scope filter ──
258            if !self.scope_matches(&rule, ctx) {
259                continue;
260            }
261
262            // ── Interrupt mode filter ──
263            let mode = if matches!(rule.interrupt_mode, InterruptMode::Never) {
264                self.settings.interrupt_mode
265            } else {
266                rule.interrupt_mode
267            };
268            if !self.mode_allows(mode, ctx.source) {
269                continue;
270            }
271
272            // ── Condition matching ──
273            if !rule.condition.iter().any(|re| re.is_match(buf)) {
274                continue;
275            }
276
277            matched.push(rule);
278        }
279
280        matched
281    }
282
283    /// Check whether the rule's scope tokens include the current context.
284    fn scope_matches(&self, rule: &Rule, ctx: &TtsrMatchContext) -> bool {
285        if rule.scope.is_empty() {
286            // No scope = applies everywhere.
287            return true;
288        }
289
290        for token in &rule.scope {
291            match token {
292                ScopeToken::Text => {
293                    if matches!(ctx.source, MatchSource::Text) {
294                        return true;
295                    }
296                }
297                ScopeToken::Thinking => {
298                    if matches!(ctx.source, MatchSource::Thinking) {
299                        return true;
300                    }
301                }
302                ScopeToken::Tool { name, globs } => {
303                    if !matches!(ctx.source, MatchSource::Tool) {
304                        continue;
305                    }
306                    if matches!(ctx.tool_name.as_ref(), Some(tool_name) if tool_name != name) {
307                        continue;
308                    }
309                    // If globs are specified, at least one must match a file path.
310                    if !globs.is_empty() {
311                        let any_match = ctx.file_paths.iter().any(|fp| {
312                            globs.iter().any(|g| {
313                                // Simple glob: suffix match.
314                                g.strip_suffix("/*")
315                                    .map(|prefix| fp.starts_with(prefix))
316                                    .unwrap_or_else(|| g == fp)
317                            })
318                        });
319                        if !any_match {
320                            continue;
321                        }
322                    }
323                    return true;
324                }
325            }
326        }
327
328        false
329    }
330
331    /// Check whether the interrupt mode permits firing on this source.
332    fn mode_allows(&self, mode: InterruptMode, source: MatchSource) -> bool {
333        match mode {
334            InterruptMode::Never => false,
335            InterruptMode::ProseOnly => matches!(source, MatchSource::Text),
336            InterruptMode::ToolOnly => matches!(source, MatchSource::Tool),
337            InterruptMode::Always => true,
338        }
339    }
340}
341
342// ── Tests ───────────────────────────────────────────────────────────────────
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use regex::Regex;
348    use std::pin::Pin;
349
350    /// Minimal RuleRegistry that returns a static set of rules.
351    struct StaticRegistry {
352        rules: Vec<Rule>,
353        injections: RwLock<Vec<(String, u64)>>,
354    }
355
356    impl RuleRegistry for StaticRegistry {
357        fn rules<'a>(&'a self) -> Pin<Box<dyn Future<Output = Vec<Rule>> + Send + 'a>> {
358            Box::pin(std::future::ready(self.rules.clone()))
359        }
360
361        fn mark_injected(&self, name: &str, turn: u64) {
362            self.injections.write().push((name.to_string(), turn));
363        }
364
365        fn injected_records(&self) -> Vec<(String, u64)> {
366            self.injections.read().clone()
367        }
368
369        fn restore(&self, records: Vec<(String, u64)>) {
370            *self.injections.write() = records;
371        }
372    }
373
374    fn make_rule(name: &str, pattern: &str) -> Rule {
375        Rule {
376            name: name.to_string(),
377            content: format!("Do not use {pattern}."),
378            description: Some(format!("Forbids {pattern}")),
379            condition: vec![Regex::new(pattern).unwrap()],
380            scope: vec![],
381            interrupt_mode: InterruptMode::ProseOnly,
382            globs: vec![],
383            always_apply: false,
384            source: RuleSource::BuiltinDefaults,
385        }
386    }
387
388    #[test]
389    fn test_check_delta_matches_simple_pattern() {
390        let rules = Arc::new(StaticRegistry {
391            rules: vec![make_rule("no-todo", r"TODO:")],
392            injections: RwLock::new(Vec::new()),
393        });
394
395        let engine = TtsrEngine::new(
396            rules,
397            TtsrSettings {
398                enabled: true,
399                ..Default::default()
400            },
401        );
402
403        let ctx = TtsrMatchContext {
404            source: MatchSource::Text,
405            file_paths: vec![],
406            tool_name: None,
407        };
408
409        // First delta — no match yet.
410        let results = engine.check_delta("This code is almost ", &ctx);
411        assert!(results.is_empty());
412
413        // Second delta triggers the rule.
414        let results = engine.check_delta("TODO: fix later", &ctx);
415        assert_eq!(results.len(), 1);
416        assert_eq!(results[0].name, "no-todo");
417    }
418
419    #[test]
420    fn test_check_delta_respects_disabled() {
421        let rules = Arc::new(StaticRegistry {
422            rules: vec![make_rule("no-todo", r"TODO:")],
423            injections: RwLock::new(Vec::new()),
424        });
425
426        let engine = TtsrEngine::new(
427            rules,
428            TtsrSettings {
429                enabled: false, // DISABLED
430                ..Default::default()
431            },
432        );
433
434        let ctx = TtsrMatchContext {
435            source: MatchSource::Text,
436            file_paths: vec![],
437            tool_name: None,
438        };
439
440        let results = engine.check_delta("TODO: fix later", &ctx);
441        assert!(results.is_empty(), "disabled engine must return no matches");
442    }
443
444    #[test]
445    fn test_scope_filter_respects_tool_scope() {
446        let rules = Arc::new(StaticRegistry {
447            rules: vec![Rule {
448                name: "edit-only-rule".to_string(),
449                content: "Only for edit tool".to_string(),
450                description: None,
451                condition: vec![Regex::new("bad").unwrap()],
452                scope: vec![ScopeToken::Tool {
453                    name: "edit".to_string(),
454                    globs: vec![],
455                }],
456                interrupt_mode: InterruptMode::Always,
457                globs: vec![],
458                always_apply: false,
459                source: RuleSource::BuiltinDefaults,
460            }],
461            injections: RwLock::new(Vec::new()),
462        });
463
464        let engine = TtsrEngine::new(
465            rules,
466            TtsrSettings {
467                enabled: true,
468                ..Default::default()
469            },
470        );
471
472        // Text source — scope doesn't match.
473        let text_ctx = TtsrMatchContext {
474            source: MatchSource::Text,
475            file_paths: vec![],
476            tool_name: None,
477        };
478        assert!(engine.check_delta("bad code", &text_ctx).is_empty());
479
480        // Tool source matching "edit" — matches.
481        let tool_ctx = TtsrMatchContext {
482            source: MatchSource::Tool,
483            file_paths: vec![],
484            tool_name: Some("edit".to_string()),
485        };
486        assert!(!engine.check_delta("bad code", &tool_ctx).is_empty());
487
488        // Tool source but wrong tool name — no match.
489        let write_ctx = TtsrMatchContext {
490            source: MatchSource::Tool,
491            file_paths: vec![],
492            tool_name: Some("write".to_string()),
493        };
494        assert!(engine.check_delta("bad code", &write_ctx).is_empty());
495    }
496
497    #[test]
498    fn test_reset_buffers_clears_accumulation() {
499        let rules = Arc::new(StaticRegistry {
500            rules: vec![make_rule("no-todo", r"TODO:")],
501            injections: RwLock::new(Vec::new()),
502        });
503
504        let engine = TtsrEngine::new(
505            rules,
506            TtsrSettings {
507                enabled: true,
508                ..Default::default()
509            },
510        );
511
512        let ctx = TtsrMatchContext {
513            source: MatchSource::Text,
514            file_paths: vec![],
515            tool_name: None,
516        };
517
518        // Accumulate "TODO" in buffer.
519        engine.check_delta("TODO", &ctx);
520        // Reset should clear it.
521        engine.reset_buffers();
522
523        // Now ":" alone shouldn't match because "TODO" was cleared.
524        let results = engine.check_delta(":", &ctx);
525        assert!(results.is_empty(), "buffer was reset — TODO should be gone");
526    }
527
528    #[test]
529    fn test_prose_only_mode_ignores_tool_source() {
530        let rules = Arc::new(StaticRegistry {
531            rules: vec![make_rule("no-bad", r"bad")],
532            injections: RwLock::new(Vec::new()),
533        });
534
535        let engine = TtsrEngine::new(
536            rules,
537            TtsrSettings {
538                enabled: true,
539                interrupt_mode: InterruptMode::ProseOnly,
540                ..Default::default()
541            },
542        );
543
544        // Text source — allowed.
545        let text_ctx = TtsrMatchContext {
546            source: MatchSource::Text,
547            file_paths: vec![],
548            tool_name: None,
549        };
550        assert!(!engine.check_delta("bad code", &text_ctx).is_empty());
551
552        // Tool source — blocked by ProseOnly mode.
553        let tool_ctx = TtsrMatchContext {
554            source: MatchSource::Tool,
555            file_paths: vec![],
556            tool_name: Some("edit".to_string()),
557        };
558        assert!(engine.check_delta("bad code", &tool_ctx).is_empty());
559    }
560}