1use anyhow::{Result, anyhow};
15use licoup_conversation::{Membership, MembershipAccess, MembershipProfileSnapshot, ProfileIntent};
16use licoup_workflow::{BindingValue, RunCommand, RuntimeKind};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19use std::cmp::Ordering;
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::Path;
22use std::sync::{Arc, Mutex, OnceLock};
23
24#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct TargetFacts {
28 pub status: Option<String>,
29 pub model: Option<String>,
30 pub environment: Option<String>,
31 pub capabilities: Vec<String>,
32 pub readiness: Option<String>,
33 pub reliability_class: Option<String>,
34 pub latency_class: Option<u8>,
35}
36
37#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct PriceFacts {
43 pub input: f64,
44 pub output: f64,
45}
46
47pub trait ProfileSnapshotAuthority: Send {
51 fn target_facts(&mut self, agent_id: &str) -> Option<TargetFacts>;
52 fn model_price_usd_per_million_tokens(&mut self, model: &str) -> Option<PriceFacts>;
53 fn coding_score(&mut self, agent_id: &str, model: &str) -> Option<i64>;
54 fn skill_names(&mut self, agent_id: &str) -> Vec<String>;
55}
56
57pub type SharedSnapshotAuthority = Arc<Mutex<Box<dyn ProfileSnapshotAuthority>>>;
58
59struct RequestScopedAuthority<'a> {
62 inner: &'a mut dyn ProfileSnapshotAuthority,
63 targets: BTreeMap<String, Option<TargetFacts>>,
64 prices: BTreeMap<String, Option<PriceFacts>>,
65 scores: BTreeMap<(String, String), Option<i64>>,
66 skills: BTreeMap<String, Vec<String>>,
67}
68
69impl<'a> RequestScopedAuthority<'a> {
70 fn new(inner: &'a mut dyn ProfileSnapshotAuthority) -> Self {
71 Self {
72 inner,
73 targets: BTreeMap::new(),
74 prices: BTreeMap::new(),
75 scores: BTreeMap::new(),
76 skills: BTreeMap::new(),
77 }
78 }
79
80 fn target_facts(&mut self, agent_id: &str) -> Option<TargetFacts> {
81 if let Some(cached) = self.targets.get(agent_id) {
82 return cached.clone();
83 }
84 let read = self.inner.target_facts(agent_id);
85 self.targets.insert(agent_id.to_owned(), read.clone());
86 read
87 }
88
89 fn model_price(&mut self, model: &str) -> Option<PriceFacts> {
90 if let Some(cached) = self.prices.get(model) {
91 return *cached;
92 }
93 let read = self.inner.model_price_usd_per_million_tokens(model);
94 self.prices.insert(model.to_owned(), read);
95 read
96 }
97
98 fn score(&mut self, agent_id: &str, model: &str) -> Option<i64> {
99 let key = (agent_id.to_owned(), model.to_owned());
100 if let Some(cached) = self.scores.get(&key) {
101 return *cached;
102 }
103 let read = self.inner.coding_score(agent_id, model);
104 self.scores.insert(key, read);
105 read
106 }
107
108 fn skills(&mut self, agent_id: &str) -> Vec<String> {
109 if let Some(cached) = self.skills.get(agent_id) {
110 return cached.clone();
111 }
112 let read = self.inner.skill_names(agent_id);
113 self.skills.insert(agent_id.to_owned(), read.clone());
114 read
115 }
116}
117
118pub fn project_profile_snapshot(
122 conversation_id: &str,
123 membership: &Membership,
124 intent: &ProfileIntent,
125 is_assistant: bool,
126 authority: &SharedSnapshotAuthority,
127 model_facts: &dyn ModelFactsPort,
128) -> MembershipProfileSnapshot {
129 let mut guard = authority
130 .lock()
131 .unwrap_or_else(|poison| poison.into_inner());
132 let mut scoped = RequestScopedAuthority::new(guard.as_mut());
133 project_with(
134 &mut scoped,
135 conversation_id,
136 membership,
137 intent,
138 is_assistant,
139 model_facts,
140 )
141}
142
143pub fn project_profile_snapshots(
147 conversation_id: &str,
148 members: &[(Membership, ProfileIntent, bool)],
149 authority: &SharedSnapshotAuthority,
150 model_facts: &dyn ModelFactsPort,
151) -> Vec<MembershipProfileSnapshot> {
152 let mut guard = authority
153 .lock()
154 .unwrap_or_else(|poison| poison.into_inner());
155 let mut scoped = RequestScopedAuthority::new(guard.as_mut());
156 members
157 .iter()
158 .map(|(membership, intent, is_assistant)| {
159 project_with(
160 &mut scoped,
161 conversation_id,
162 membership,
163 intent,
164 *is_assistant,
165 model_facts,
166 )
167 })
168 .collect()
169}
170
171#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176pub struct CandidateFilters {
177 #[serde(default)]
178 pub required_authority: Vec<String>,
179 #[serde(default)]
180 pub required_skills: Vec<String>,
181 #[serde(default)]
182 pub required_capabilities: Vec<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub required_model: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub required_environment: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub required_readiness: Option<String>,
189 #[serde(default)]
190 pub membership_ids: Vec<String>,
191 #[serde(default)]
192 pub pinned_membership_ids: Vec<String>,
193 #[serde(default)]
194 pub preferred_skills: Vec<String>,
195 #[serde(default)]
196 pub preferred_capabilities: Vec<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub preferred_model: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub preferred_environment: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub required_task: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub preferred_task: Option<String>,
205}
206
207pub fn rank_candidates(
212 snapshots: Vec<MembershipProfileSnapshot>,
213 filters: &CandidateFilters,
214) -> Result<Vec<MembershipProfileSnapshot>, String> {
215 let required_ids = filters.membership_ids.iter().collect::<BTreeSet<_>>();
216 let available = snapshots
217 .iter()
218 .map(|snapshot| &snapshot.membership_id)
219 .collect::<BTreeSet<_>>();
220 if !required_ids.is_subset(&available) {
221 return Err("profile_candidate_rejected".to_owned());
222 }
223 let mut eligible = snapshots
224 .into_iter()
225 .filter(|snapshot| {
226 (required_ids.is_empty() || required_ids.contains(&snapshot.membership_id))
227 && candidate_eligible(snapshot, filters)
228 })
229 .collect::<Vec<_>>();
230 let remaining = eligible
231 .iter()
232 .map(|snapshot| &snapshot.membership_id)
233 .collect::<BTreeSet<_>>();
234 if !required_ids.is_subset(&remaining) {
235 return Err("profile_candidate_rejected".to_owned());
236 }
237 let pin_order = filters
238 .pinned_membership_ids
239 .iter()
240 .chain(filters.membership_ids.iter())
241 .enumerate()
242 .fold(BTreeMap::new(), |mut result, (ordinal, membership_id)| {
243 result.entry(membership_id.as_str()).or_insert(ordinal);
244 result
245 });
246 eligible.sort_by(|left, right| {
247 pin_order
248 .get(left.membership_id.as_str())
249 .copied()
250 .unwrap_or(usize::MAX)
251 .cmp(
252 &pin_order
253 .get(right.membership_id.as_str())
254 .copied()
255 .unwrap_or(usize::MAX),
256 )
257 .then_with(|| preference_misses(left, filters).cmp(&preference_misses(right, filters)))
258 .then_with(|| reliability_rank(left).cmp(&reliability_rank(right)))
259 .then_with(|| optional_desc(left.intelligence_score, right.intelligence_score))
260 .then_with(|| optional_price(left).cmp(&optional_price(right)))
261 .then_with(|| optional_asc(left.latency_class, right.latency_class))
262 .then_with(|| left.membership_id.cmp(&right.membership_id))
263 });
264 Ok(eligible)
265}
266
267fn candidate_eligible(snapshot: &MembershipProfileSnapshot, filters: &CandidateFilters) -> bool {
268 snapshot
269 .required_capabilities
270 .iter()
271 .all(|required| snapshot.capabilities.iter().any(|value| value == required))
272 && snapshot
273 .skill_references
274 .iter()
275 .all(|required| snapshot.skills.iter().any(|value| value == required))
276 && filters
277 .required_authority
278 .iter()
279 .all(|required| snapshot.authority.iter().any(|value| value == required))
280 && filters
281 .required_skills
282 .iter()
283 .all(|required| snapshot.skills.iter().any(|value| value == required))
284 && filters
285 .required_capabilities
286 .iter()
287 .all(|required| snapshot.capabilities.iter().any(|value| value == required))
288 && filters
289 .required_model
290 .as_deref()
291 .map(|required| snapshot.model.as_deref() == Some(required))
292 .unwrap_or(true)
293 && filters
294 .required_environment
295 .as_deref()
296 .map(|required| snapshot.environment.as_deref() == Some(required))
297 .unwrap_or(true)
298 && filters
299 .required_readiness
300 .as_deref()
301 .map(|required| snapshot.readiness.as_deref() == Some(required))
302 .unwrap_or(true)
303 && filters
304 .required_task
305 .as_deref()
306 .map(|required| snapshot.task_tags.iter().any(|tag| tag == required))
307 .unwrap_or(true)
308}
309
310fn preference_misses(snapshot: &MembershipProfileSnapshot, filters: &CandidateFilters) -> usize {
311 let model = filters
312 .preferred_model
313 .as_deref()
314 .or(snapshot.preferred_model.as_deref());
315 let environment = filters
316 .preferred_environment
317 .as_deref()
318 .or(snapshot.preferred_environment.as_deref());
319 usize::from(model.is_some_and(|value| snapshot.model.as_deref() != Some(value)))
320 + usize::from(
321 environment.is_some_and(|value| snapshot.environment.as_deref() != Some(value)),
322 )
323 + filters
324 .preferred_skills
325 .iter()
326 .filter(|value| !snapshot.skills.contains(value))
327 .count()
328 + filters
329 .preferred_capabilities
330 .iter()
331 .chain(snapshot.preferred_capabilities.iter())
332 .filter(|value| !snapshot.capabilities.contains(value))
333 .count()
334 + usize::from(
335 filters
336 .preferred_task
337 .as_deref()
338 .is_some_and(|task| !snapshot.task_tags.iter().any(|tag| tag == task)),
339 )
340}
341
342fn reliability_rank(snapshot: &MembershipProfileSnapshot) -> (bool, u8) {
343 let Some(value) = snapshot.reliability_class.as_deref() else {
344 return (true, u8::MAX);
345 };
346 let rank = match value {
347 "verified" | "high" | "ready" => 0,
348 "standard" | "partial" => 1,
349 "low" | "unverified" => 2,
350 _ => 3,
351 };
352 (false, rank)
353}
354
355fn optional_desc(left: Option<i64>, right: Option<i64>) -> Ordering {
356 match (left, right) {
357 (Some(left), Some(right)) => right.cmp(&left),
358 (Some(_), None) => Ordering::Less,
359 (None, Some(_)) => Ordering::Greater,
360 (None, None) => Ordering::Equal,
361 }
362}
363
364fn optional_asc<T: Ord>(left: Option<T>, right: Option<T>) -> Ordering {
365 match (left, right) {
366 (Some(left), Some(right)) => left.cmp(&right),
367 (Some(_), None) => Ordering::Less,
368 (None, Some(_)) => Ordering::Greater,
369 (None, None) => Ordering::Equal,
370 }
371}
372
373fn optional_price(snapshot: &MembershipProfileSnapshot) -> (bool, u64) {
374 let Some(input) = snapshot.price_input_usd_per_million_tokens else {
375 return (true, u64::MAX);
376 };
377 let Some(output) = snapshot.price_output_usd_per_million_tokens else {
378 return (true, u64::MAX);
379 };
380 if !input.is_finite() || !output.is_finite() || input < 0.0 || output < 0.0 {
381 return (true, u64::MAX);
382 }
383 (false, ((input + output) * 1_000_000.0).round() as u64)
384}
385
386fn project_with(
387 authority: &mut RequestScopedAuthority<'_>,
388 conversation_id: &str,
389 membership: &Membership,
390 intent: &ProfileIntent,
391 is_assistant: bool,
392 model_facts: &dyn ModelFactsPort,
393) -> MembershipProfileSnapshot {
394 let agent_id = membership
395 .principal
396 .agent_id
397 .clone()
398 .unwrap_or_else(|| membership.principal.id.clone());
399 let target = authority.target_facts(&agent_id);
400 let model = target.as_ref().and_then(|facts| facts.model.clone());
401 let price = model
402 .as_deref()
403 .and_then(|model| authority.model_price(model));
404 let score = model
405 .as_deref()
406 .and_then(|model| authority.score(&agent_id, model));
407 let mut capabilities = target
408 .as_ref()
409 .map(|facts| facts.capabilities.clone())
410 .unwrap_or_default();
411 capabilities.sort();
412 capabilities.dedup();
413 let mut skills = authority.skills(&agent_id);
414 if is_assistant
415 && let Some(bundled) = model_facts.bundled_guide_skill_id()
416 && intent.skill_references.iter().any(|skill| skill == bundled)
417 && !skills.iter().any(|skill| skill == bundled)
418 {
419 skills.push(bundled.to_owned());
420 }
421 skills.sort();
422 skills.dedup();
423 let authority = match membership.access {
424 MembershipAccess::Owner => vec![
425 "conversation.act".to_owned(),
426 "conversation.manage".to_owned(),
427 "conversation.read".to_owned(),
428 ],
429 MembershipAccess::Member => vec![
430 "conversation.act".to_owned(),
431 "conversation.read".to_owned(),
432 ],
433 };
434 let task_tags = model
435 .as_deref()
436 .map(|model| model_facts.task_tags_for_model(model))
437 .unwrap_or_default();
438 MembershipProfileSnapshot {
439 conversation_id: conversation_id.to_owned(),
440 membership_id: membership.id.clone(),
441 agent_id,
442 intent_revision: intent.revision,
443 responsibility: intent.responsibility,
444 required_capabilities: intent.required_capabilities.clone(),
445 preferred_capabilities: intent.preferred_capabilities.clone(),
446 skill_references: intent.skill_references.clone(),
447 preferred_model: intent.preferred_model.clone(),
448 preferred_reasoning_effort: intent.preferred_reasoning_effort.clone(),
449 preferred_environment: intent.preferred_environment.clone(),
450 preferred_permission_mode: intent.preferred_permission_mode.clone(),
451 model,
452 capabilities,
453 skills,
454 environment: target.as_ref().and_then(|facts| facts.environment.clone()),
455 readiness: target.as_ref().and_then(|facts| facts.readiness.clone()),
456 price_input_usd_per_million_tokens: price.map(|price| price.input),
457 price_output_usd_per_million_tokens: price.map(|price| price.output),
458 intelligence_score: score,
459 task_tags,
460 reliability_class: target
461 .as_ref()
462 .and_then(|facts| facts.reliability_class.clone()),
463 latency_class: target.as_ref().and_then(|facts| facts.latency_class),
464 authority,
465 }
466}
467
468pub trait ModelFactsPort: Send + Sync {
476 fn model_display_name(&self, model: &str) -> String;
478 fn project_allowlisted_model(&self, model: &str) -> Option<Value>;
481 fn task_tags_for_model(&self, model: &str) -> Vec<String>;
483 fn bundled_guide_skill_id(&self) -> Option<&'static str>;
487}
488
489pub use acativa::runtime::ports::{
493 ActorTurnError, EffectPort, ResolvedRuntime, StrategyEffectPermit, UsageLedgerPort,
494};
495
496pub fn route_receipt(
507 conversation_id: &str,
508 snapshots: &[MembershipProfileSnapshot],
509 model_facts: &dyn ModelFactsPort,
510 selection_policy_revision: &str,
511) -> Value {
512 json!({
513 "conversationId": conversation_id,
514 "sourceRevisions": [
515 {"source": "selectionPolicy", "revision": selection_policy_revision},
516 {"source": "targets", "revision": "read-only-v1"},
517 {"source": "nativeCapabilities", "revision": "v0.0.1"},
518 {"source": "providerModelPricing", "revision": "catalog-v1"},
519 {"source": "agentIntelligenceCatalog", "revision": "catalog-v1"},
520 {"source": "skillHub", "revision": "request-snapshot-v1"},
521 {"source": "assistantWorkflowAuthoringBundle", "revision": "v1"},
522 ],
523 "rankedMembershipIds": snapshots
524 .iter()
525 .map(|snapshot| snapshot.membership_id.clone())
526 .collect::<Vec<_>>(),
527 "candidates": snapshots.iter().map(|snapshot| json!({
528 "membershipId": snapshot.membership_id,
529 "profileRevision": snapshot.intent_revision,
530 "responsibility": snapshot.responsibility,
531 "model": snapshot.model,
532 "capabilities": snapshot.capabilities,
533 "skills": snapshot.skills,
534 "environment": snapshot.environment,
535 "readiness": snapshot.readiness,
536 "inputPriceUsdPerMillionTokens": snapshot.price_input_usd_per_million_tokens,
537 "outputPriceUsdPerMillionTokens": snapshot.price_output_usd_per_million_tokens,
538 "codingScore": snapshot.intelligence_score,
539 "taskTags": snapshot.task_tags,
540 "intelligence": snapshot.model.as_deref().and_then(|model| model_facts.project_allowlisted_model(model)),
541 "reliabilityClass": snapshot.reliability_class,
542 "latencyClass": snapshot.latency_class,
543 "authority": snapshot.authority,
544 })).collect::<Vec<_>>(),
545 })
546}
547
548#[derive(Clone)]
556pub struct HostPorts {
557 pub profile: SharedSnapshotAuthority,
559 pub model: Arc<dyn ModelFactsPort>,
561 pub usage: Arc<dyn UsageLedgerPort>,
563 pub effect: Arc<dyn EffectPort>,
565}
566
567impl HostPorts {
568 pub fn unavailable() -> Self {
572 Self {
573 profile: Arc::new(Mutex::new(Box::new(UnavailableProfileAuthority))),
574 model: Arc::new(UnavailableModelFacts),
575 usage: Arc::new(UnavailableUsageLedger),
576 effect: Arc::new(UnavailableEffect),
577 }
578 }
579}
580
581impl std::fmt::Debug for HostPorts {
582 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583 formatter.debug_struct("HostPorts").finish_non_exhaustive()
584 }
585}
586
587static HOST_PORTS: OnceLock<HostPorts> = OnceLock::new();
588
589pub fn install_host_ports(ports: HostPorts) {
593 let _ = HOST_PORTS.set(ports);
594}
595
596pub fn host_ports() -> &'static HostPorts {
598 HOST_PORTS.get_or_init(HostPorts::unavailable)
599}
600
601struct UnavailableProfileAuthority;
602
603impl ProfileSnapshotAuthority for UnavailableProfileAuthority {
604 fn target_facts(&mut self, _agent_id: &str) -> Option<TargetFacts> {
605 None
606 }
607
608 fn model_price_usd_per_million_tokens(&mut self, _model: &str) -> Option<PriceFacts> {
609 None
610 }
611
612 fn coding_score(&mut self, _agent_id: &str, _model: &str) -> Option<i64> {
613 None
614 }
615
616 fn skill_names(&mut self, _agent_id: &str) -> Vec<String> {
617 Vec::new()
618 }
619}
620
621struct UnavailableModelFacts;
622
623impl ModelFactsPort for UnavailableModelFacts {
624 fn model_display_name(&self, model: &str) -> String {
625 licoup_foundation::core::model_naming::model_display_name(model)
629 }
630
631 fn project_allowlisted_model(&self, _model: &str) -> Option<Value> {
632 None
633 }
634
635 fn task_tags_for_model(&self, _model: &str) -> Vec<String> {
636 Vec::new()
637 }
638
639 fn bundled_guide_skill_id(&self) -> Option<&'static str> {
640 None
641 }
642}
643
644struct UnavailableUsageLedger;
645
646impl UsageLedgerPort for UnavailableUsageLedger {
647 fn begin_graph_run(&self, _payload: &Value) -> Result<()> {
648 Ok(())
649 }
650
651 fn record_graph_command(&self, _payload: &Value) -> Result<()> {
652 Ok(())
653 }
654
655 fn workflow_report(&self, _payload: &Value) -> Result<Value> {
656 Err(anyhow!("workflow_host_port_unavailable"))
657 }
658}
659
660struct UnavailableEffect;
661
662impl EffectPort for UnavailableEffect {
663 fn runtime_descriptors(&self) -> Vec<Value> {
664 Vec::new()
665 }
666
667 fn compatible_runtime_id(
668 &self,
669 _kind: RuntimeKind,
670 _version_requirement: &str,
671 ) -> Option<String> {
672 None
673 }
674
675 fn resolve_runtime(
676 &self,
677 _runtime_id: &str,
678 _kind: RuntimeKind,
679 _version_requirement: &str,
680 ) -> Result<Arc<dyn ResolvedRuntime>> {
681 Err(anyhow!("workflow_host_port_unavailable"))
682 }
683
684 fn actor_fingerprint(
685 &self,
686 _value_id: &str,
687 _model: &str,
688 _reasoning_effort: &str,
689 ) -> Result<String> {
690 Err(anyhow!("workflow_host_port_unavailable"))
691 }
692
693 fn actor_capabilities(&self, _value_id: &str) -> Result<Value> {
694 Err(anyhow!("workflow_host_port_unavailable"))
695 }
696
697 fn admit_strategy_cwd(&self, _cwd: &str) -> Result<()> {
698 Err(anyhow!("workflow_host_port_unavailable"))
699 }
700
701 fn execute_script(
702 &self,
703 _command: &RunCommand,
704 _authorization_digest: &str,
705 _runtime: &Arc<dyn ResolvedRuntime>,
706 _revision_content: &Path,
707 _runtime_state_root: &Path,
708 _permit: &mut StrategyEffectPermit,
709 ) -> Result<Value> {
710 Err(anyhow!("workflow_host_port_unavailable"))
711 }
712
713 fn execute_actor(
714 &self,
715 _command: &RunCommand,
716 _authorization_digest: &str,
717 _binding: &BindingValue,
718 _permit: &mut StrategyEffectPermit,
719 _cwd: Option<&str>,
720 ) -> Result<Value> {
721 Err(anyhow!("workflow_host_port_unavailable"))
722 }
723
724 fn predecessor_locator(&self, _facts: &Value) -> Value {
725 Value::Null
726 }
727
728 fn dispatch_lane_operation(&self, _operation: &str, _params: &Value) -> Result<Value> {
729 Err(anyhow!("workflow_host_port_unavailable"))
730 }
731
732 fn new_correlation_id(&self) -> String {
733 String::new()
734 }
735
736 fn record_run_stop(
737 &self,
738 _portable_root: &Path,
739 _correlation_id: &str,
740 _confirmed: bool,
741 ) -> Result<()> {
742 Ok(())
743 }
744}
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use licoup_conversation::{
749 LICOUP_GUIDE_SKILL_ID, MembershipAccess, MembershipStatus, Principal, PrincipalKind,
750 ProfileResponsibility,
751 };
752
753 #[test]
754 fn request_scoped_authority_reads_each_owner_once() {
755 let calls = Arc::new(Mutex::new(BTreeMap::<&'static str, usize>::new()));
756 let mut inner = CountingAuthority {
757 calls: Arc::clone(&calls),
758 };
759 let mut scoped = RequestScopedAuthority::new(&mut inner);
760 for _ in 0..3 {
761 assert_eq!(
762 scoped.target_facts("agent:one").unwrap().status.as_deref(),
763 Some("ready")
764 );
765 assert_eq!(
766 scoped.model_price("model-a"),
767 Some(PriceFacts {
768 input: 1.0,
769 output: 2.0
770 })
771 );
772 assert_eq!(scoped.score("agent:one", "model-a"), Some(2));
773 assert_eq!(scoped.skills("agent:one"), vec!["skill-a".to_owned()]);
774 }
775 let recorded = calls.lock().unwrap();
776 assert_eq!(
777 recorded.clone(),
778 BTreeMap::from([
779 ("target_facts", 1),
780 ("model_price", 1),
781 ("score", 1),
782 ("skills", 1),
783 ])
784 );
785 }
786
787 #[test]
788 fn intent_cannot_assert_derived_truth_or_authority() {
789 let calls = Arc::new(Mutex::new(BTreeMap::<&'static str, usize>::new()));
790 let authority: SharedSnapshotAuthority =
791 Arc::new(Mutex::new(Box::new(CountingAuthority {
792 calls: Arc::clone(&calls),
793 })));
794 let membership = membership("membership:one", MembershipAccess::Member);
795 let intent = ProfileIntent {
796 revision: 3,
797 required_capabilities: vec!["caller-asserted-capability".to_owned()],
798 preferred_capabilities: vec!["caller-preference".to_owned()],
799 skill_references: vec![
800 "caller-asserted-skill".to_owned(),
801 LICOUP_GUIDE_SKILL_ID.to_owned(),
802 ],
803 preferred_model: Some("caller-model".to_owned()),
804 preferred_reasoning_effort: Some("high".to_owned()),
805 preferred_environment: Some("caller-environment".to_owned()),
806 preferred_permission_mode: Some("caller-permission-mode".to_owned()),
807 preferred_instructions: Some("caller-instructions".to_owned()),
808 preferred_context_window: Some(128_000),
809 responsibility: ProfileResponsibility::Assistant,
810 updated_at_unix_ms: 9,
811 };
812 let snapshot = project_profile_snapshot(
813 "conversation:g",
814 &membership,
815 &intent,
816 true,
817 &authority,
818 &UnavailableModelFacts,
819 );
820 assert_eq!(snapshot.membership_id, "membership:one");
821 assert_eq!(snapshot.intent_revision, 3);
822 assert_eq!(snapshot.model.as_deref(), Some("target-model"));
823 assert_eq!(snapshot.price_input_usd_per_million_tokens, Some(1.0));
824 assert_eq!(snapshot.price_output_usd_per_million_tokens, Some(2.0));
825 assert_eq!(snapshot.intelligence_score, Some(2));
826 assert!(
827 !snapshot
828 .capabilities
829 .contains(&"caller-asserted-capability".to_owned())
830 );
831 assert_eq!(
832 snapshot.capabilities,
833 vec!["conversationDriver:supported".to_owned()]
834 );
835 assert!(snapshot.skills.contains(&"skill-a".to_owned()));
836 assert!(
837 !snapshot
838 .skills
839 .contains(&"caller-asserted-skill".to_owned())
840 );
841 assert!(!snapshot.skills.contains(&LICOUP_GUIDE_SKILL_ID.to_owned()));
844 let bundled = project_profile_snapshot(
845 "conversation:g",
846 &membership,
847 &intent,
848 true,
849 &authority,
850 &BundledGuideFacts,
851 );
852 assert!(bundled.skills.contains(&"skill-a".to_owned()));
853 assert!(!bundled.skills.contains(&"caller-asserted-skill".to_owned()));
854 assert!(bundled.skills.contains(&LICOUP_GUIDE_SKILL_ID.to_owned()));
855 assert_eq!(snapshot.environment.as_deref(), Some("local"));
856 assert_eq!(snapshot.readiness.as_deref(), Some("ready"));
857 assert_eq!(
858 snapshot.authority,
859 vec![
860 "conversation.act".to_owned(),
861 "conversation.read".to_owned()
862 ]
863 );
864 let encoded = serde_json::to_value(&snapshot).unwrap();
865 assert!(encoded.get("id").is_none());
866 assert!(encoded.get("displayName").is_none());
867 assert!(encoded.get("updatedAtUnixMs").is_none());
868 }
869
870 #[test]
871 fn hard_filters_precede_the_stable_lexicographic_order() {
872 let mut first = snapshot("membership:b");
873 first.reliability_class = Some("verified".to_owned());
874 first.intelligence_score = Some(8);
875 first.price_input_usd_per_million_tokens = Some(4.0);
876 first.price_output_usd_per_million_tokens = Some(8.0);
877 first.latency_class = Some(2);
878 let mut pinned = snapshot("membership:c");
879 pinned.reliability_class = None;
880 pinned.intelligence_score = None;
881 let mut cheap = snapshot("membership:a");
882 cheap.reliability_class = Some("verified".to_owned());
883 cheap.intelligence_score = Some(8);
884 cheap.price_input_usd_per_million_tokens = Some(1.0);
885 cheap.price_output_usd_per_million_tokens = Some(2.0);
886 cheap.latency_class = Some(1);
887 let ranked = rank_candidates(
888 vec![first, pinned, cheap],
889 &CandidateFilters {
890 required_capabilities: vec!["conversationDriver:supported".to_owned()],
891 pinned_membership_ids: vec!["membership:c".to_owned()],
892 ..CandidateFilters::default()
893 },
894 )
895 .unwrap();
896 assert_eq!(
897 ranked
898 .iter()
899 .map(|candidate| candidate.membership_id.as_str())
900 .collect::<Vec<_>>(),
901 vec!["membership:c", "membership:a", "membership:b"]
902 );
903 let rejected = rank_candidates(
904 ranked,
905 &CandidateFilters {
906 membership_ids: vec!["membership:c".to_owned()],
907 required_skills: vec!["missing".to_owned()],
908 ..CandidateFilters::default()
909 },
910 );
911 assert_eq!(rejected.unwrap_err(), "profile_candidate_rejected");
912 }
913
914 #[test]
915 fn task_filters_boost_matching_catalog_tags() {
916 let mut frontend = snapshot("membership:frontend");
917 frontend.task_tags = vec!["frontend".to_owned()];
918 frontend.intelligence_score = Some(4);
919 let mut backend = snapshot("membership:backend");
920 backend.task_tags = vec!["backend".to_owned()];
921 backend.intelligence_score = Some(9);
922 let ranked = rank_candidates(
923 vec![frontend.clone(), backend.clone()],
924 &CandidateFilters {
925 preferred_task: Some("frontend".to_owned()),
926 ..CandidateFilters::default()
927 },
928 )
929 .unwrap();
930 assert_eq!(ranked[0].membership_id, "membership:frontend");
931 let required = rank_candidates(
932 vec![frontend, backend],
933 &CandidateFilters {
934 required_task: Some("frontend".to_owned()),
935 ..CandidateFilters::default()
936 },
937 )
938 .unwrap();
939 assert_eq!(required.len(), 1);
940 assert_eq!(required[0].membership_id, "membership:frontend");
941 }
942
943 fn membership(id: &str, access: MembershipAccess) -> Membership {
944 Membership {
945 id: id.to_owned(),
946 conversation_id: "conversation:g".to_owned(),
947 principal: Principal {
948 id: "agent:one".to_owned(),
949 kind: PrincipalKind::Agent,
950 display_name: "One".to_owned(),
951 agent_id: Some("agent:one".to_owned()),
952 created_at_unix_ms: 1,
953 },
954 access,
955 status: MembershipStatus::Active,
956 joined_at_unix_ms: 1,
957 left_at_unix_ms: None,
958 }
959 }
960
961 fn snapshot(id: &str) -> MembershipProfileSnapshot {
962 MembershipProfileSnapshot {
963 conversation_id: "conversation:g".to_owned(),
964 membership_id: id.to_owned(),
965 agent_id: "agent:one".to_owned(),
966 intent_revision: 1,
967 responsibility: ProfileResponsibility::Member,
968 required_capabilities: Vec::new(),
969 preferred_capabilities: Vec::new(),
970 skill_references: Vec::new(),
971 preferred_model: None,
972 preferred_reasoning_effort: None,
973 preferred_environment: None,
974 preferred_permission_mode: None,
975 model: Some("model-a".to_owned()),
976 capabilities: vec!["conversationDriver:supported".to_owned()],
977 skills: vec!["skill-a".to_owned()],
978 environment: Some("local".to_owned()),
979 readiness: Some("ready".to_owned()),
980 price_input_usd_per_million_tokens: None,
981 price_output_usd_per_million_tokens: None,
982 intelligence_score: None,
983 task_tags: Vec::new(),
984 reliability_class: None,
985 latency_class: None,
986 authority: vec!["conversation.act".to_owned()],
987 }
988 }
989
990 struct CountingAuthority {
991 calls: Arc<Mutex<BTreeMap<&'static str, usize>>>,
992 }
993
994 impl ProfileSnapshotAuthority for CountingAuthority {
995 fn target_facts(&mut self, _agent_id: &str) -> Option<TargetFacts> {
996 *self
997 .calls
998 .lock()
999 .unwrap()
1000 .entry("target_facts")
1001 .or_insert(0) += 1;
1002 Some(TargetFacts {
1003 status: Some("ready".to_owned()),
1004 model: Some("target-model".to_owned()),
1005 environment: Some("local".to_owned()),
1006 capabilities: vec!["conversationDriver:supported".to_owned()],
1007 readiness: Some("ready".to_owned()),
1008 reliability_class: Some("verified".to_owned()),
1009 latency_class: Some(1),
1010 })
1011 }
1012
1013 fn model_price_usd_per_million_tokens(&mut self, _model: &str) -> Option<PriceFacts> {
1014 *self.calls.lock().unwrap().entry("model_price").or_insert(0) += 1;
1015 Some(PriceFacts {
1016 input: 1.0,
1017 output: 2.0,
1018 })
1019 }
1020
1021 fn coding_score(&mut self, _agent_id: &str, _model: &str) -> Option<i64> {
1022 *self.calls.lock().unwrap().entry("score").or_insert(0) += 1;
1023 Some(2)
1024 }
1025
1026 fn skill_names(&mut self, _agent_id: &str) -> Vec<String> {
1027 *self.calls.lock().unwrap().entry("skills").or_insert(0) += 1;
1028 vec!["skill-a".to_owned()]
1029 }
1030 }
1031
1032 struct BundledGuideFacts;
1033
1034 impl ModelFactsPort for BundledGuideFacts {
1035 fn model_display_name(&self, model: &str) -> String {
1036 model.to_owned()
1037 }
1038
1039 fn project_allowlisted_model(&self, _model: &str) -> Option<Value> {
1040 None
1041 }
1042
1043 fn task_tags_for_model(&self, _model: &str) -> Vec<String> {
1044 Vec::new()
1045 }
1046
1047 fn bundled_guide_skill_id(&self) -> Option<&'static str> {
1048 Some(LICOUP_GUIDE_SKILL_ID)
1049 }
1050 }
1051}
1052#[cfg(test)]
1053mod port_stability {
1054 use super::*;
1068 use licoup_conversation::ProfileResponsibility;
1069
1070 const PORT_SOURCE: &str = include_str!("ports.rs");
1071 const CRATE_ROOT: &str = include_str!("lib.rs");
1072
1073 const PUBLISHED_PORT_ITEMS: &[&str] = &[
1081 "fn host_ports",
1082 "fn install_host_ports",
1083 "fn project_profile_snapshot",
1084 "fn project_profile_snapshots",
1085 "fn rank_candidates",
1086 "fn route_receipt",
1087 "struct CandidateFilters",
1088 "struct HostPorts",
1089 "struct PriceFacts",
1090 "struct TargetFacts",
1091 "trait ModelFactsPort",
1092 "trait ProfileSnapshotAuthority",
1093 "type SharedSnapshotAuthority",
1094 ];
1095
1096 const UNPUBLISHED_PORT_ITEMS: &[&str] = &["RequestScopedAuthority"];
1100
1101 const FROZEN_TRAIT_METHODS: &[(&str, &[&str])] = &[
1105 (
1106 "ModelFactsPort",
1107 &[
1108 "model_display_name",
1109 "project_allowlisted_model",
1110 "task_tags_for_model",
1111 "bundled_guide_skill_id",
1112 ],
1113 ),
1114 (
1115 "ProfileSnapshotAuthority",
1116 &[
1117 "target_facts",
1118 "model_price_usd_per_million_tokens",
1119 "coding_score",
1120 "skill_names",
1121 ],
1122 ),
1123 ];
1124
1125 const FROZEN_STRUCT_FIELDS: &[(&str, &[&str])] = &[
1127 (
1128 "CandidateFilters",
1129 &[
1130 "required_authority",
1131 "required_skills",
1132 "required_capabilities",
1133 "required_model",
1134 "required_environment",
1135 "required_readiness",
1136 "membership_ids",
1137 "pinned_membership_ids",
1138 "preferred_skills",
1139 "preferred_capabilities",
1140 "preferred_model",
1141 "preferred_environment",
1142 "required_task",
1143 "preferred_task",
1144 ],
1145 ),
1146 ("HostPorts", &["profile", "model", "usage", "effect"]),
1147 ("PriceFacts", &["input", "output"]),
1148 (
1149 "TargetFacts",
1150 &[
1151 "status",
1152 "model",
1153 "environment",
1154 "capabilities",
1155 "readiness",
1156 "reliability_class",
1157 "latency_class",
1158 ],
1159 ),
1160 ];
1161
1162 const ROOT_REEXPORTED_PORT_ITEMS: &[&str] = &[
1164 "ActorTurnError",
1165 "CandidateFilters",
1166 "EffectPort",
1167 "HostPorts",
1168 "ModelFactsPort",
1169 "PriceFacts",
1170 "ProfileSnapshotAuthority",
1171 "ResolvedRuntime",
1172 "SharedSnapshotAuthority",
1173 "StrategyEffectPermit",
1174 "TargetFacts",
1175 "UsageLedgerPort",
1176 "host_ports",
1177 "install_host_ports",
1178 "project_profile_snapshot",
1179 "project_profile_snapshots",
1180 "rank_candidates",
1181 "route_receipt",
1182 ];
1183
1184 #[derive(Clone, Debug, Eq, PartialEq)]
1185 struct PortItem {
1186 kind: String,
1187 name: String,
1188 }
1189
1190 impl PortItem {
1191 fn render(&self) -> String {
1192 format!("{} {}", self.kind, self.name)
1193 }
1194 }
1195
1196 #[derive(Clone, Debug)]
1198 struct PortDeclaration {
1199 item: PortItem,
1200 members: Vec<String>,
1201 }
1202
1203 fn scan_port_declarations(source: &str) -> Vec<PortDeclaration> {
1211 let lines = structural_lines(source);
1212 let mut declarations = Vec::new();
1213 let mut index = 0;
1214 while index < lines.len() {
1215 if lines[index].indent != 0 {
1216 index += 1;
1217 continue;
1218 }
1219 let Some(item) = port_item(&lines[index].text, false) else {
1220 index += 1;
1221 continue;
1222 };
1223 let opened = index;
1224 signature_end(&lines, &mut index);
1225 let members = member_names(&lines, opened, index);
1226 declarations.push(PortDeclaration { item, members });
1227 }
1228 declarations
1229 }
1230
1231 fn member_names(lines: &[StructuralLine], opened: usize, end: usize) -> Vec<String> {
1236 if !lines[opened].text.ends_with('{') || end <= opened + 1 {
1237 return Vec::new();
1238 }
1239 let Some(body_indent) = lines[opened + 1..end]
1240 .iter()
1241 .find(|line| line.indent > lines[opened].indent)
1242 .map(|line| line.indent)
1243 else {
1244 return Vec::new();
1245 };
1246 let mut members = Vec::new();
1247 for line in &lines[opened + 1..end] {
1248 if line.indent != body_indent {
1249 continue;
1250 }
1251 if let Some(member) = port_item(&line.text, true)
1252 .map(|item| item.name)
1253 .or_else(|| struct_field(&line.text))
1254 {
1255 members.push(member);
1256 }
1257 }
1258 members
1259 }
1260
1261 struct StructuralLine {
1262 indent: usize,
1263 text: String,
1264 }
1265
1266 fn structural_lines(source: &str) -> Vec<StructuralLine> {
1268 let mut lines = Vec::new();
1269 let mut in_block_comment = false;
1270 for raw in source.lines() {
1271 let mut text = String::new();
1272 let mut quotes = 0usize;
1273 let mut characters = raw.chars().peekable();
1274 while let Some(character) = characters.next() {
1275 if in_block_comment {
1276 if character == '*' && characters.peek() == Some(&'/') {
1277 characters.next();
1278 in_block_comment = false;
1279 }
1280 continue;
1281 }
1282 if character == '"' {
1283 quotes = 1 - quotes;
1284 } else if quotes == 0 && character == '/' && characters.peek() == Some(&'/') {
1285 break;
1286 } else if quotes == 0 && character == '/' && characters.peek() == Some(&'*') {
1287 characters.next();
1288 in_block_comment = true;
1289 continue;
1290 }
1291 text.push(character);
1292 }
1293 if !text.trim().is_empty() {
1294 lines.push(StructuralLine {
1295 indent: text.len() - text.trim_start().len(),
1296 text: text.trim().to_owned(),
1297 });
1298 }
1299 }
1300 lines
1301 }
1302
1303 fn port_item(text: &str, nested: bool) -> Option<PortItem> {
1307 if !text.starts_with("pub ") && !nested {
1308 return None;
1309 }
1310 if !text.starts_with("pub ")
1311 && !text.starts_with("fn ")
1312 && !text.starts_with("type ")
1313 && !text.starts_with("const ")
1314 && !text.starts_with("static ")
1315 {
1316 return None;
1317 }
1318 let declaration = declaration_part(text)?;
1319 let keyword = declaration.split_whitespace().next()?;
1320 let kind = match keyword {
1321 "fn" => "fn",
1322 "type" => "type",
1323 "struct" => "struct",
1324 "enum" => "enum",
1325 "trait" => "trait",
1326 "mod" => "mod",
1327 "const" => "const",
1328 "static" => "static",
1329 _ => return None,
1330 };
1331 let name = declaration[keyword.len()..]
1332 .trim_start()
1333 .split(|character: char| !(character.is_alphanumeric() || character == '_'))
1334 .next()
1335 .filter(|name| !name.is_empty())?;
1336 Some(PortItem {
1337 kind: kind.to_owned(),
1338 name: name.to_owned(),
1339 })
1340 }
1341
1342 fn struct_field(text: &str) -> Option<String> {
1344 let declaration = text.strip_prefix("pub ")?.split(';').next()?;
1345 let (name, _) = declaration.split_once(':')?;
1346 let name = name.trim();
1347 (!name.is_empty()).then(|| name.to_owned())
1348 }
1349
1350 fn declaration_part(text: &str) -> Option<String> {
1352 let declaration = text.strip_prefix("pub ").unwrap_or(text);
1353 if let Some(opening) = declaration.rfind('{') {
1354 return Some(declaration[..opening].trim().to_owned());
1355 }
1356 let declaration = declaration.split(';').next()?.trim();
1357 (!declaration.is_empty()).then(|| declaration.to_owned())
1358 }
1359
1360 fn signature_end(lines: &[StructuralLine], index: &mut usize) -> usize {
1362 let mut depth = 0i64;
1363 let mut opened_block = false;
1364 while *index < lines.len() {
1365 let text = lines[*index].text.clone();
1366 *index += 1;
1367 depth += balance(&text);
1368 if depth > 0 {
1369 opened_block = true;
1370 }
1371 if (opened_block && depth == 0) || (!opened_block && text.ends_with(';')) {
1372 break;
1373 }
1374 }
1375 *index
1376 }
1377
1378 fn balance(text: &str) -> i64 {
1379 text.chars().fold(0, |depth, character| match character {
1380 '{' => depth + 1,
1381 '}' => depth - 1,
1382 _ => depth,
1383 })
1384 }
1385
1386 fn frozen_members<'a>(frozen: &'a [(&'a str, &'a [&'a str])]) -> BTreeSet<&'a str> {
1387 frozen.iter().map(|(name, _)| *name).collect()
1388 }
1389
1390 fn contract_differences(declared: &[PortItem], frozen: &[&str]) -> Vec<String> {
1394 let declared = declared
1395 .iter()
1396 .map(PortItem::render)
1397 .collect::<BTreeSet<_>>();
1398 let frozen = frozen
1399 .iter()
1400 .map(|item| (*item).to_owned())
1401 .collect::<BTreeSet<_>>();
1402 let mut differences = declared
1403 .difference(&frozen)
1404 .map(|item| format!("{item} is declared but not published in the contract"))
1405 .collect::<Vec<_>>();
1406 differences.extend(
1407 frozen
1408 .difference(&declared)
1409 .map(|item| format!("{item} is published but no longer declared")),
1410 );
1411 differences
1412 }
1413
1414 fn member_differences(
1415 declarations: &[PortDeclaration],
1416 kind: &str,
1417 frozen: &[(&str, &[&str])],
1418 ) -> Vec<String> {
1419 let declared = declarations
1420 .iter()
1421 .filter(|declaration| declaration.item.kind == kind)
1422 .map(|declaration| (declaration.item.name.clone(), declaration.members.clone()))
1423 .collect::<BTreeMap<_, _>>();
1424 let mut differences = Vec::new();
1425 for (name, expected) in frozen {
1426 match declared.get(*name) {
1427 None => differences.push(format!("{kind} {name} is no longer declared")),
1428 Some(actual) if actual != expected => differences.push(format!(
1429 "{kind} {name} changed\n published: {expected:?}\n declared: {actual:?}",
1430 )),
1431 Some(_) => {}
1432 }
1433 }
1434 for (name, members) in &declared {
1435 if members.is_empty() {
1438 continue;
1439 }
1440 if !frozen_members(frozen).contains(name.as_str()) {
1441 differences.push(format!(
1442 "{kind} {name} is not part of the published contract"
1443 ));
1444 }
1445 }
1446 differences
1447 }
1448
1449 fn render_member_record(declarations: &[PortDeclaration], kind: &str) -> String {
1453 let mut rendered = declarations
1454 .iter()
1455 .filter(|declaration| declaration.item.kind == kind && !declaration.members.is_empty())
1456 .map(|declaration| {
1457 let members = declaration
1458 .members
1459 .iter()
1460 .map(|member| format!("\"{member}\""))
1461 .collect::<Vec<_>>()
1462 .join(", ");
1463 format!(" (\"{}\", &[{}]),\n", declaration.item.name, members,)
1464 })
1465 .collect::<Vec<_>>();
1466 rendered.sort();
1467 rendered.concat()
1468 }
1469
1470 fn render_item_list(declarations: &[PortDeclaration], kind: &str) -> String {
1471 let mut rendered = declarations
1472 .iter()
1473 .filter(|declaration| declaration.item.kind == kind)
1474 .map(|declaration| format!(" \"{}\",\n", declaration.item.render()))
1475 .collect::<Vec<_>>();
1476 rendered.sort();
1477 rendered.concat()
1478 }
1479
1480 #[test]
1487 fn print_the_frozen_records() {
1488 let declarations = scan_port_declarations(PORT_SOURCE);
1489 println!("PUBLISHED_PORT_ITEMS = [");
1490 print!("{}", render_item_list(&declarations, "fn"));
1491 print!("{}", render_item_list(&declarations, "struct"));
1492 print!("{}", render_item_list(&declarations, "trait"));
1493 print!("{}", render_item_list(&declarations, "type"));
1494 println!("]");
1495 println!(
1496 "FROZEN_TRAIT_METHODS = [\n{}]",
1497 render_member_record(&declarations, "trait"),
1498 );
1499 println!(
1500 "FROZEN_STRUCT_FIELDS = [\n{}]",
1501 render_member_record(&declarations, "struct"),
1502 );
1503 }
1504
1505 fn stability_snapshot(id: &str) -> MembershipProfileSnapshot {
1508 MembershipProfileSnapshot {
1509 conversation_id: "conversation:g".to_owned(),
1510 membership_id: id.to_owned(),
1511 agent_id: "agent:one".to_owned(),
1512 intent_revision: 1,
1513 responsibility: ProfileResponsibility::Member,
1514 required_capabilities: Vec::new(),
1515 preferred_capabilities: Vec::new(),
1516 skill_references: Vec::new(),
1517 preferred_model: None,
1518 preferred_reasoning_effort: None,
1519 preferred_environment: None,
1520 preferred_permission_mode: None,
1521 model: Some("model-a".to_owned()),
1522 capabilities: vec!["conversationDriver:supported".to_owned()],
1523 skills: vec!["skill-a".to_owned()],
1524 environment: Some("local".to_owned()),
1525 readiness: Some("ready".to_owned()),
1526 price_input_usd_per_million_tokens: None,
1527 price_output_usd_per_million_tokens: None,
1528 intelligence_score: None,
1529 task_tags: Vec::new(),
1530 reliability_class: None,
1531 latency_class: None,
1532 authority: vec!["conversation.act".to_owned()],
1533 }
1534 }
1535
1536 #[test]
1540 fn port_surface_declarations_are_frozen() {
1541 let declarations = scan_port_declarations(PORT_SOURCE);
1542 let declared = declarations
1543 .iter()
1544 .map(|declaration| declaration.item.clone())
1545 .collect::<Vec<_>>();
1546 let mut differences = contract_differences(&declared, PUBLISHED_PORT_ITEMS);
1547 differences.extend(UNPUBLISHED_PORT_ITEMS.iter().flat_map(|item| {
1548 let declared = PORT_SOURCE.contains(&format!("struct {item}"));
1549 let published = PORT_SOURCE.contains(&format!("pub struct {item}"));
1550 let mut found = Vec::new();
1551 if !declared {
1552 found.push(format!("{item} is listed as internal but is not declared"));
1553 }
1554 if published {
1555 found.push(format!("{item} is listed as internal but is published"));
1556 }
1557 found
1558 }));
1559 differences.extend(member_differences(
1560 &declarations,
1561 "trait",
1562 FROZEN_TRAIT_METHODS,
1563 ));
1564 differences.extend(member_differences(
1565 &declarations,
1566 "struct",
1567 FROZEN_STRUCT_FIELDS,
1568 ));
1569 assert!(
1570 differences.is_empty(),
1571 "the workflow port contract changed; update the host composition and the published record together:\n{}",
1572 differences.join("\n"),
1573 );
1574 assert_eq!(
1575 declarations.len(),
1576 PUBLISHED_PORT_ITEMS.len(),
1577 "the set of declarations this module owns changed",
1578 );
1579 }
1580
1581 #[test]
1590 fn port_trait_signatures_are_frozen() {
1591 let _: fn(&mut UnavailableProfileAuthority, &str) -> Option<TargetFacts> =
1592 UnavailableProfileAuthority::target_facts;
1593 let _: fn(&mut UnavailableProfileAuthority, &str) -> Option<PriceFacts> =
1594 UnavailableProfileAuthority::model_price_usd_per_million_tokens;
1595 let _: fn(&mut UnavailableProfileAuthority, &str, &str) -> Option<i64> =
1596 UnavailableProfileAuthority::coding_score;
1597 let _: fn(&mut UnavailableProfileAuthority, &str) -> Vec<String> =
1598 UnavailableProfileAuthority::skill_names;
1599 let _: fn(&mut dyn ProfileSnapshotAuthority, &str, &str) -> Option<i64> =
1600 |authority, agent_id, model| {
1601 (authority as &mut dyn ProfileSnapshotAuthority).coding_score(agent_id, model)
1602 };
1603
1604 let _: fn(&UnavailableModelFacts, &str) -> String =
1605 UnavailableModelFacts::model_display_name;
1606 let _: fn(&UnavailableModelFacts, &str) -> Option<Value> =
1607 UnavailableModelFacts::project_allowlisted_model;
1608 let _: fn(&UnavailableModelFacts, &str) -> Vec<String> =
1609 UnavailableModelFacts::task_tags_for_model;
1610 let _: fn(&UnavailableModelFacts) -> Option<&'static str> =
1611 UnavailableModelFacts::bundled_guide_skill_id;
1612 let _: fn(&dyn ModelFactsPort, &str) -> String =
1613 |port, model| (port as &dyn ModelFactsPort).model_display_name(model);
1614
1615 let _: fn(&UnavailableUsageLedger, &Value) -> Result<()> =
1616 UnavailableUsageLedger::begin_graph_run;
1617 let _: fn(&UnavailableUsageLedger, &Value) -> Result<()> =
1618 UnavailableUsageLedger::record_graph_command;
1619 let _: fn(&UnavailableUsageLedger, &Value) -> Result<Value> =
1620 UnavailableUsageLedger::workflow_report;
1621 let _: fn(&dyn UsageLedgerPort, &Value) -> Result<()> =
1622 |port, payload| (port as &dyn UsageLedgerPort).begin_graph_run(payload);
1623
1624 struct RuntimeWitness;
1625 impl ResolvedRuntime for RuntimeWitness {
1626 fn fingerprint(&self) -> &str {
1627 "port-signature-witness"
1628 }
1629
1630 fn as_any(&self) -> &dyn std::any::Any {
1631 self
1632 }
1633 }
1634 let _: for<'a> fn(&'a RuntimeWitness) -> &'a str = RuntimeWitness::fingerprint;
1635 let _: for<'a> fn(&'a RuntimeWitness) -> &'a dyn std::any::Any = RuntimeWitness::as_any;
1636 let _: for<'a> fn(&'a dyn ResolvedRuntime) -> &'a str =
1637 |runtime| (runtime as &dyn ResolvedRuntime).fingerprint();
1638 let _: for<'a> fn(&'a dyn ResolvedRuntime) -> &'a dyn std::any::Any =
1639 |runtime| (runtime as &dyn ResolvedRuntime).as_any();
1640
1641 let _: fn(&UnavailableEffect) -> Vec<Value> = UnavailableEffect::runtime_descriptors;
1642 let _: fn(&UnavailableEffect, RuntimeKind, &str) -> Option<String> =
1643 UnavailableEffect::compatible_runtime_id;
1644 let _: fn(&UnavailableEffect, &str, RuntimeKind, &str) -> Result<Arc<dyn ResolvedRuntime>> =
1645 UnavailableEffect::resolve_runtime;
1646 let _: fn(&UnavailableEffect, &str, &str, &str) -> Result<String> =
1647 UnavailableEffect::actor_fingerprint;
1648 let _: fn(&UnavailableEffect, &str) -> Result<()> = UnavailableEffect::admit_strategy_cwd;
1649 let _: fn(
1650 &UnavailableEffect,
1651 &RunCommand,
1652 &str,
1653 &Arc<dyn ResolvedRuntime>,
1654 &Path,
1655 &Path,
1656 &mut StrategyEffectPermit,
1657 ) -> Result<Value> = UnavailableEffect::execute_script;
1658 let _: fn(
1659 &UnavailableEffect,
1660 &RunCommand,
1661 &str,
1662 &BindingValue,
1663 &mut StrategyEffectPermit,
1664 Option<&str>,
1665 ) -> Result<Value> = UnavailableEffect::execute_actor;
1666 let _: fn(&UnavailableEffect, &Value) -> Value = UnavailableEffect::predecessor_locator;
1667 let _: fn(&UnavailableEffect, &str, &Value) -> Result<Value> =
1668 UnavailableEffect::dispatch_lane_operation;
1669 let _: fn(&UnavailableEffect) -> String = UnavailableEffect::new_correlation_id;
1670 let _: fn(&UnavailableEffect, &Path, &str, bool) -> Result<()> =
1671 UnavailableEffect::record_run_stop;
1672 let _: fn(&dyn EffectPort, RuntimeKind, &str) -> Option<String> =
1673 |port, kind, requirement| {
1674 (port as &dyn EffectPort).compatible_runtime_id(kind, requirement)
1675 };
1676
1677 let error = ActorTurnError::dispatch_failed("port-signature-witness");
1678 let _: &str = error.detail();
1679 assert_eq!(
1680 error.to_string(),
1681 "port-signature-witness",
1682 "the port error keeps displaying its owner's detail",
1683 );
1684 let _: fn(&mut StrategyEffectPermit, &RunCommand, &str, &str) -> Result<()> =
1685 StrategyEffectPermit::consume;
1686 let _: fn(&str, &str, &str) -> Result<StrategyEffectPermit> = StrategyEffectPermit::issue;
1687 }
1688
1689 #[test]
1692 fn port_struct_fields_are_frozen() {
1693 let target_facts = TargetFacts {
1694 status: Some("ready".to_owned()),
1695 model: Some("model-a".to_owned()),
1696 environment: Some("local".to_owned()),
1697 capabilities: vec!["conversationDriver:supported".to_owned()],
1698 readiness: Some("ready".to_owned()),
1699 reliability_class: Some("verified".to_owned()),
1700 latency_class: Some(1),
1701 };
1702 let price_facts = PriceFacts {
1703 input: 1.0,
1704 output: 2.0,
1705 };
1706 let filters = CandidateFilters {
1707 required_authority: vec!["conversation.read".to_owned()],
1708 required_skills: vec!["skill-a".to_owned()],
1709 required_capabilities: vec!["conversationDriver:supported".to_owned()],
1710 required_model: Some("model-a".to_owned()),
1711 required_environment: Some("local".to_owned()),
1712 required_readiness: Some("ready".to_owned()),
1713 membership_ids: vec!["membership:one".to_owned()],
1714 pinned_membership_ids: Vec::new(),
1715 preferred_skills: Vec::new(),
1716 preferred_capabilities: Vec::new(),
1717 preferred_model: None,
1718 preferred_environment: None,
1719 required_task: Some("frontend".to_owned()),
1720 preferred_task: None,
1721 };
1722 let failure = ActorTurnError::dispatch_failed("port-field-witness");
1723 let ports = HostPorts {
1724 profile: Arc::new(Mutex::new(Box::new(UnavailableProfileAuthority))),
1725 model: Arc::new(UnavailableModelFacts),
1726 usage: Arc::new(UnavailableUsageLedger),
1727 effect: Arc::new(UnavailableEffect),
1728 };
1729 let snapshots =
1730 project_profile_snapshots("conversation:g", &[], &ports.profile, &*ports.model);
1731 let receipt = route_receipt("conversation:g", &snapshots, &*ports.model, "policy-v1");
1732 let permit = StrategyEffectPermit::issue(
1733 "command",
1734 "port-stability-authorization-digest-with-exactly-64-characters-x",
1735 "port-stability-effect-fingerprint-with-exactly-64-characters-len",
1736 )
1737 .expect("a complete permit issues");
1738 assert!(receipt.is_object(), "the receipt keeps its document shape");
1739 assert_eq!(filters.clone(), filters, "the filter data stays comparable");
1740 assert_eq!(price_facts.input + price_facts.output, 3.0);
1741 assert_eq!(target_facts.latency_class, Some(1));
1742 assert_eq!(failure.detail(), "port-field-witness");
1743 assert!(format!("{permit:?}").starts_with("StrategyEffectPermit"));
1744 assert_eq!(
1745 ports
1746 .profile
1747 .lock()
1748 .unwrap_or_else(|poison| poison.into_inner())
1749 .coding_score("agent:one", "model-a"),
1750 None,
1751 );
1752 assert_eq!(
1753 HostPorts::unavailable()
1754 .usage
1755 .workflow_report(&Value::Null)
1756 .unwrap_err()
1757 .to_string(),
1758 "workflow_host_port_unavailable",
1759 "the uninstalled composition stays fail-closed",
1760 );
1761 }
1762
1763 #[test]
1768 fn port_traits_keep_their_published_bounds_and_members() {
1769 fn object_safe<T: ?Sized>() {}
1770 fn sendable<T: Send + ?Sized>() {}
1771 fn shareable<T: Send + Sync + ?Sized>() {}
1772
1773 object_safe::<dyn ProfileSnapshotAuthority>();
1774 object_safe::<dyn ModelFactsPort>();
1775 object_safe::<dyn UsageLedgerPort>();
1776 object_safe::<dyn ResolvedRuntime>();
1777 object_safe::<dyn EffectPort>();
1778 sendable::<dyn ProfileSnapshotAuthority>();
1779 shareable::<dyn ModelFactsPort>();
1780 shareable::<dyn UsageLedgerPort>();
1781 shareable::<dyn ResolvedRuntime>();
1782 shareable::<dyn EffectPort>();
1783
1784 struct WitnessProfileAuthority;
1785 struct WitnessModelFacts;
1786 struct WitnessUsageLedger;
1787 struct WitnessResolvedRuntime;
1788 struct WitnessEffect;
1789
1790 impl ProfileSnapshotAuthority for WitnessProfileAuthority {
1791 fn target_facts(&mut self, _agent_id: &str) -> Option<TargetFacts> {
1792 None
1793 }
1794
1795 fn model_price_usd_per_million_tokens(&mut self, _model: &str) -> Option<PriceFacts> {
1796 None
1797 }
1798
1799 fn coding_score(&mut self, _agent_id: &str, _model: &str) -> Option<i64> {
1800 None
1801 }
1802
1803 fn skill_names(&mut self, _agent_id: &str) -> Vec<String> {
1804 Vec::new()
1805 }
1806 }
1807
1808 impl ModelFactsPort for WitnessModelFacts {
1809 fn model_display_name(&self, model: &str) -> String {
1810 model.to_owned()
1811 }
1812
1813 fn project_allowlisted_model(&self, _model: &str) -> Option<Value> {
1814 None
1815 }
1816
1817 fn task_tags_for_model(&self, _model: &str) -> Vec<String> {
1818 Vec::new()
1819 }
1820
1821 fn bundled_guide_skill_id(&self) -> Option<&'static str> {
1822 None
1823 }
1824 }
1825
1826 impl UsageLedgerPort for WitnessUsageLedger {
1827 fn begin_graph_run(&self, _payload: &Value) -> Result<()> {
1828 Ok(())
1829 }
1830
1831 fn record_graph_command(&self, _payload: &Value) -> Result<()> {
1832 Ok(())
1833 }
1834
1835 fn workflow_report(&self, _payload: &Value) -> Result<Value> {
1836 Ok(Value::Null)
1837 }
1838 }
1839
1840 impl ResolvedRuntime for WitnessResolvedRuntime {
1841 fn fingerprint(&self) -> &str {
1842 "witness"
1843 }
1844
1845 fn as_any(&self) -> &dyn std::any::Any {
1846 self
1847 }
1848 }
1849
1850 impl EffectPort for WitnessEffect {
1851 fn runtime_descriptors(&self) -> Vec<Value> {
1852 Vec::new()
1853 }
1854
1855 fn compatible_runtime_id(
1856 &self,
1857 _kind: RuntimeKind,
1858 _version_requirement: &str,
1859 ) -> Option<String> {
1860 None
1861 }
1862
1863 fn resolve_runtime(
1864 &self,
1865 _runtime_id: &str,
1866 _kind: RuntimeKind,
1867 _version_requirement: &str,
1868 ) -> Result<Arc<dyn ResolvedRuntime>> {
1869 Ok(Arc::new(WitnessResolvedRuntime))
1870 }
1871
1872 fn actor_fingerprint(
1873 &self,
1874 _value_id: &str,
1875 _model: &str,
1876 _reasoning_effort: &str,
1877 ) -> Result<String> {
1878 Ok("witness".to_owned())
1879 }
1880
1881 fn actor_capabilities(&self, _value_id: &str) -> Result<Value> {
1882 Ok(json!({ "ok": true }))
1883 }
1884
1885 fn admit_strategy_cwd(&self, _cwd: &str) -> Result<()> {
1886 Ok(())
1887 }
1888
1889 fn execute_script(
1890 &self,
1891 _command: &RunCommand,
1892 _authorization_digest: &str,
1893 _runtime: &Arc<dyn ResolvedRuntime>,
1894 _revision_content: &Path,
1895 _runtime_state_root: &Path,
1896 _permit: &mut StrategyEffectPermit,
1897 ) -> Result<Value> {
1898 Ok(Value::Null)
1899 }
1900
1901 fn execute_actor(
1902 &self,
1903 _command: &RunCommand,
1904 _authorization_digest: &str,
1905 _binding: &BindingValue,
1906 _permit: &mut StrategyEffectPermit,
1907 _cwd: Option<&str>,
1908 ) -> Result<Value> {
1909 Ok(Value::Null)
1910 }
1911
1912 fn predecessor_locator(&self, _facts: &Value) -> Value {
1913 Value::Null
1914 }
1915
1916 fn dispatch_lane_operation(&self, _operation: &str, _params: &Value) -> Result<Value> {
1917 Ok(Value::Null)
1918 }
1919
1920 fn new_correlation_id(&self) -> String {
1921 String::new()
1922 }
1923
1924 fn record_run_stop(
1925 &self,
1926 _portable_root: &Path,
1927 _correlation_id: &str,
1928 _confirmed: bool,
1929 ) -> Result<()> {
1930 Ok(())
1931 }
1932 }
1933
1934 fn assert_host_ports_shape<T: Send + Sync + 'static>() {}
1935 assert_host_ports_shape::<HostPorts>();
1936 let ports = HostPorts {
1937 profile: Arc::new(Mutex::new(Box::new(WitnessProfileAuthority))),
1938 model: Arc::new(WitnessModelFacts),
1939 usage: Arc::new(WitnessUsageLedger),
1940 effect: Arc::new(WitnessEffect),
1941 };
1942 assert!(ports.effect.runtime_descriptors().is_empty());
1943 assert!(
1944 ports
1945 .effect
1946 .resolve_runtime("witness", RuntimeKind::new("node"), ">=0")
1947 .is_ok(),
1948 );
1949 drop(
1950 ports
1951 .profile
1952 .lock()
1953 .unwrap_or_else(|poison| poison.into_inner()),
1954 );
1955 }
1956
1957 #[test]
1961 fn port_wire_contracts_are_frozen() {
1962 let filters = CandidateFilters {
1963 required_authority: vec!["conversation.read".to_owned()],
1964 required_skills: vec!["skill-a".to_owned()],
1965 required_capabilities: vec!["conversationDriver:supported".to_owned()],
1966 required_model: Some("model-a".to_owned()),
1967 required_environment: Some("local".to_owned()),
1968 required_readiness: Some("ready".to_owned()),
1969 membership_ids: vec!["membership:one".to_owned()],
1970 pinned_membership_ids: vec!["membership:two".to_owned()],
1971 preferred_skills: Vec::new(),
1972 preferred_capabilities: Vec::new(),
1973 preferred_model: None,
1974 preferred_environment: None,
1975 required_task: Some("frontend".to_owned()),
1976 preferred_task: None,
1977 };
1978 let encoded = serde_json::to_value(&filters).expect("candidate filters serialize");
1979 let keys = encoded
1980 .as_object()
1981 .expect("candidate filters serialize as an object")
1982 .keys()
1983 .cloned()
1984 .collect::<BTreeSet<_>>();
1985 assert_eq!(
1986 keys,
1987 BTreeSet::from([
1988 "membershipIds".to_owned(),
1989 "pinnedMembershipIds".to_owned(),
1990 "preferredCapabilities".to_owned(),
1991 "preferredSkills".to_owned(),
1992 "requiredAuthority".to_owned(),
1993 "requiredCapabilities".to_owned(),
1994 "requiredEnvironment".to_owned(),
1995 "requiredModel".to_owned(),
1996 "requiredReadiness".to_owned(),
1997 "requiredSkills".to_owned(),
1998 "requiredTask".to_owned(),
1999 ]),
2000 "the candidate-filter wire contract changed",
2001 );
2002 assert!(
2003 !keys.contains("preferredModel")
2004 && !keys.contains("preferredEnvironment")
2005 && !keys.contains("preferredTask"),
2006 "absent preferences stay absent rather than serialize as null",
2007 );
2008
2009 let mut candidate = stability_snapshot("membership:one");
2010 candidate.capabilities = Vec::new();
2011 candidate.skills = Vec::new();
2012 candidate.authority = Vec::new();
2013 let receipt = route_receipt(
2014 "conversation:g",
2015 &[candidate],
2016 &UnavailableModelFacts,
2017 "policy-v1",
2018 );
2019 let object = receipt.as_object().expect("the route receipt is an object");
2020 assert_eq!(
2021 object.keys().cloned().collect::<BTreeSet<_>>(),
2022 BTreeSet::from([
2023 "candidates".to_owned(),
2024 "conversationId".to_owned(),
2025 "rankedMembershipIds".to_owned(),
2026 "sourceRevisions".to_owned(),
2027 ]),
2028 "the route-receipt wire contract changed",
2029 );
2030 assert_eq!(
2031 receipt.get("conversationId").and_then(Value::as_str),
2032 Some("conversation:g"),
2033 );
2034 assert_eq!(
2035 receipt
2036 .get("rankedMembershipIds")
2037 .and_then(Value::as_array)
2038 .map(Vec::len),
2039 Some(1),
2040 );
2041 let revisions = receipt
2042 .get("sourceRevisions")
2043 .and_then(Value::as_array)
2044 .expect("the source revisions are a list")
2045 .iter()
2046 .map(|entry| {
2047 (
2048 entry
2049 .get("source")
2050 .and_then(Value::as_str)
2051 .unwrap_or_default(),
2052 entry
2053 .get("revision")
2054 .and_then(Value::as_str)
2055 .unwrap_or_default(),
2056 )
2057 })
2058 .collect::<Vec<_>>();
2059 assert_eq!(
2060 revisions,
2061 vec![
2062 ("selectionPolicy", "policy-v1"),
2063 ("targets", "read-only-v1"),
2064 ("nativeCapabilities", "v0.0.1"),
2065 ("providerModelPricing", "catalog-v1"),
2066 ("agentIntelligenceCatalog", "catalog-v1"),
2067 ("skillHub", "request-snapshot-v1"),
2068 ("assistantWorkflowAuthoringBundle", "v1"),
2069 ],
2070 "the published source revisions changed",
2071 );
2072 let candidates = receipt
2073 .get("candidates")
2074 .and_then(Value::as_array)
2075 .expect("the candidates are a list");
2076 assert_eq!(candidates.len(), 1);
2077 assert_eq!(
2078 candidates[0]
2079 .as_object()
2080 .expect("one candidate is an object")
2081 .keys()
2082 .cloned()
2083 .collect::<BTreeSet<_>>(),
2084 BTreeSet::from([
2085 "authority".to_owned(),
2086 "capabilities".to_owned(),
2087 "codingScore".to_owned(),
2088 "environment".to_owned(),
2089 "inputPriceUsdPerMillionTokens".to_owned(),
2090 "intelligence".to_owned(),
2091 "latencyClass".to_owned(),
2092 "membershipId".to_owned(),
2093 "model".to_owned(),
2094 "outputPriceUsdPerMillionTokens".to_owned(),
2095 "profileRevision".to_owned(),
2096 "readiness".to_owned(),
2097 "reliabilityClass".to_owned(),
2098 "responsibility".to_owned(),
2099 "skills".to_owned(),
2100 "taskTags".to_owned(),
2101 ]),
2102 "the published candidate projection changed",
2103 );
2104 }
2105
2106 #[test]
2110 fn port_surface_is_published_from_the_crate_root() {
2111 let published = CRATE_ROOT
2112 .lines()
2113 .skip_while(|line| !line.contains("pub use ports::{"))
2114 .skip(1)
2115 .take_while(|line| !line.contains("};"))
2116 .flat_map(|line| line.split(','))
2117 .map(|name| name.trim().to_owned())
2118 .filter(|name| !name.is_empty())
2119 .collect::<BTreeSet<_>>();
2120 assert_eq!(
2121 published,
2122 ROOT_REEXPORTED_PORT_ITEMS
2123 .iter()
2124 .map(|name| (*name).to_owned())
2125 .collect::<BTreeSet<_>>(),
2126 "the crate root re-exports of the workflow port surface changed",
2127 );
2128 let host_facing = scan_port_declarations(PORT_SOURCE)
2129 .into_iter()
2130 .filter(|declaration| {
2131 !UNPUBLISHED_PORT_ITEMS.contains(&declaration.item.render().as_str())
2132 })
2133 .map(|declaration| declaration.item.name)
2134 .collect::<BTreeSet<_>>();
2135 let mut missing = host_facing
2136 .difference(&published)
2137 .cloned()
2138 .collect::<Vec<_>>();
2139 missing.sort();
2140 assert!(
2141 missing.is_empty(),
2142 "these port items are not reachable from the crate root: {missing:?}",
2143 );
2144 }
2145}