Skip to main content

sim_lib_openai_server/runtime/
tool_loop.rs

1use 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/// Bounds on the multi-round tool-call loop.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct ToolLoopConfig {
17    /// Maximum number of tool rounds before the loop fails with an error.
18    pub max_tool_rounds: usize,
19    /// Maximum number of tool calls accepted within a single round.
20    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
32/// Runs the tool loop using the tool registry parsed from `request_object`.
33///
34/// Returns `initial` unchanged when the request declares no tools; otherwise
35/// drives [`run_tool_loop_with_registry`] with the default [`ToolLoopConfig`].
36pub 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        &registry,
54        initial,
55        ToolLoopConfig::default(),
56    )
57}
58
59/// Drives the model/tool loop until the model stops requesting tool calls.
60///
61/// Each round executes the model's requested tool calls against `registry`,
62/// appends their results to the request, and re-evaluates `plan`. The loop
63/// rejects duplicate identical calls, enforces the per-round call limit, and
64/// fails once `config.max_tool_rounds` is exceeded.
65pub 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, &current_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;