1use 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#[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
32pub(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 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 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 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 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 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 #[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 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#[derive(Debug, Default)]
403pub(crate) struct ArbiterState {
404 pub(crate) waiting_on: Vec<String>,
405 polling: bool,
406}
407
408const WAIT_POLL: Duration = Duration::from_secs(2);
410
411async 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}