1use sim_codec_bridge::{
2 BridgeCallArgument, BridgeCallPayload, BridgePacket, CallArgumentMedia, content_id_string,
3 packet_content_id, stamp_packet_cid,
4};
5use sim_kernel::{CapabilityName, Cx, Datum, DatumStore, Error, EvalFabric, Expr, Result, Symbol};
6use sim_lib_agent_runner_core::InjectionFence;
7use sim_lib_bridge::{RepairPolicy, effective_caps, run_ask_with_policy};
8
9use crate::{CompiledIntent, VerifyCatalog, packet_artifact::resolve_packet};
10
11pub struct RouteTarget<'a> {
13 pub id: String,
15 pub fabric: &'a dyn EvalFabric,
17 pub required_capabilities: Vec<Symbol>,
20 pub card: Option<String>,
22}
23
24impl<'a> RouteTarget<'a> {
25 pub fn new(id: impl Into<String>, fabric: &'a dyn EvalFabric) -> Self {
27 Self {
28 id: id.into(),
29 fabric,
30 required_capabilities: Vec::new(),
31 card: None,
32 }
33 }
34
35 pub fn requiring(mut self, required_capabilities: Vec<Symbol>) -> Self {
37 self.required_capabilities = required_capabilities;
38 self
39 }
40
41 pub fn with_card(mut self, card: impl Into<String>) -> Self {
43 self.card = Some(card.into());
44 self
45 }
46}
47
48pub struct RoutePolicy<'a> {
50 pub ladder: Vec<RouteTarget<'a>>,
52 pub escalate_after: u8,
55 pub repair_retries: u8,
58 pub verify_catalog: VerifyCatalog,
60}
61
62impl<'a> RoutePolicy<'a> {
63 pub fn new(ladder: Vec<RouteTarget<'a>>, escalate_after: u8) -> Self {
65 Self {
66 ladder,
67 escalate_after: escalate_after.max(1),
68 repair_retries: RepairPolicy::default().max_retries,
69 verify_catalog: VerifyCatalog::new(),
70 }
71 }
72
73 pub fn with_repair_retries(mut self, repair_retries: u8) -> Self {
75 self.repair_retries = repair_retries.min(2);
76 self
77 }
78
79 pub fn with_verify_catalog(mut self, verify_catalog: VerifyCatalog) -> Self {
81 self.verify_catalog = verify_catalog;
82 self
83 }
84
85 fn repair_policy(&self) -> RepairPolicy {
86 RepairPolicy::new(self.repair_retries)
87 }
88}
89
90#[derive(Clone, Debug, PartialEq, Eq)]
92pub enum RouteAttemptStatus {
93 Skipped,
95 Failed,
97 Accepted,
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct RouteAttempt {
104 pub target: String,
106 pub status: RouteAttemptStatus,
108 pub reason: Option<String>,
110}
111
112#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct RouteProvenance {
115 pub target: String,
117 pub card: Option<String>,
119 pub answer_packet: String,
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
125pub struct RoutedAnswer {
126 pub answer: Expr,
128 pub provenance: RouteProvenance,
130 pub attempts: Vec<RouteAttempt>,
132}
133
134pub fn run_intent_routed(
137 cx: &mut Cx,
138 intent: &CompiledIntent,
139 args: &Expr,
140 policy: &RoutePolicy<'_>,
141) -> Result<Expr> {
142 Ok(run_intent_routed_report(cx, intent, args, policy)?.answer)
143}
144
145pub fn run_intent_routed_report(
148 cx: &mut Cx,
149 intent: &CompiledIntent,
150 args: &Expr,
151 policy: &RoutePolicy<'_>,
152) -> Result<RoutedAnswer> {
153 if policy.ladder.is_empty() {
154 return Err(Error::Eval("route policy ladder is empty".to_owned()));
155 }
156
157 let packet = resolve_packet(cx, &intent.packet)?;
158 let executable = bind_args(cx, &packet, args)?;
159 let mut attempts = Vec::new();
160
161 for target in &policy.ladder {
162 if let Some(reason) = skip_reason(cx, &executable, target)? {
163 attempts.push(RouteAttempt {
164 target: target.id.clone(),
165 status: RouteAttemptStatus::Skipped,
166 reason: Some(reason),
167 });
168 continue;
169 }
170
171 let mut failures = 0u8;
172 while failures < policy.escalate_after {
173 match run_target_once(cx, intent, &executable, target, policy) {
174 Ok((answer, answer_packet)) => {
175 let packet_cid = answer_packet.header.cid.clone().unwrap_or_else(|| {
176 packet_content_id(&answer_packet)
177 .map(|id| content_id_string(&id))
178 .unwrap_or_else(|_| "unhashable".to_owned())
179 });
180 attempts.push(RouteAttempt {
181 target: target.id.clone(),
182 status: RouteAttemptStatus::Accepted,
183 reason: None,
184 });
185 return Ok(RoutedAnswer {
186 answer,
187 provenance: RouteProvenance {
188 target: target.id.clone(),
189 card: target
190 .card
191 .clone()
192 .or_else(|| answer_packet.header.provenance.card.clone()),
193 answer_packet: packet_cid,
194 },
195 attempts,
196 });
197 }
198 Err(reason) => {
199 failures = failures.saturating_add(1);
200 attempts.push(RouteAttempt {
201 target: target.id.clone(),
202 status: RouteAttemptStatus::Failed,
203 reason: Some(reason),
204 });
205 }
206 }
207 }
208 }
209
210 Err(Error::Eval(format!(
211 "routed intent {} exhausted {} target(s): {}",
212 intent.name,
213 policy.ladder.len(),
214 attempt_summary(&attempts)
215 )))
216}
217
218fn run_target_once(
219 cx: &mut Cx,
220 intent: &CompiledIntent,
221 packet: &BridgePacket,
222 target: &RouteTarget<'_>,
223 policy: &RoutePolicy<'_>,
224) -> std::result::Result<(Expr, BridgePacket), String> {
225 let answer_packet =
226 run_ask_with_policy(cx, target.fabric, packet.clone(), policy.repair_policy())
227 .map_err(|err| format!("structural check failed: {err}"))?;
228 let answer = answer_from_packet(&answer_packet).map_err(|err| err.to_string())?;
229 let verify_report = policy
230 .verify_catalog
231 .verify_answer(cx, intent, &answer)
232 .map_err(|err| err.to_string())?;
233 if verify_report.accepted() {
234 Ok((answer, answer_packet))
235 } else {
236 Err(format!(
237 "semantic verification failed: {:?}",
238 verify_report.failed
239 ))
240 }
241}
242
243fn answer_from_packet(packet: &BridgePacket) -> Result<Expr> {
244 packet
245 .body
246 .iter()
247 .find(|part| part.id == packet.header.output)
248 .map(|part| part.payload.clone())
249 .ok_or_else(|| {
250 Error::Eval(format!(
251 "answer packet output part {} is missing",
252 packet.header.output
253 ))
254 })
255}
256
257fn skip_reason(
258 cx: &mut Cx,
259 packet: &BridgePacket,
260 target: &RouteTarget<'_>,
261) -> Result<Option<String>> {
262 let allowed = effective_caps(cx, packet)?;
263 for required in &target.required_capabilities {
264 let capability = capability_name(required);
265 if !allowed.contains(&capability) {
266 return Ok(Some(format!(
267 "target requires capability {capability}, outside packet ceiling"
268 )));
269 }
270 }
271 Ok(None)
272}
273
274fn bind_args(cx: &mut Cx, packet: &BridgePacket, args: &Expr) -> Result<BridgePacket> {
275 let mut bound = packet.canonicalized();
276 for part in &mut bound.body {
277 if part.kind != Symbol::qualified("bridge", "Call") {
278 continue;
279 }
280 let payload = BridgeCallPayload::from_expr(&part.payload)?.with_arg(fenced_arg(cx, args)?);
281 part.payload = payload.to_expr();
282 return stamp_packet_cid(&bound);
283 }
284 Err(Error::Eval(
285 "routed intent packet requires a bridge/Call part".to_owned(),
286 ))
287}
288
289fn fenced_arg(cx: &mut Cx, expr: &Expr) -> Result<BridgeCallArgument> {
290 let text = sim_codec_json::expr_to_json(expr).to_string();
291 let datum = Datum::String(text.clone());
292 let content_id = cx.datum_store_mut().intern(datum)?;
293 let fence = InjectionFence::for_content(&content_id);
294 Ok(BridgeCallArgument::new(
295 Symbol::qualified("forge", "args"),
296 Symbol::qualified("codec", "json"),
297 CallArgumentMedia::Text,
298 content_id_string(&content_id),
299 fence.wrap("forge-args", &text),
300 ))
301}
302
303fn capability_name(symbol: &Symbol) -> CapabilityName {
304 match symbol.namespace.as_deref() {
305 Some("capability") => CapabilityName::new(symbol.name.to_string()),
306 _ => CapabilityName::new(symbol.as_qualified_str()),
307 }
308}
309
310fn attempt_summary(attempts: &[RouteAttempt]) -> String {
311 if attempts.is_empty() {
312 return "no attempts".to_owned();
313 }
314 attempts
315 .iter()
316 .map(|attempt| {
317 let status = match attempt.status {
318 RouteAttemptStatus::Skipped => "skipped",
319 RouteAttemptStatus::Failed => "failed",
320 RouteAttemptStatus::Accepted => "accepted",
321 };
322 match &attempt.reason {
323 Some(reason) => format!("{} {status}: {reason}", attempt.target),
324 None => format!("{} {status}", attempt.target),
325 }
326 })
327 .collect::<Vec<_>>()
328 .join("; ")
329}