1use serde_json::{Value, json};
6use tokio_util::sync::CancellationToken;
7use yapi_agent::hooks::TurnDecision;
8use yapi_types::event::AgentEvent;
9use yapi_types::extension_event::ExtensionEvent;
10use yapi_types::message::{AssistantMessage, Content, Message, StopReason, ToolResultMessage};
11use yapi_types::session::FileEntry;
12use yapi_types::sync::lock;
13
14use crate::compaction::estimate_projected_context_tokens;
15use crate::session::{SessionManager, build_projection};
16
17use super::AgentSession;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Boundary {
22 TurnEnd,
24 BeforeSettle,
26}
27
28impl Boundary {
29 pub fn kind(self) -> &'static str {
31 match self {
32 Boundary::TurnEnd => "turn_end",
33 Boundary::BeforeSettle => "agent_before_settle",
34 }
35 }
36
37 pub fn parse(kind: &str) -> Option<Boundary> {
39 match kind {
40 "turn_end" => Some(Boundary::TurnEnd),
41 "agent_before_settle" => Some(Boundary::BeforeSettle),
42 _ => None,
43 }
44 }
45}
46
47const INVALID_REPLACEMENT: &str =
48 "Context edit replacement must be null or contain string/array content";
49
50fn apply_drafts(manager: &mut SessionManager, drafts: &[Value]) -> Result<Vec<FileEntry>, String> {
53 let mut appended = Vec::new();
54 for draft in drafts {
55 let text = |key: &str| draft[key].as_str().unwrap_or_default().to_owned();
56 let optional = |key: &str| Some(draft[key].clone()).filter(|value| !value.is_null());
57 let id = match draft["type"].as_str() {
58 Some("custom") => manager.append_custom_entry(&text("customType"), optional("data")),
59 Some("custom_message") => {
60 let content = serde_json::from_value::<Content>(draft["content"].clone())
61 .map_err(|err| err.to_string())?;
62 manager.append_custom_message(
63 &text("customType"),
64 content,
65 draft["display"] == true,
66 optional("details"),
67 )
68 }
69 Some("context_edit") => {
70 let replacement = match &draft["replacement"] {
71 Value::Null => None,
72 Value::Object(replacement) => Some(
73 serde_json::from_value::<Content>(
74 replacement.get("content").cloned().unwrap_or_default(),
75 )
76 .map_err(|_| INVALID_REPLACEMENT)?,
77 ),
78 _ => return Err(INVALID_REPLACEMENT.into()),
79 };
80 manager.append_context_edit(&text("targetId"), replacement)
81 }
82 Some("compaction") => {
83 let branch = manager.branch_path(None);
84 let tokens_before =
85 estimate_projected_context_tokens(&build_projection(&branch), &branch).tokens;
86 manager.append_compaction(
87 text("summary"),
88 draft["firstKeptEntryId"].as_str().map(str::to_owned),
89 tokens_before,
90 optional("details"),
91 Some(true),
92 serde_json::from_value(draft["usage"].clone()).ok(),
93 )
94 }
95 _ => continue,
97 }
98 .map_err(|err| err.to_string())?;
99 appended.extend(manager.entry(&id).cloned());
100 }
101 Ok(appended)
102}
103
104impl AgentSession {
105 fn preview_with(&self, drafts: &[Value]) -> Result<SessionManager, String> {
107 let mut preview = self.with_session(|session| session.preview());
108 apply_drafts(&mut preview, drafts)?;
109 Ok(preview)
110 }
111
112 fn pending_boundary_messages(&self) -> Vec<Message> {
115 let (steering_all, follow_up_all) = self.drains_all();
116 let peek = |queue: &std::sync::Mutex<std::collections::VecDeque<Message>>, all| {
117 let queue = lock(queue);
118 let count = if all { queue.len() } else { 1 };
119 queue.iter().take(count).cloned().collect::<Vec<_>>()
120 };
121 let mut pending = peek(&self.inner.steering, steering_all);
122 if pending.is_empty() {
123 pending = peek(&self.inner.follow_up, follow_up_all);
124 }
125 pending.extend(lock(&self.inner.pending_custom).iter().cloned());
126 pending
127 }
128
129 fn can_continue(&self, boundary: Boundary, llm: &[Message]) -> bool {
132 let final_assistant = matches!(llm.last(), Some(Message::Assistant(_)));
133 let has_context = llm
134 .iter()
135 .any(|message| !matches!(message, Message::System(_)));
136 (has_context && !final_assistant)
137 || !lock(&self.inner.pending_custom).is_empty()
138 || match boundary {
139 Boundary::TurnEnd => self.has_queued(),
140 Boundary::BeforeSettle => final_assistant && self.has_queued(),
141 }
142 }
143
144 pub fn boundary_context(&self, boundary: Boundary, drafts: &[Value]) -> Result<Value, String> {
148 let preview = self.preview_with(drafts)?;
149 let branch = preview.branch_path(None);
150 let projection = build_projection(&branch);
151 let llm = crate::messages::convert_to_llm(projection.messages.clone());
152 let entries: Vec<Value> = projection
153 .entries
154 .iter()
155 .map(|entry| json!({"sourceEntry": entry.source, "messages": entry.messages}))
156 .collect();
157 Ok(json!({
158 "contextEntries": entries,
159 "contextMessages": projection.messages,
160 "llmMessages": llm,
161 "pendingMessages": self.pending_boundary_messages(),
162 "canContinue": self.can_continue(boundary, &llm),
163 }))
164 }
165
166 fn can_continue_now(&self, boundary: Boundary) -> bool {
168 let llm = crate::messages::convert_to_llm(self.messages());
169 self.can_continue(boundary, &llm)
170 }
171
172 async fn emit_boundary(
177 &self,
178 base: Value,
179 boundary: Boundary,
180 cancel: CancellationToken,
181 ) -> (Vec<Value>, bool) {
182 let ctx = self.extension_context(cancel);
183 let mut entries: Vec<Value> = Vec::new();
184 let mut proceed = false;
185 let mut valid = true;
186 for extension in self.handlers_of(boundary.kind()) {
187 let mut event = base.clone();
188 event["entries"] = Value::Array(entries.clone());
189 event["continue"] = proceed.into();
190 if let Some(result) = extension.handle(&ctx, &event).await {
191 let granted = || {
193 self.applies(
194 extension,
195 boundary.kind(),
196 extension.grants().session,
197 super::extensions::SESSION,
198 )
199 };
200 if let Some(next) = result["entries"].as_array()
202 && *next != entries
203 && granted()
204 {
205 entries.clone_from(next);
206 }
207 if let Some(next) = result["continue"].as_bool()
208 && next != proceed
209 && (!next || granted())
210 {
211 proceed = next;
212 }
213 }
214 valid = match self.preview_with(&entries) {
215 Ok(_) => true,
216 Err(error) => {
217 ctx.ui.extension_error(
218 &extension.source().path,
219 boundary.kind(),
220 &format!("Invalid boundary entries: {error}"),
221 None,
222 );
223 false
224 }
225 };
226 }
227 if valid {
228 (entries, proceed)
229 } else {
230 (Vec::new(), false)
231 }
232 }
233
234 fn commit_drafts(&self, drafts: &[Value]) {
237 if drafts.is_empty() {
238 return;
239 }
240 let appended = self
241 .with_session(|session| apply_drafts(session, drafts))
242 .unwrap_or_default();
243 for entry in &appended {
244 self.emit(&AgentEvent::EntryAppended {
245 entry: entry.clone(),
246 });
247 }
248 }
249
250 fn invalid_continuation(&self, boundary: Boundary) {
251 let (ui, _) = self.extension_binding();
252 let kind = boundary.kind();
253 ui.extension_error(
254 "<boundary>",
255 kind,
256 &format!("{kind} requested continuation without runnable model context"),
257 None,
258 );
259 }
260
261 pub(super) async fn turn_end_boundary(
264 &self,
265 message: &AssistantMessage,
266 tool_results: &[ToolResultMessage],
267 turn_index: u64,
268 cancel: CancellationToken,
269 ) -> TurnDecision {
270 let outcome = match message.stop_reason {
271 StopReason::Aborted => "aborted",
272 StopReason::Error => "error",
273 _ => "completed",
274 };
275 *lock(&self.inner.outcome) = outcome;
276 if !self.has_handlers("turn_end") {
277 return TurnDecision::default();
278 }
279 let (entry_id, tool_result_ids) = {
280 let recovery = lock(&self.inner.recovery);
281 (
282 recovery
283 .last_assistant
284 .as_ref()
285 .and_then(|(_, id)| id.clone()),
286 recovery.turn_tool_results.clone(),
287 )
288 };
289 let Some(entry_id) = entry_id else {
290 let (ui, _) = self.extension_binding();
291 ui.extension_error(
292 "<boundary>",
293 "turn_end",
294 "turn_end could not resolve the persisted assistant entry ID",
295 None,
296 );
297 return TurnDecision::default();
298 };
299 let tool_results: Vec<Message> = tool_results
300 .iter()
301 .cloned()
302 .map(Message::ToolResult)
303 .collect();
304 let message = Message::Assistant(Box::new(message.clone()));
305 let base = ExtensionEvent::TurnEnd {
306 turn_index,
307 message: &message,
308 tool_results: &tool_results,
309 message_entry_id: &entry_id,
310 tool_result_entry_ids: &tool_result_ids,
311 outcome,
312 }
313 .to_value();
314 let (entries, proceed) = self.emit_boundary(base, Boundary::TurnEnd, cancel).await;
315 self.commit_drafts(&entries);
316 let continue_run = proceed && self.can_continue_now(Boundary::TurnEnd);
317 if proceed && !continue_run {
318 self.invalid_continuation(Boundary::TurnEnd);
319 }
320 TurnDecision { continue_run }
321 }
322
323 pub(super) async fn before_settle(&self, cancel: &CancellationToken) -> bool {
326 if !self.has_handlers("agent_before_settle") {
327 return self.has_queued();
328 }
329 let outcome = *lock(&self.inner.outcome);
330 let base = ExtensionEvent::AgentBeforeSettle { outcome }.to_value();
331 let (entries, proceed) = self
332 .emit_boundary(base, Boundary::BeforeSettle, cancel.clone())
333 .await;
334 self.commit_drafts(&entries);
335 self.flush_pending_custom();
336 if cancel.is_cancelled() {
337 return false;
338 }
339 let should_continue = proceed || self.has_queued();
340 if should_continue && !self.can_continue_now(Boundary::BeforeSettle) {
341 if proceed {
342 self.invalid_continuation(Boundary::BeforeSettle);
343 }
344 return false;
345 }
346 should_continue
347 }
348}