Skip to main content

oino_core/session/
boundary.rs

1//! Session boundaries (`_dispatchTurnEndBoundary`, `_runBeforeSettleBoundary`,
2//! `_buildBoundaryContext`, `_applyBoundaryDrafts`, `_commitBoundaryDrafts`).
3
4use oino_ai::{AgentMessage, AssistantMessage, JsonValue, StopReason, ToolResultMessage};
5use oino_session::{
6    CompactionDraft, CreateOptions, EditableContent, EntryId, SessionEntry, SessionManager,
7};
8use serde_json::json;
9
10use std::sync::{Arc, Weak};
11use std::time::Duration;
12
13use super::{ActivityOutcome, AgentSession, Inner, IsolatedTask, lock};
14use crate::events::{OinoEvent, RunState};
15
16/// Which boundary is being built.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub(crate) enum Boundary {
19    TurnEnd,
20    BeforeSettle,
21}
22
23impl Boundary {
24    const fn name(self) -> &'static str {
25        match self {
26            Self::TurnEnd => "turn_end",
27            Self::BeforeSettle => "agent_before_settle",
28        }
29    }
30}
31
32/// Applies boundary drafts to `manager`.
33///
34/// # Errors
35///
36/// The first invalid draft.
37pub(crate) fn apply_drafts(
38    manager: &mut SessionManager,
39    drafts: &[JsonValue],
40) -> Result<Vec<EntryId>, String> {
41    let mut appended = Vec::new();
42    for draft in drafts {
43        let text = |key: &str| {
44            draft
45                .get(key)
46                .and_then(JsonValue::as_str)
47                .map(str::to_owned)
48        };
49        let id = match draft.get("type").and_then(JsonValue::as_str) {
50            Some("custom") => manager.append_custom_entry(
51                &text("customType").ok_or("custom draft without customType")?,
52                draft.get("data").cloned(),
53            ),
54            Some("custom_message") => manager.append_custom_message_entry(
55                &text("customType").ok_or("custom_message draft without customType")?,
56                draft
57                    .get("content")
58                    .and_then(|c| serde_json::from_value(c.clone()).ok())
59                    .unwrap_or_default(),
60                draft
61                    .get("display")
62                    .and_then(JsonValue::as_bool)
63                    .unwrap_or(false),
64                draft.get("details").cloned(),
65            ),
66            Some("context_edit") => {
67                let target =
68                    EntryId::new(text("targetId").ok_or("context_edit draft without targetId")?);
69                let replacement = match draft.get("replacement") {
70                    None | Some(JsonValue::Null) => None,
71                    Some(value) => Some(
72                        serde_json::from_value::<EditableContent>(value["content"].clone())
73                            .map_err(|e| e.to_string())?,
74                    ),
75                };
76                manager.append_context_edit(&target, replacement)
77            }
78            Some("compaction") => {
79                let tokens_before = crate::compaction::estimate_projected_context_tokens(
80                    &manager.build_session_projection(),
81                    &manager.branch(None),
82                )
83                .tokens;
84                manager.append_compaction(CompactionDraft {
85                    summary: text("summary").unwrap_or_default(),
86                    first_kept_entry_id: text("firstKeptEntryId").map(EntryId::new),
87                    tokens_before,
88                    details: draft.get("details").cloned(),
89                    from_hook: Some(true),
90                    usage: draft
91                        .get("usage")
92                        .and_then(|u| serde_json::from_value(u.clone()).ok()),
93                    system_message: None,
94                })
95            }
96            other => {
97                return Err(format!(
98                    "Unknown boundary entry type: {}",
99                    other.unwrap_or("undefined")
100                ));
101            }
102        }
103        .map_err(|e| e.to_string())?;
104        appended.push(id);
105    }
106    Ok(appended)
107}
108
109impl AgentSession {
110    fn preview_manager(&self, drafts: &[JsonValue]) -> Result<SessionManager, String> {
111        let (cwd, branch): (std::path::PathBuf, Vec<SessionEntry>) = {
112            let manager = lock(&self.inner.manager);
113            (
114                manager.cwd().to_path_buf(),
115                manager.branch(None).into_iter().cloned().collect(),
116            )
117        };
118        let mut preview = SessionManager::in_memory(&cwd, CreateOptions::default(), branch);
119        apply_drafts(&mut preview, drafts)?;
120        Ok(preview)
121    }
122
123    /// `_buildBoundaryContext` (`BoundaryContextPreview` JSON).
124    pub(crate) fn build_boundary_context(
125        &self,
126        drafts: &[JsonValue],
127        boundary: Boundary,
128    ) -> Result<JsonValue, String> {
129        let projection = self.preview_manager(drafts)?.build_session_projection();
130        let pending: Vec<AgentMessage> = self
131            .inner
132            .agent
133            .peek_queued_messages()
134            .into_iter()
135            .chain(
136                lock(&self.inner.book)
137                    .pending_custom
138                    .iter()
139                    .cloned()
140                    .map(AgentMessage::Custom),
141            )
142            .collect();
143        let llm = oino_session::convert_to_llm(&projection.messages);
144        let final_role = llm.last().map(oino_ai::Message::role);
145        let has_context = llm
146            .iter()
147            .any(|m| !matches!(m, oino_ai::Message::System(_)));
148        let context_can_continue = has_context && final_role != Some("assistant");
149        let pending_custom = !lock(&self.inner.book).pending_custom.is_empty();
150        let queued = self.inner.agent.has_queued_messages();
151        let can_continue = context_can_continue
152            || pending_custom
153            || match boundary {
154                Boundary::TurnEnd => queued,
155                Boundary::BeforeSettle => final_role == Some("assistant") && queued,
156            };
157        Ok(json!({
158            "contextEntries": projection.entries,
159            "contextMessages": projection.messages,
160            "llmMessages": llm,
161            "pendingMessages": pending,
162            "canContinue": can_continue,
163        }))
164    }
165
166    fn can_continue(&self, boundary: Boundary) -> bool {
167        self.build_boundary_context(&[], boundary)
168            .ok()
169            .and_then(|c| c["canContinue"].as_bool())
170            .unwrap_or(false)
171    }
172
173    pub(crate) fn commit_boundary_drafts(&self, drafts: &[JsonValue]) {
174        if drafts.is_empty() {
175            return;
176        }
177        let appended = apply_drafts(&mut lock(&self.inner.manager), drafts);
178        // D-FLUSH barrier 3: boundary entries are durable before the run continues.
179        self.boundary_sync("boundary drafts");
180        self.refresh_context();
181        match appended {
182            Ok(ids) => {
183                for id in ids {
184                    self.publish_entry(&id);
185                }
186            }
187            Err(error) => self.runner().emit_error("<boundary>", "boundary", error),
188        }
189    }
190
191    fn report_invalid_continuation(&self, boundary: Boundary) {
192        let name = boundary.name();
193        self.runner().emit_error(
194            "<boundary>",
195            name,
196            format!("{name} requested continuation without runnable model context"),
197        );
198    }
199
200    /// `_dispatchTurnEndBoundary`; returns whether an extension asked to continue.
201    pub(crate) async fn dispatch_turn_end_boundary(
202        &self,
203        message: &AssistantMessage,
204        tool_results: &[ToolResultMessage],
205    ) -> bool {
206        let outcome = match message.stop_reason {
207            StopReason::Aborted => ActivityOutcome::Aborted,
208            StopReason::Error => ActivityOutcome::Error,
209            _ => ActivityOutcome::Completed,
210        };
211        let (turn_index, entry_id, tool_ids) = {
212            let mut book = lock(&self.inner.book);
213            book.last_outcome = outcome;
214            let ids: Vec<String> = tool_results
215                .iter()
216                .filter_map(|result| {
217                    book.tool_result_entries
218                        .iter()
219                        .find(|(call, _)| *call == result.tool_call_id)
220                        .map(|(_, id)| id.as_str().to_owned())
221                })
222                .collect();
223            (book.turn_index, book.last_assistant_entry.clone(), ids)
224        };
225        let runner = self.runner();
226        if !runner.has_handlers("turn_end") {
227            return false;
228        }
229        let Some(entry_id) = entry_id else {
230            runner.emit_error(
231                "<boundary>",
232                "turn_end",
233                "turn_end could not resolve the persisted assistant entry ID",
234            );
235            return false;
236        };
237        let mut base = serde_json::Map::new();
238        base.insert("type".into(), json!("turn_end"));
239        base.insert("turnIndex".into(), json!(turn_index));
240        base.insert(
241            "message".into(),
242            json!(AgentMessage::Assistant(message.clone())),
243        );
244        base.insert(
245            "toolResults".into(),
246            json!(
247                tool_results
248                    .iter()
249                    .cloned()
250                    .map(AgentMessage::ToolResult)
251                    .collect::<Vec<_>>()
252            ),
253        );
254        base.insert("messageEntryId".into(), json!(entry_id.as_str()));
255        base.insert("toolResultEntryIds".into(), json!(tool_ids));
256        base.insert("outcome".into(), json!(outcome));
257        let session = self.clone();
258        let build =
259            move |drafts: &[JsonValue]| session.build_boundary_context(drafts, Boundary::TurnEnd);
260        let result = runner.emit_boundary(base, &build).await;
261        self.commit_boundary_drafts(&result.entries);
262        if result.continue_run && !self.can_continue(Boundary::TurnEnd) {
263            self.report_invalid_continuation(Boundary::TurnEnd);
264            return false;
265        }
266        result.continue_run
267    }
268
269    /// `_runBeforeSettleBoundary`; returns whether the run continues. When the handlers
270    /// leave the run settling, the turn arbiter (runtime §7.6) may still continue it.
271    pub(crate) async fn run_before_settle_boundary(&self) -> bool {
272        let runner = self.runner();
273        if !runner.has_handlers("agent_before_settle") {
274            if self.inner.agent.has_queued_messages() {
275                return true;
276            }
277            return self.run_arbiter().await;
278        }
279        let outcome = {
280            let mut book = lock(&self.inner.book);
281            book.is_before_settle = true;
282            book.abort_during_before_settle = false;
283            book.last_outcome
284        };
285        let mut base = serde_json::Map::new();
286        base.insert("type".into(), json!("agent_before_settle"));
287        base.insert("outcome".into(), json!(outcome));
288        let session = self.clone();
289        let build = move |drafts: &[JsonValue]| {
290            session.build_boundary_context(drafts, Boundary::BeforeSettle)
291        };
292        let result = runner.emit_boundary(base, &build).await;
293        self.commit_boundary_drafts(&result.entries);
294        self.flush_pending_custom_messages();
295        let can_continue = self.can_continue(Boundary::BeforeSettle);
296        let aborted = {
297            let mut book = lock(&self.inner.book);
298            book.is_before_settle = false;
299            book.abort_during_before_settle
300        };
301        if aborted {
302            return false;
303        }
304        let should_continue = result.continue_run || self.inner.agent.has_queued_messages();
305        if should_continue && !can_continue {
306            if result.continue_run {
307                self.report_invalid_continuation(Boundary::BeforeSettle);
308            }
309            return false;
310        }
311        if should_continue {
312            return true;
313        }
314        self.run_arbiter().await
315    }
316
317    /// Turn arbiter (runtime §7.6, D9): when the run would settle normally with nothing
318    /// pending, wait sources may hold the session in `waiting`, else the highest-priority
319    /// nudge is appended as a `custom_message` and the run continues. Disabled in children.
320    async fn run_arbiter(&self) -> bool {
321        if self.is_child() || self.abort_requested() {
322            return false;
323        }
324        let (outcome, pending_custom) = {
325            let book = lock(&self.inner.book);
326            (book.last_outcome, !book.pending_custom.is_empty())
327        };
328        if outcome != ActivityOutcome::Completed
329            || pending_custom
330            || self.inner.agent.has_queued_messages()
331            || self.pending_message_count() > 0
332        {
333            return false;
334        }
335        let runner = self.runner();
336        let reasons = runner.arbiter_wait_reasons().await;
337        if !reasons.is_empty() {
338            self.set_waiting(reasons);
339            return false;
340        }
341        let Some(nudge) = runner.arbiter_nudge().await else {
342            return false;
343        };
344        if self.abort_requested() {
345            return false;
346        }
347        let mut draft = json!({
348            "type": "custom_message",
349            "customType": nudge["customType"],
350            "content": nudge.get("content").cloned().unwrap_or_else(|| json!("")),
351            "display": nudge.get("display").and_then(JsonValue::as_bool).unwrap_or(true),
352        });
353        if let Some(details) = nudge.get("details").filter(|d| !d.is_null()) {
354            draft["details"] = details.clone();
355        }
356        self.commit_boundary_drafts(&[draft]);
357        if !self.can_continue(Boundary::BeforeSettle) {
358            self.report_invalid_continuation(Boundary::BeforeSettle);
359            return false;
360        }
361        true
362    }
363
364    /// Wait reasons reported at the last settle (empty when not waiting).
365    #[must_use]
366    pub fn waiting_on(&self) -> Vec<String> {
367        lock(&self.inner.arbiter).waiting_on.clone()
368    }
369
370    fn set_waiting(&self, reasons: Vec<String>) {
371        let start_poll = {
372            let mut state = lock(&self.inner.arbiter);
373            state.waiting_on.clone_from(&reasons);
374            !std::mem::replace(&mut state.polling, true)
375        };
376        self.inner.hub.publish(OinoEvent::RunState {
377            state: RunState::Waiting,
378            waiting_on: Some(reasons),
379        });
380        if start_poll {
381            let inner = Arc::downgrade(&self.inner);
382            self.spawn_isolated(IsolatedTask::ArbiterPoll, poll_wait_sources(inner));
383        }
384    }
385
386    /// Leaves `waiting` (a run started or the wait sources went quiet).
387    pub(crate) fn clear_waiting(&self, next: RunState) {
388        let was_waiting = {
389            let mut state = lock(&self.inner.arbiter);
390            !std::mem::take(&mut state.waiting_on).is_empty()
391        };
392        if was_waiting {
393            self.inner.hub.publish(OinoEvent::RunState {
394                state: next,
395                waiting_on: None,
396            });
397        }
398    }
399}
400
401/// Arbiter bookkeeping of a session.
402#[derive(Debug, Default)]
403pub(crate) struct ArbiterState {
404    pub(crate) waiting_on: Vec<String>,
405    polling: bool,
406}
407
408/// How often wait sources are re-asked while the session waits.
409const WAIT_POLL: Duration = Duration::from_secs(2);
410
411/// Re-asks the wait sources while the session is idle and waiting; leaves `waiting` when
412/// they report nothing.
413async fn poll_wait_sources(inner: Weak<Inner>) {
414    loop {
415        tokio::time::sleep(WAIT_POLL).await;
416        let Some(session) = inner.upgrade().map(AgentSession::from_inner) else {
417            return;
418        };
419        if session.waiting_on().is_empty() || !session.is_idle() {
420            break;
421        }
422        let reasons = session.runner().arbiter_wait_reasons().await;
423        if reasons.is_empty() {
424            session.clear_waiting(RunState::Idle);
425            break;
426        }
427        let changed = {
428            let mut state = lock(&session.inner.arbiter);
429            if state.waiting_on.is_empty() {
430                false
431            } else {
432                let changed = state.waiting_on != reasons;
433                state.waiting_on.clone_from(&reasons);
434                changed
435            }
436        };
437        if changed {
438            session.inner.hub.publish(OinoEvent::RunState {
439                state: RunState::Waiting,
440                waiting_on: Some(reasons),
441            });
442        }
443    }
444    if let Some(inner) = inner.upgrade() {
445        lock(&inner.arbiter).polling = false;
446    }
447}