Skip to main content

yapi_core/agent_session/
boundary.rs

1//! pi's boundary events: `turn_end`, where extensions may stage session
2//! entries and ask for one more response, and `agent_before_settle`, the
3//! same before a run settles.
4
5use 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/// pi's boundary events.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Boundary {
22    /// `turn_end`.
23    TurnEnd,
24    /// `agent_before_settle`.
25    BeforeSettle,
26}
27
28impl Boundary {
29    /// The event's type.
30    pub fn kind(self) -> &'static str {
31        match self {
32            Boundary::TurnEnd => "turn_end",
33            Boundary::BeforeSettle => "agent_before_settle",
34        }
35    }
36
37    /// The boundary an event type names.
38    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
50/// pi's `_applyBoundaryDrafts`: appends the entries `drafts` describe to
51/// `manager`, and returns them.
52fn 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            // pi appends nothing for other drafts.
96            _ => 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    /// The session as it would be with `drafts` appended.
106    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    /// pi's `_getPendingBoundaryMessages`: queued messages the next turn
113    /// would take, then extension messages waiting for the turn to end.
114    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    /// Whether `llm`, the model context of a session, can be continued at
130    /// `boundary`, counting queued and waiting messages.
131    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    /// pi's `BoundaryContextPreview` at `boundary` with `drafts` staged:
145    /// the model context entry by entry, as messages and as the provider
146    /// sees them, the pending messages, and whether the run can continue.
147    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    /// Whether the session as it is can be continued at `boundary`.
167    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    /// pi's `emitBoundary`: each extension sees the entries and the
173    /// continuation earlier ones staged and may replace them. Entries that
174    /// cannot be appended are reported, and when the last extension leaves
175    /// them so, nothing is staged and the run does not continue.
176    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                // Without the session grant, a handler may only stop the run.
192                let granted = || {
193                    self.applies(
194                        extension,
195                        boundary.kind(),
196                        extension.grants().session,
197                        super::extensions::SESSION,
198                    )
199                };
200                // The runtime reports both whether or not a handler changed them.
201                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    /// pi's `_commitBoundaryDrafts`: appends the staged entries and reports
235    /// each.
236    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    /// pi's `turn_end` boundary for the turn that produced `message` and
262    /// `tool_results`: whether to continue.
263    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    /// pi's `agent_before_settle` boundary once a run would settle: whether
324    /// to run again.
325    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}