sim_lib_openai_server/runtime/
tool_loop.rs1use std::collections::BTreeSet;
2
3use serde_json::{Map, Value};
4use sim_kernel::{Args, Cx, Error, Expr, Result, Symbol};
5use sim_lib_agent_runner_core::ModelResponse;
6
7use crate::{
8 capabilities::openai_gateway_tools_capability,
9 plan::{PlanEvalEvent, PlanEvalReport, eval_plan_report_with_cache},
10 runtime::OpenAiPlanCache,
11 translate::tools::{OpenAiToolCall, OpenAiToolRegistry, OpenAiToolResult},
12};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct ToolLoopConfig {
17 pub max_tool_rounds: usize,
19 pub max_tool_calls_per_round: usize,
21}
22
23impl Default for ToolLoopConfig {
24 fn default() -> Self {
25 Self {
26 max_tool_rounds: 4,
27 max_tool_calls_per_round: 8,
28 }
29 }
30}
31
32pub fn run_tool_loop_with_cache(
37 cx: &mut Cx,
38 plan: &Expr,
39 request: &Expr,
40 request_object: &Map<String, Value>,
41 cache: &mut OpenAiPlanCache,
42 initial: PlanEvalReport,
43) -> Result<PlanEvalReport> {
44 let registry = OpenAiToolRegistry::from_request(request_object)?;
45 if registry.is_empty() {
46 return Ok(initial);
47 }
48 run_tool_loop_with_registry(
49 cx,
50 plan,
51 request,
52 cache,
53 ®istry,
54 initial,
55 ToolLoopConfig::default(),
56 )
57}
58
59pub fn run_tool_loop_with_registry(
66 cx: &mut Cx,
67 plan: &Expr,
68 request: &Expr,
69 cache: &mut OpenAiPlanCache,
70 registry: &OpenAiToolRegistry,
71 initial: PlanEvalReport,
72 config: ToolLoopConfig,
73) -> Result<PlanEvalReport> {
74 let mut events = initial.events;
75 let mut current_request = request.clone();
76 let mut response = ModelResponse::try_from(initial.response)?;
77 let mut seen_calls = BTreeSet::new();
78
79 for _round in 0..config.max_tool_rounds {
80 let calls = tool_calls(&response)?;
81 if calls.is_empty() {
82 return Ok(PlanEvalReport {
83 response: Expr::from(response),
84 events,
85 });
86 }
87 if calls.len() > config.max_tool_calls_per_round {
88 return Err(Error::Eval(format!(
89 "tool round requested {} calls, maximum is {}",
90 calls.len(),
91 config.max_tool_calls_per_round
92 )));
93 }
94
95 for call in calls {
96 let fingerprint = call.fingerprint();
97 if !seen_calls.insert(fingerprint) {
98 return Err(Error::Eval(format!(
99 "repeated identical tool call rejected: {}",
100 call.name
101 )));
102 }
103 events.push(PlanEvalEvent {
104 kind: Symbol::new("tool-call"),
105 payload: call.to_expr(),
106 });
107 let result = execute_tool_call(cx, registry, &call)?;
108 events.push(PlanEvalEvent {
109 kind: Symbol::new("tool-result"),
110 payload: result.to_expr(),
111 });
112 append_tool_result(&mut current_request, &result);
113 }
114
115 let next = eval_plan_report_with_cache(cx, plan, ¤t_request, cache)?;
116 events.extend(next.events);
117 response = ModelResponse::try_from(next.response)?;
118 }
119
120 Err(Error::Eval(format!(
121 "tool loop exceeded maximum rounds {}",
122 config.max_tool_rounds
123 )))
124}
125
126fn execute_tool_call(
127 cx: &mut Cx,
128 registry: &OpenAiToolRegistry,
129 call: &OpenAiToolCall,
130) -> Result<OpenAiToolResult> {
131 let Some(tool) = registry.get(&call.name) else {
132 return Ok(OpenAiToolResult::unknown_tool(call));
133 };
134 if let Err(err) = cx.require(&openai_gateway_tools_capability()) {
135 return Ok(OpenAiToolResult::capability_denied(call, err.to_string()));
136 }
137 if let Err(err) = cx.require_all(tool.capabilities()) {
138 return Ok(OpenAiToolResult::capability_denied(call, err.to_string()));
139 }
140 let args = match tool.argument_values(cx, &call.arguments) {
141 Ok(values) => values,
142 Err(message) => return Ok(OpenAiToolResult::invalid_arguments(call, message)),
143 };
144 if let Some(message) = validate_callable_args_shape(cx, tool.symbol(), &args)? {
145 return Ok(OpenAiToolResult::invalid_arguments(call, message));
146 }
147 match cx.call_function(tool.symbol(), Args::new(args)) {
148 Ok(value) => value
149 .object()
150 .as_expr(cx)
151 .map(|expr| OpenAiToolResult::success(call, expr)),
152 Err(Error::CapabilityDenied { capability }) => Ok(OpenAiToolResult::capability_denied(
153 call,
154 format!("capability denied: {capability}"),
155 )),
156 Err(err) => Err(err),
157 }
158}
159
160fn validate_callable_args_shape(
161 cx: &mut Cx,
162 symbol: &Symbol,
163 args: &[sim_kernel::Value],
164) -> Result<Option<String>> {
165 let function = cx.resolve_function(symbol)?;
166 let Some(callable) = function.object().as_callable() else {
167 return Err(Error::TypeMismatch {
168 expected: "callable",
169 found: "non-callable",
170 });
171 };
172 let Some(shape) = callable.browse_args_shape(cx)? else {
173 return Ok(None);
174 };
175 let Some(shape_impl) = shape.object().as_shape() else {
176 return Err(Error::TypeMismatch {
177 expected: "shape",
178 found: "non-shape",
179 });
180 };
181 let arg_list = cx.factory().list(args.to_vec())?;
182 let matched = shape_impl.check_value(cx, arg_list)?;
183 Ok((!matched.accepted).then(|| shape_error_message(&matched.diagnostics)))
184}
185
186fn shape_error_message(diagnostics: &[sim_kernel::Diagnostic]) -> String {
187 diagnostics
188 .first()
189 .map(|diagnostic| diagnostic.message.clone())
190 .unwrap_or_else(|| "callable args shape rejected tool arguments".to_owned())
191}
192
193fn tool_calls(response: &ModelResponse) -> Result<Vec<OpenAiToolCall>> {
194 response
195 .content
196 .iter()
197 .filter_map(|part| OpenAiToolCall::from_content_part(part).transpose())
198 .collect()
199}
200
201fn append_tool_result(request: &mut Expr, result: &OpenAiToolResult) {
202 let Expr::Map(entries) = request else {
203 return;
204 };
205 let message = tool_result_message(result);
206 if let Some((_, Expr::List(messages))) = entries.iter_mut().find(|(key, _)| {
207 matches!(
208 key,
209 Expr::Symbol(symbol)
210 if symbol.namespace.is_none() && symbol.name.as_ref() == "messages"
211 )
212 }) {
213 messages.push(message);
214 } else {
215 entries.push((
216 Expr::Symbol(Symbol::new("messages")),
217 Expr::List(vec![message]),
218 ));
219 }
220}
221
222fn tool_result_message(result: &OpenAiToolResult) -> Expr {
223 Expr::Map(vec![
224 field("role", Expr::Symbol(Symbol::new("tool"))),
225 field("tool-call-id", Expr::String(result.call_id.clone())),
226 field(
227 "content",
228 Expr::List(vec![Expr::Map(vec![
229 field("type", Expr::Symbol(Symbol::new("text"))),
230 field("text", Expr::String(result.message_text())),
231 ])]),
232 ),
233 ])
234}
235
236use sim_value::build::entry as field;