1use std::mem;
2
3use sim_kernel::{Cx, Error, Expr, Result, Symbol};
4use sim_lib_agent_runner_core::{ModelRequest, ModelResponse};
5
6use crate::{
7 capabilities::{openai_gateway_plan_capability, openai_gateway_plan_remote_capability},
8 plan::{
9 address::resolve_atom_address,
10 eval_context::{EvalContext, PlanPrivacy},
11 eval_helpers::{
12 branch_id, child_args, field, is_slow_fixture, keyword_atom, keyword_value,
13 verifier_accepts,
14 },
15 fixtures::{
16 fixture_echo_response, fixture_static_response, fixture_tool_call_response,
17 request_text, response_summary, response_text,
18 },
19 shape::{check_plan, plan_parts},
20 },
21 runtime::{
22 OpenAiFederation, OpenAiPlanCache, OpenAiRunnerRegistry, PlanCacheKey, PlanCacheMode,
23 PlanCacheWriteTarget,
24 },
25};
26
27#[derive(Clone, Debug, PartialEq)]
30pub struct PlanEvalReport {
31 pub response: Expr,
33 pub events: Vec<PlanEvalEvent>,
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct PlanEvalEvent {
40 pub kind: Symbol,
42 pub payload: Expr,
44}
45
46pub fn eval_plan(cx: &mut Cx, plan: &Expr, request: &Expr) -> Result<Expr> {
48 eval_plan_report(cx, plan, request).map(|report| report.response)
49}
50
51pub fn eval_plan_report(cx: &mut Cx, plan: &Expr, request: &Expr) -> Result<PlanEvalReport> {
53 eval_plan_report_inner(cx, plan, request, None, None, None)
54}
55
56pub fn eval_plan_report_with_cache(
58 cx: &mut Cx,
59 plan: &Expr,
60 request: &Expr,
61 cache: &mut OpenAiPlanCache,
62) -> Result<PlanEvalReport> {
63 eval_plan_report_inner(cx, plan, request, Some(cache), None, None)
64}
65
66pub fn eval_plan_report_with_cache_and_runners(
68 cx: &mut Cx,
69 plan: &Expr,
70 request: &Expr,
71 cache: &mut OpenAiPlanCache,
72 runners: &OpenAiRunnerRegistry,
73) -> Result<PlanEvalReport> {
74 eval_plan_report_inner(cx, plan, request, Some(cache), Some(runners), None)
75}
76
77pub fn eval_plan_report_with_federation(
79 cx: &mut Cx,
80 plan: &Expr,
81 request: &Expr,
82 federation: &OpenAiFederation,
83) -> Result<PlanEvalReport> {
84 eval_plan_report_inner(cx, plan, request, None, None, Some(federation))
85}
86
87pub fn eval_plan_report_with_cache_runners_and_federation(
90 cx: &mut Cx,
91 plan: &Expr,
92 request: &Expr,
93 cache: &mut OpenAiPlanCache,
94 runners: &OpenAiRunnerRegistry,
95 federation: &OpenAiFederation,
96) -> Result<PlanEvalReport> {
97 eval_plan_report_inner(
98 cx,
99 plan,
100 request,
101 Some(cache),
102 Some(runners),
103 Some(federation),
104 )
105}
106
107fn eval_plan_report_inner(
108 cx: &mut Cx,
109 plan: &Expr,
110 request: &Expr,
111 cache: Option<&mut OpenAiPlanCache>,
112 runners: Option<&OpenAiRunnerRegistry>,
113 federation: Option<&OpenAiFederation>,
114) -> Result<PlanEvalReport> {
115 check_plan(plan)?;
116 let (name, _) = plan_parts(plan)?;
117 if name != "atom" {
118 cx.require(&openai_gateway_plan_capability())?;
119 }
120 let request_model = ModelRequest::try_from(request.clone())?;
121 let context = EvalContext::from_request(request);
122 let mut evaluator = PlanEvaluator {
123 cx,
124 request: request_model,
125 cache,
126 runners,
127 federation,
128 events: Vec::new(),
129 next_branch: 0,
130 };
131 let response = evaluator.eval(plan, context)?;
132 Ok(PlanEvalReport {
133 response: Expr::from(response),
134 events: evaluator.events,
135 })
136}
137
138struct PlanEvaluator<'a> {
139 cx: &'a mut Cx,
140 request: ModelRequest,
141 cache: Option<&'a mut OpenAiPlanCache>,
142 runners: Option<&'a OpenAiRunnerRegistry>,
143 federation: Option<&'a OpenAiFederation>,
144 events: Vec<PlanEvalEvent>,
145 next_branch: u64,
146}
147
148impl PlanEvaluator<'_> {
149 fn eval(&mut self, plan: &Expr, context: EvalContext) -> Result<ModelResponse> {
150 let (name, args) = plan_parts(plan)?;
151 if name == "atom" {
152 let [Expr::String(address)] = args else {
153 return Err(Error::Eval("plan/atom expects one address".to_owned()));
154 };
155 return self.eval_atom(address, context);
156 }
157 let children = child_args(args);
158 match name {
159 "race" => self.eval_race(&children, context),
160 "fallback" => self.eval_fallback(&children, context),
161 "chain" => self.eval_chain(&children, context),
162 "budget" => self.eval_wrapped(name, children[0], context.budgeted(args)),
163 "market" => self.eval_wrapped(name, children[0], context),
164 "local" => self.eval_wrapped(name, children[0], context.local()),
165 "remote" => self.eval_remote(children[0], context),
166 "trace" => self.eval_wrapped(name, children[0], context.trace()),
167 "verify" => self.eval_verify(args, &children, context),
168 "debate" => self.eval_debate(args, &children, context),
169 "cache" => self.eval_cache(args, children[0], context),
170 _ => Err(Error::Eval(format!("unknown plan combinator {name}"))),
171 }
172 }
173
174 fn eval_atom(&mut self, address: &str, context: EvalContext) -> Result<ModelResponse> {
175 let descriptor = resolve_atom_address(address)?;
176 if descriptor.is_gateway() {
177 if context.privacy == PlanPrivacy::LocalOnly {
178 return Err(Error::Eval(format!(
179 "local-only privacy rejects remote model {address}"
180 )));
181 }
182 return self
183 .federation
184 .ok_or_else(|| Error::Eval(format!("model_not_found: {address}")))?
185 .infer(
186 self.cx,
187 address,
188 &self.request,
189 &context.federation_policy(),
190 );
191 }
192 if descriptor.is_runner_backed() {
193 let runners = self
194 .runners
195 .ok_or_else(|| Error::Eval(format!("model_not_found: {address}")))?;
196 let card = runners
197 .card_for(address)
198 .ok_or_else(|| Error::Eval(format!("model_not_found: {address}")))?;
199 if context.privacy == PlanPrivacy::LocalOnly
200 && !locality_is_allowed_for_local_only(&card.locality)
201 {
202 return Err(Error::Eval(format!(
203 "local-only privacy rejects remote model {address}"
204 )));
205 }
206 return runners.infer(self.cx, address, self.request.clone());
207 }
208 match descriptor.address.as_str() {
209 "fixture/echo" | "fixture/slow-echo" => {
210 Ok(fixture_echo_response(address, &self.request))
211 }
212 "fixture/a" => Ok(fixture_static_response(address, "fixture a transcript")),
213 "fixture/b" => Ok(fixture_static_response(address, "fixture b transcript")),
214 "fixture/judge" => Ok(fixture_static_response(
215 address,
216 &format!("judged: {}", request_text(&self.request)),
217 )),
218 "fixture/tool-call" => Ok(fixture_tool_call_response(address, &self.request, false)),
219 "fixture/repeat-tool-call" => {
220 Ok(fixture_tool_call_response(address, &self.request, true))
221 }
222 "fixture/always-ok" => Ok(fixture_static_response(address, "ok")),
223 "fixture/always-fail" => Ok(fixture_static_response(address, "fail")),
224 "fixture/fail" => Err(Error::Eval("fixture/fail failed".to_owned())),
225 _ => Ok(fixture_static_response(
226 address,
227 &request_text(&self.request),
228 )),
229 }
230 }
231
232 fn eval_race(&mut self, children: &[&Expr], context: EvalContext) -> Result<ModelResponse> {
233 let mut winner = None;
234 let mut first_error = None;
235 for child in children {
236 let branch = self.branch_start("race", child);
237 if children.len() > 1 && is_slow_fixture(child) {
238 self.branch_end(
239 branch,
240 "cancelled",
241 Expr::String("cancelled by faster branch".to_owned()),
242 );
243 continue;
244 }
245 match self.eval(child, context.clone()) {
246 Ok(response) if winner.is_none() => {
247 self.branch_end(branch, "winner", response_summary(&response));
248 winner = Some(response);
249 }
250 Ok(response) => {
251 self.branch_end(branch, "cancelled", response_summary(&response));
252 }
253 Err(err) => {
254 self.branch_end(branch, "error", Expr::String(err.to_string()));
255 first_error.get_or_insert(err);
256 }
257 }
258 }
259 winner.ok_or_else(|| {
260 first_error.unwrap_or_else(|| Error::Eval("plan/race had no winning branch".to_owned()))
261 })
262 }
263
264 fn eval_fallback(&mut self, children: &[&Expr], context: EvalContext) -> Result<ModelResponse> {
265 let mut first_error = None;
266 for child in children {
267 let branch = self.branch_start("fallback", child);
268 match self.eval(child, context.clone()) {
269 Ok(response) => {
270 self.branch_end(branch, "accepted", response_summary(&response));
271 return Ok(response);
272 }
273 Err(err) => {
274 self.branch_end(branch, "error", Expr::String(err.to_string()));
275 first_error.get_or_insert(err);
276 }
277 }
278 }
279 Err(first_error.unwrap_or_else(|| Error::Eval("plan/fallback had no children".to_owned())))
280 }
281
282 fn eval_chain(&mut self, children: &[&Expr], context: EvalContext) -> Result<ModelResponse> {
283 let saved = self.request.clone();
284 let mut last = None;
285 for child in children {
286 let response = self.eval_child("chain", child, context.clone())?;
287 self.request = ModelRequest::new(Expr::String(response_text(&response)), Vec::new());
288 last = Some(response);
289 }
290 self.request = saved;
291 last.ok_or_else(|| Error::Eval("plan/chain had no children".to_owned()))
292 }
293
294 fn eval_wrapped(
295 &mut self,
296 combinator: &str,
297 child: &Expr,
298 context: EvalContext,
299 ) -> Result<ModelResponse> {
300 self.eval_child(combinator, child, context)
301 }
302
303 fn eval_remote(&mut self, child: &Expr, context: EvalContext) -> Result<ModelResponse> {
304 if context.privacy == PlanPrivacy::LocalOnly {
305 return Err(Error::Eval(
306 "local-only privacy rejects plan/remote".to_owned(),
307 ));
308 }
309 self.cx.require(&openai_gateway_plan_remote_capability())?;
310 self.eval_child("remote", child, context)
311 }
312
313 fn eval_cache(
314 &mut self,
315 args: &[Expr],
316 child: &Expr,
317 context: EvalContext,
318 ) -> Result<ModelResponse> {
319 let mode = keyword_value(args, "mode")
320 .map(PlanCacheMode::from_expr)
321 .transpose()?
322 .unwrap_or_default();
323 if mode == PlanCacheMode::Disabled {
324 return self.eval_child("cache", child, context);
325 }
326
327 let request_expr = Expr::from(self.request.clone());
328 let key = PlanCacheKey::for_request_plan(&request_expr, child)?;
329 if mode.reads()
330 && let Some(cached) = self
331 .cache
332 .as_ref()
333 .and_then(|cache| cache.get(&key))
334 .cloned()
335 {
336 self.events.push(PlanEvalEvent {
337 kind: Symbol::new("cache-hit"),
338 payload: key.to_expr(),
339 });
340 return ModelResponse::try_from(cached);
341 }
342
343 let response = self.eval_child("cache", child, context)?;
344 if mode.writes()
345 && let Some(cache) = &mut self.cache
346 {
347 cache.put(
348 &mut *self.cx,
349 PlanCacheWriteTarget::Memory,
350 key,
351 Expr::from(response.clone()),
352 )?;
353 }
354 Ok(response)
355 }
356
357 fn eval_verify(
358 &mut self,
359 args: &[Expr],
360 children: &[&Expr],
361 context: EvalContext,
362 ) -> Result<ModelResponse> {
363 let generator = self.eval_child("verify/generator", children[0], context.clone())?;
364 let verifier = self.eval_child("verify/checker", children[1], context.clone())?;
365 if verifier_accepts(&verifier) {
366 return Ok(generator);
367 }
368 match keyword_atom(args, "on-fail").unwrap_or("error") {
369 "accept" => Ok(generator),
370 "error" | "escalate" => Err(Error::Eval(
371 "plan/verify checker rejected output".to_owned(),
372 )),
373 other => Err(Error::Eval(format!(
374 "unsupported plan/verify on-fail behavior {other}"
375 ))),
376 }
377 }
378
379 fn eval_debate(
380 &mut self,
381 args: &[Expr],
382 children: &[&Expr],
383 context: EvalContext,
384 ) -> Result<ModelResponse> {
385 let mut transcripts = Vec::new();
386 for child in children {
387 let response = self.eval_child("debate/side", child, context.clone())?;
388 transcripts.push(response_text(&response));
389 }
390 let judge_plan = keyword_value(args, "judge").cloned().unwrap_or_else(|| {
391 Expr::List(vec![
392 Expr::Symbol(Symbol::new("plan/atom")),
393 Expr::String("fixture/judge".to_owned()),
394 ])
395 });
396 let judge_input = transcripts.join(" | ");
397 self.with_request(
398 ModelRequest::new(Expr::String(judge_input), Vec::new()),
399 |evaluator| evaluator.eval_child("debate/judge", &judge_plan, context),
400 )
401 }
402
403 fn eval_child(
404 &mut self,
405 combinator: &str,
406 child: &Expr,
407 context: EvalContext,
408 ) -> Result<ModelResponse> {
409 let branch = self.branch_start(combinator, child);
410 match self.eval(child, context) {
411 Ok(response) => {
412 self.branch_end(branch, "completed", response_summary(&response));
413 Ok(response)
414 }
415 Err(err) => {
416 self.branch_end(branch, "error", Expr::String(err.to_string()));
417 Err(err)
418 }
419 }
420 }
421
422 fn with_request<T>(
423 &mut self,
424 request: ModelRequest,
425 f: impl FnOnce(&mut Self) -> Result<T>,
426 ) -> Result<T> {
427 let saved = mem::replace(&mut self.request, request);
428 let result = f(self);
429 self.request = saved;
430 result
431 }
432
433 fn branch_start(&mut self, combinator: &str, child: &Expr) -> u64 {
434 let branch = self.next_branch;
435 self.next_branch += 1;
436 self.events.push(PlanEvalEvent {
437 kind: Symbol::new("branch-start"),
438 payload: Expr::Map(vec![
439 field("branch-id", branch_id(branch)),
440 field(
441 "combinator",
442 Expr::Symbol(Symbol::new(combinator.to_owned())),
443 ),
444 field("plan", child.clone()),
445 ]),
446 });
447 branch
448 }
449
450 fn branch_end(&mut self, branch: u64, status: &str, result: Expr) {
451 self.events.push(PlanEvalEvent {
452 kind: Symbol::new("branch-end"),
453 payload: Expr::Map(vec![
454 field("branch-id", branch_id(branch)),
455 field("status", Expr::Symbol(Symbol::new(status.to_owned()))),
456 field("result", result),
457 ]),
458 });
459 }
460}
461
462fn locality_is_allowed_for_local_only(locality: &Symbol) -> bool {
463 matches!(
464 locality.name.as_ref(),
465 "local" | "agent" | "agent-backed" | "fabric" | "in-process" | "process"
466 )
467}