ubl_office/
runtime.rs

1//! Office runtime: the main agent loop.
2
3use crate::{MemorySystem, Narrator, NarratorConfig, OfficeError};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use std::time::{Duration, Instant};
7use tdln_brain::{GenerationConfig, Message, NeuralBackend};
8use tokio::sync::watch;
9
10/// Agent lifecycle states.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum OfficeState {
13    /// Bootstrapping identity/IO.
14    Opening,
15    /// Active OODA loop.
16    Active,
17    /// Dreaming / consolidation / compaction.
18    Maintenance,
19    /// Shutdown with flush.
20    Closing,
21}
22
23/// Session type determines allowed behaviors.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25pub enum SessionType {
26    /// Full autonomous work: may sign & act.
27    #[default]
28    Work,
29    /// Propose only, never act.
30    Assist,
31    /// Think-only; no tool calls.
32    Deliberate,
33    /// Read-only tools allowed; summarize with citations.
34    Research,
35}
36
37/// Session mode determines commitment level.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
39pub enum SessionMode {
40    /// Actions are binding; receipts written.
41    #[default]
42    Commitment,
43    /// Proposals only; nothing executed.
44    Deliberation,
45}
46
47/// Token budget configuration.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct TokenBudget {
50    /// Max input tokens per decision.
51    pub max_input_tokens: u32,
52    /// Max output tokens per decision.
53    pub max_output_tokens: u32,
54    /// Daily token quota per entity.
55    pub daily_token_quota: u64,
56    /// Max decisions per cycle.
57    pub max_decisions_per_cycle: u32,
58}
59
60impl Default for TokenBudget {
61    fn default() -> Self {
62        Self {
63            max_input_tokens: 4000,
64            max_output_tokens: 1024,
65            daily_token_quota: 200_000,
66            max_decisions_per_cycle: 1,
67        }
68    }
69}
70
71/// Dreaming (maintenance) configuration.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct DreamConfig {
74    /// Dream every N cycles.
75    pub dream_every_n_cycles: u64,
76    /// Minimum interval between dreams (seconds).
77    pub dream_min_interval_secs: u64,
78}
79
80impl Default for DreamConfig {
81    fn default() -> Self {
82        Self {
83            dream_every_n_cycles: 100,
84            dream_min_interval_secs: 900, // 15 minutes
85        }
86    }
87}
88
89/// Runtime configuration.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct OfficeConfig {
92    /// Tenant/agent identity.
93    pub tenant_id: String,
94    /// Path to constitution / policy bundle.
95    pub constitution_path: Option<PathBuf>,
96    /// Workspace root.
97    pub workspace_root: PathBuf,
98    /// Path to ledger file (NDJSON).
99    pub ledger_path: Option<PathBuf>,
100    /// Model ID for the brain.
101    pub model_id: String,
102    /// Session type.
103    pub session_type: SessionType,
104    /// Session mode.
105    pub session_mode: SessionMode,
106    /// Token budget.
107    pub budget: TokenBudget,
108    /// Dreaming configuration.
109    pub dream: DreamConfig,
110    /// Pause between steps (milliseconds).
111    pub step_pause_ms: u64,
112    /// Maximum consecutive errors before abort.
113    pub max_consecutive_errors: u32,
114}
115
116impl Default for OfficeConfig {
117    fn default() -> Self {
118        Self {
119            tenant_id: "agent".into(),
120            constitution_path: None,
121            workspace_root: PathBuf::from("."),
122            ledger_path: None,
123            model_id: "mock".into(),
124            session_type: SessionType::default(),
125            session_mode: SessionMode::default(),
126            budget: TokenBudget::default(),
127            dream: DreamConfig::default(),
128            step_pause_ms: 1000,
129            max_consecutive_errors: 5,
130        }
131    }
132}
133
134/// Structured metrics for the runtime.
135#[derive(Debug, Clone, Default, Serialize, Deserialize)]
136pub struct OfficeMetrics {
137    /// Total steps executed.
138    pub steps_total: u64,
139    /// Total decisions made.
140    pub decisions_total: u64,
141    /// Total denials from Gate.
142    pub denials_total: u64,
143    /// Total challenges from Gate.
144    pub challenges_total: u64,
145    /// Total tool errors.
146    pub tool_errors_total: u64,
147    /// Consecutive errors (resets on success).
148    pub consecutive_errors: u32,
149    /// Total input tokens consumed today.
150    pub input_tokens_today: u64,
151    /// Total output tokens consumed today.
152    pub output_tokens_today: u64,
153    /// Dreams completed.
154    pub dreams_total: u64,
155    /// Decisions since last dream.
156    pub decisions_since_dream: u64,
157}
158
159/// The Office runtime.
160pub struct Office<B: NeuralBackend> {
161    config: OfficeConfig,
162    brain: B,
163    memory: MemorySystem,
164    narrator: Narrator,
165    metrics: OfficeMetrics,
166    state: OfficeState,
167    state_tx: watch::Sender<OfficeState>,
168    history: Vec<Message>,
169    last_dream_time: Option<Instant>,
170}
171
172impl<B: NeuralBackend> Office<B> {
173    /// Create a new Office with the given config and brain.
174    ///
175    /// Returns the Office and a state receiver for monitoring.
176    pub fn new(config: OfficeConfig, brain: B) -> (Self, watch::Receiver<OfficeState>) {
177        let (state_tx, state_rx) = watch::channel(OfficeState::Opening);
178
179        let narrator_config = NarratorConfig {
180            system_directive: format!(
181                "IDENTITY: {}\nSESSION: {:?}/{:?}\nYou are an autonomous professional agent in LogLine OS.\nOutput MUST be a valid TDLN SemanticUnit JSON.",
182                config.tenant_id, config.session_type, config.session_mode
183            ),
184            constraints: Self::build_constraints(&config),
185            constitution: None,
186            session_type: config.session_type,
187            session_mode: config.session_mode,
188        };
189
190        let office = Self {
191            config,
192            brain,
193            memory: MemorySystem::new(),
194            narrator: Narrator::new(narrator_config),
195            metrics: OfficeMetrics::default(),
196            state: OfficeState::Opening,
197            state_tx,
198            history: Vec::new(),
199            last_dream_time: None,
200        };
201
202        (office, state_rx)
203    }
204
205    fn build_constraints(config: &OfficeConfig) -> Vec<String> {
206        let mut constraints = vec![
207            "Never execute write tools unless Gate:Permit".into(),
208            "Prefer simulate() for risk_score ≥ 0.7".into(),
209        ];
210
211        match config.session_type {
212            SessionType::Work => {
213                constraints.push("May sign & act; write receipts for all actions".into());
214            }
215            SessionType::Assist => {
216                constraints.push("Propose only, never act; include remediation steps".into());
217            }
218            SessionType::Deliberate => {
219                constraints.push("Think-only; do NOT call tools".into());
220            }
221            SessionType::Research => {
222                constraints.push("Read-only tools allowed; summarize sources with citations".into());
223            }
224        }
225
226        match config.session_mode {
227            SessionMode::Commitment => {
228                constraints.push("Actions are binding; all decisions produce receipts".into());
229            }
230            SessionMode::Deliberation => {
231                constraints.push("Proposals only; nothing is executed".into());
232            }
233        }
234
235        constraints
236    }
237
238    /// Get current state.
239    #[must_use]
240    pub fn state(&self) -> OfficeState {
241        self.state
242    }
243
244    /// Get metrics.
245    #[must_use]
246    pub fn metrics(&self) -> &OfficeMetrics {
247        &self.metrics
248    }
249
250    /// Get mutable metrics.
251    pub fn metrics_mut(&mut self) -> &mut OfficeMetrics {
252        &mut self.metrics
253    }
254
255    /// Get config.
256    #[must_use]
257    pub fn config(&self) -> &OfficeConfig {
258        &self.config
259    }
260
261    /// Get memory system.
262    #[must_use]
263    pub fn memory(&self) -> &MemorySystem {
264        &self.memory
265    }
266
267    /// Get mutable memory system.
268    pub fn memory_mut(&mut self) -> &mut MemorySystem {
269        &mut self.memory
270    }
271
272    fn set_state(&mut self, state: OfficeState) {
273        self.state = state;
274        let _ = self.state_tx.send(state);
275    }
276
277    /// Check if dreaming is needed.
278    fn needs_dream(&self) -> bool {
279        // Check cycle count
280        if self.metrics.decisions_since_dream >= self.config.dream.dream_every_n_cycles {
281            // Check minimum interval
282            if let Some(last) = self.last_dream_time {
283                let min_interval = Duration::from_secs(self.config.dream.dream_min_interval_secs);
284                return last.elapsed() >= min_interval;
285            }
286            return true;
287        }
288        false
289    }
290
291    /// Check token budget before a decision.
292    fn check_budget(&self) -> Result<(), OfficeError> {
293        // Check daily quota
294        let total_today = self.metrics.input_tokens_today + self.metrics.output_tokens_today;
295        if total_today >= self.config.budget.daily_token_quota {
296            return Err(OfficeError::QuotaExceeded(format!(
297                "daily quota exceeded: {} >= {}",
298                total_today, self.config.budget.daily_token_quota
299            )));
300        }
301        Ok(())
302    }
303
304    /// Open the office: load constitution, initialize.
305    pub async fn open(&mut self) -> Result<(), OfficeError> {
306        self.set_state(OfficeState::Opening);
307
308        // Load constitution if specified
309        if let Some(ref path) = self.config.constitution_path {
310            if path.exists() {
311                let constitution = tokio::fs::read_to_string(path)
312                    .await
313                    .map_err(|e| OfficeError::Config(format!("failed to load constitution: {e}")))?;
314                self.narrator.set_constitution(constitution);
315            }
316        }
317
318        self.set_state(OfficeState::Active);
319        Ok(())
320    }
321
322    /// Run one OODA step.
323    ///
324    /// Returns the intent (if any) or an error.
325    pub async fn step(&mut self, input: Option<&str>) -> Result<Option<tdln_ast::SemanticUnit>, OfficeError> {
326        if self.state != OfficeState::Active {
327            return Ok(None);
328        }
329
330        self.metrics.steps_total += 1;
331
332        // Check if we need to dream
333        if self.needs_dream() {
334            self.dream().await?;
335        }
336
337        // Check budget
338        self.check_budget()?;
339
340        // Add user input to history if present
341        if let Some(input) = input {
342            self.history.push(Message::user(input));
343            self.memory.remember(format!("User: {input}"));
344        }
345
346        // Orient: build cognitive context
347        let ctx = self.narrator.orient(&self.memory, self.history.clone());
348
349        // Build generation config with budget limits
350        let gen_config = GenerationConfig {
351            max_tokens: Some(self.config.budget.max_output_tokens),
352            ..GenerationConfig::default()
353        };
354
355        // Decide: call brain
356        let messages = ctx.render();
357        let raw = self.brain.generate(&messages, &gen_config).await?;
358
359        // Track token usage
360        self.metrics.input_tokens_today += u64::from(raw.meta.input_tokens);
361        self.metrics.output_tokens_today += u64::from(raw.meta.output_tokens);
362
363        // Parse decision
364        let decision = tdln_brain::parser::parse_decision(&raw.content, raw.meta)?;
365        self.metrics.decisions_total += 1;
366        self.metrics.decisions_since_dream += 1;
367        self.metrics.consecutive_errors = 0;
368
369        // Remember the decision
370        self.memory.remember(format!("Decision: {}", decision.intent.kind));
371        self.history.push(Message::assistant(&raw.content));
372
373        // Increment narrator maintenance counter
374        self.narrator.increment_maintenance_counter();
375
376        Ok(Some(decision.intent))
377    }
378
379    /// Enter maintenance mode (dreaming).
380    pub async fn dream(&mut self) -> Result<(), OfficeError> {
381        let prev_state = self.state;
382        self.set_state(OfficeState::Maintenance);
383
384        // Consolidate memory
385        let events: Vec<String> = self.memory.recent(20);
386        self.memory.consolidate(&events);
387
388        // Trim history
389        if self.history.len() > 20 {
390            self.history = self.history.split_off(self.history.len() - 10);
391        }
392
393        // Reset counters
394        self.metrics.decisions_since_dream = 0;
395        self.metrics.dreams_total += 1;
396        self.last_dream_time = Some(Instant::now());
397        self.narrator.reset_maintenance_counter();
398
399        self.set_state(prev_state);
400        Ok(())
401    }
402
403    /// Run the main loop until shutdown or fatal error.
404    pub async fn run(mut self) -> Result<(), OfficeError> {
405        self.open().await?;
406
407        loop {
408            // Pause between steps
409            tokio::time::sleep(Duration::from_millis(self.config.step_pause_ms)).await;
410
411            match self.step(None).await {
412                Ok(_) => {}
413                Err(OfficeError::Shutdown) => {
414                    self.set_state(OfficeState::Closing);
415                    break;
416                }
417                Err(OfficeError::QuotaExceeded(msg)) => {
418                    tracing::warn!("quota exceeded: {msg}");
419                    // Don't increment error counter for quota; just skip
420                    continue;
421                }
422                Err(e) => {
423                    self.metrics.consecutive_errors += 1;
424                    tracing::warn!("step error: {e}");
425
426                    if self.metrics.consecutive_errors >= self.config.max_consecutive_errors {
427                        self.set_state(OfficeState::Closing);
428                        return Err(e);
429                    }
430
431                    // Exponential backoff
432                    let delay = Duration::from_millis(
433                        100 * (2_u64.pow(self.metrics.consecutive_errors.min(6))),
434                    );
435                    tokio::time::sleep(delay).await;
436                }
437            }
438        }
439
440        Ok(())
441    }
442
443    /// Shutdown the office gracefully.
444    pub fn shutdown(&mut self) {
445        self.set_state(OfficeState::Closing);
446    }
447
448    /// Check if session allows tool execution.
449    #[must_use]
450    pub fn can_execute_tools(&self) -> bool {
451        match self.config.session_type {
452            SessionType::Work => true,
453            SessionType::Research => true, // read-only
454            SessionType::Assist | SessionType::Deliberate => false,
455        }
456    }
457
458    /// Check if session allows write operations.
459    #[must_use]
460    pub fn can_write(&self) -> bool {
461        matches!(
462            (&self.config.session_type, &self.config.session_mode),
463            (SessionType::Work, SessionMode::Commitment)
464        )
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use serde_json::json;
472    use tdln_brain::MockBackend;
473
474    #[tokio::test]
475    async fn state_transitions() {
476        let backend = MockBackend::with_intent("noop", json!({}));
477        let (mut office, rx) = Office::new(OfficeConfig::default(), backend);
478
479        assert_eq!(office.state(), OfficeState::Opening);
480        assert_eq!(*rx.borrow(), OfficeState::Opening);
481
482        office.open().await.unwrap();
483        assert_eq!(office.state(), OfficeState::Active);
484    }
485
486    #[tokio::test]
487    async fn step_produces_intent() {
488        let backend = MockBackend::with_intent("greet", json!({"name": "alice"}));
489        let (mut office, _) = Office::new(OfficeConfig::default(), backend);
490        office.open().await.unwrap();
491
492        let intent = office.step(Some("say hello")).await.unwrap();
493        assert!(intent.is_some());
494        assert_eq!(intent.unwrap().kind, "greet");
495    }
496
497    #[tokio::test]
498    async fn dream_consolidates() {
499        let backend = MockBackend::with_intent("noop", json!({}));
500        let (mut office, _) = Office::new(OfficeConfig::default(), backend);
501        office.open().await.unwrap();
502
503        // Add some memory
504        for i in 0..80 {
505            office.memory.remember(format!("event {i}"));
506        }
507        let before = office.memory.short_term_len();
508
509        office.dream().await.unwrap();
510        
511        // Consolidate should reduce memory
512        assert!(office.memory.short_term_len() < before);
513    }
514
515    #[tokio::test]
516    async fn metrics_increment() {
517        let backend = MockBackend::with_intent("test", json!({}));
518        let (mut office, _) = Office::new(OfficeConfig::default(), backend);
519        office.open().await.unwrap();
520
521        office.step(Some("test")).await.unwrap();
522        assert_eq!(office.metrics().steps_total, 1);
523        assert_eq!(office.metrics().decisions_total, 1);
524    }
525}