1use std::collections::HashMap;
6use std::time::Instant;
7
8use oino_agent::{
9 AfterToolCall, AfterToolCallContext, AfterToolCallResult, AgentContext, BeforeToolCall,
10 BeforeToolCallContext, BeforeToolCallResult, HookError, RunToolCallOptions, ToolCallOutcome,
11 run_tool_call,
12};
13use oino_ai::{
14 AssistantMessage, JsonObject, NestedStatus, NestedToolCallRecord, NestedToolCalls, ToolCall,
15 Usage, UserContent,
16};
17use tokio_util::sync::CancellationToken;
18
19use super::{AgentSession, lock};
20use crate::events::AgentSessionEvent;
21
22const MAX_CALLS: usize = 256;
24const MAX_ARGUMENT_BYTES_PER_CALL: u64 = 8 * 1024;
25const MAX_ARGUMENT_BYTES_TOTAL: u64 = 32 * 1024;
26const MAX_ERROR_CHARS: usize = 500;
27
28pub(crate) struct Recorder {
30 calls: Vec<(NestedToolCallRecord, Instant)>,
31 complete: bool,
32 argument_bytes: u64,
33 usage: Option<Usage>,
34 next_id: u64,
35}
36
37impl Default for Recorder {
38 fn default() -> Self {
39 Self {
40 calls: Vec::new(),
41 complete: true,
42 argument_bytes: 0,
43 usage: None,
44 next_id: 1,
45 }
46 }
47}
48
49impl Recorder {
50 fn start(&mut self, call: &ToolCall) -> Option<usize> {
51 if self.calls.len() >= MAX_CALLS {
52 self.complete = false;
53 return None;
54 }
55 let json = serde_json::to_string(&call.arguments).unwrap_or_default();
56 let bytes = json.len() as u64;
57 let mut record = NestedToolCallRecord {
58 id: call.id.clone(),
59 name: call.name.clone(),
60 arguments: None,
61 arguments_bytes: None,
62 status: NestedStatus::Unfinished,
63 duration_ms: None,
64 error: None,
65 };
66 if bytes > MAX_ARGUMENT_BYTES_PER_CALL
67 || self.argument_bytes + bytes > MAX_ARGUMENT_BYTES_TOTAL
68 {
69 record.arguments_bytes = Some(bytes);
70 self.complete = false;
71 } else {
72 record.arguments = Some(call.arguments.clone());
73 self.argument_bytes += bytes;
74 }
75 self.calls.push((record, Instant::now()));
76 Some(self.calls.len() - 1)
77 }
78
79 fn finish(&mut self, index: Option<usize>, outcome: &ToolCallOutcome) {
80 let Some((record, started)) = index.and_then(|i| self.calls.get_mut(i)) else {
81 return;
82 };
83 record.status = if outcome.is_error {
84 NestedStatus::Error
85 } else {
86 NestedStatus::Ok
87 };
88 record.duration_ms = Some(u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX));
89 let text: Vec<&str> = outcome
90 .result
91 .content
92 .iter()
93 .filter_map(|block| match block {
94 UserContent::Text(text) => Some(text.text.as_str()),
95 UserContent::Image(_) => None,
96 })
97 .collect();
98 let text = text.join("\n");
99 if outcome.is_error && !text.is_empty() {
100 record.error = Some(text.chars().take(MAX_ERROR_CHARS).collect());
101 }
102 if let Some(usage) = &outcome.result.usage {
103 match &mut self.usage {
104 Some(total) => total.accumulate(usage),
105 None => self.usage = Some(*usage),
106 }
107 }
108 }
109
110 fn snapshot(&self) -> Option<NestedToolCalls> {
111 if self.calls.is_empty() && self.complete {
112 return None;
113 }
114 let calls: Vec<NestedToolCallRecord> = self.calls.iter().map(|(r, _)| r.clone()).collect();
115 let complete = self.complete && calls.iter().all(|c| c.status != NestedStatus::Unfinished);
116 Some(NestedToolCalls { calls, complete })
117 }
118}
119
120#[derive(Default)]
122pub(crate) struct NestedState {
123 recorders: HashMap<String, Recorder>,
124 roots: HashMap<String, String>,
125 taken: HashMap<String, (Option<NestedToolCalls>, Option<Usage>)>,
127}
128
129struct NestedHooks {
130 session: AgentSession,
131 parent: String,
132}
133
134impl BeforeToolCall for NestedHooks {
135 fn before<'a>(
136 &'a self,
137 cx: BeforeToolCallContext<'a>,
138 _cancel: &'a CancellationToken,
139 ) -> futures::future::BoxFuture<'a, Result<Option<BeforeToolCallResult>, HookError>> {
140 Box::pin(async move {
141 self.session
142 .before_tool_call(cx.tool_call, cx.args, Some(&self.parent))
143 .await
144 })
145 }
146}
147
148impl AfterToolCall for NestedHooks {
149 fn after<'a>(
150 &'a self,
151 cx: AfterToolCallContext<'a>,
152 _cancel: &'a CancellationToken,
153 ) -> futures::future::BoxFuture<'a, Result<Option<AfterToolCallResult>, HookError>> {
154 Box::pin(async move { Ok(self.session.after_tool_call(&cx, Some(&self.parent)).await) })
155 }
156}
157
158impl AgentSession {
159 pub async fn execute_nested_tool(
161 &self,
162 parent: &str,
163 name: &str,
164 args: JsonObject,
165 cancel: CancellationToken,
166 ) -> ToolCallOutcome {
167 let (call, index) = {
168 let mut state = lock(&self.inner.nested);
169 let root = state
170 .roots
171 .get(parent)
172 .cloned()
173 .unwrap_or_else(|| parent.to_owned());
174 let recorder = state.recorders.entry(root.clone()).or_default();
175 let call = ToolCall {
176 id: format!("{parent}/{}", recorder.next_id),
177 name: name.to_owned(),
178 arguments: args,
179 ..ToolCall::default()
180 };
181 recorder.next_id += 1;
182 let index = recorder.start(&call);
183 state.roots.insert(call.id.clone(), root);
184 (call, index)
185 };
186 self.emit(AgentSessionEvent::ToolExecutionStart {
187 tool_call_id: call.id.clone(),
188 tool_name: name.to_owned(),
189 args: call.arguments.clone(),
190 parent_tool_call_id: Some(parent.to_owned()),
191 });
192 let outcome = match self.last_assistant_message() {
193 None => ToolCallOutcome {
194 tool_call: call.clone(),
195 result: oino_agent::AgentToolResult::error("No assistant message issued this call"),
196 is_error: true,
197 duration_ms: None,
198 },
199 Some(assistant) => {
200 self.run_nested(call.clone(), parent, &assistant, cancel)
201 .await
202 }
203 };
204 {
205 let mut state = lock(&self.inner.nested);
206 let root = state.roots.remove(&call.id);
207 if let Some(recorder) = root.and_then(|r| state.recorders.get_mut(&r)) {
208 recorder.finish(index, &outcome);
209 }
210 }
211 self.emit(AgentSessionEvent::ToolExecutionEnd {
212 tool_call_id: call.id.clone(),
213 tool_name: name.to_owned(),
214 result: outcome.result.clone(),
215 is_error: outcome.is_error,
216 duration_ms: outcome.duration_ms,
217 parent_tool_call_id: Some(parent.to_owned()),
218 });
219 outcome
220 }
221
222 async fn run_nested(
223 &self,
224 call: ToolCall,
225 parent: &str,
226 assistant: &AssistantMessage,
227 cancel: CancellationToken,
228 ) -> ToolCallOutcome {
229 let active = self.active_tool_names().into_iter().collect();
230 let tools = self.callable_tools(&active);
231 let hooks = std::sync::Arc::new(NestedHooks {
232 session: self.clone(),
233 parent: parent.to_owned(),
234 });
235 let context = AgentContext {
236 messages: self.messages(),
237 tools: self.inner.agent.with_state(|s| s.tools.clone()),
238 };
239 run_tool_call(
240 call,
241 RunToolCallOptions {
242 assistant_message: assistant,
243 context: &context,
244 tools: &tools,
245 before_tool_call: Some(std::sync::Arc::clone(&hooks) as _),
246 after_tool_call: Some(hooks as _),
247 cancel,
248 parent_tool_call_id: Some(parent.to_owned()),
249 on_update: None,
250 },
251 )
252 .await
253 }
254
255 pub(crate) fn attach_nested_record(
258 &self,
259 message: &oino_ai::ToolResultMessage,
260 end: bool,
261 ) -> Option<oino_ai::ToolResultMessage> {
262 let mut state = lock(&self.inner.nested);
263 let record = if end {
264 state.taken.remove(&message.tool_call_id)?
265 } else {
266 let recorder = state.recorders.remove(&message.tool_call_id)?;
267 let record = (recorder.snapshot(), recorder.usage);
268 state
269 .taken
270 .insert(message.tool_call_id.clone(), record.clone());
271 record
272 };
273 let mut message = message.clone();
274 if let Some(calls) = record.0 {
275 message.nested_calls = Some(calls);
276 }
277 if let Some(usage) = record.1 {
278 match &mut message.usage {
279 Some(total) => total.accumulate(&usage),
280 None => message.usage = Some(usage),
281 }
282 }
283 Some(message)
284 }
285
286 pub(crate) fn clear_nested(&self) {
288 let mut state = lock(&self.inner.nested);
289 state.recorders.clear();
290 state.roots.clear();
291 state.taken.clear();
292 }
293}