embassy_github/
suggestor.rs1use std::sync::Arc;
15
16use async_trait::async_trait;
17use converge_pack::{
18 AgentEffect, Context, ContextKey, ExecutionIdentity, FactPayload, ProposedFact,
19 ProvenanceSource, Suggestor,
20};
21use serde::{Deserialize, Serialize};
22
23use crate::provenance::GITHUB_PROVENANCE;
24use crate::provider::{GithubProvider, GithubRequest};
25use crate::types::Organization;
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct GithubOrganizationPayload {
34 pub organization: Organization,
35 pub request_hash: String,
37 pub vendor: String,
38 pub latency_ms: u64,
39 pub execution_identity: ExecutionIdentity,
40}
41
42impl FactPayload for GithubOrganizationPayload {
43 const FAMILY: &'static str = "embassy.github.organization";
44 const VERSION: u16 = 1;
45}
46
47pub struct GithubLookupSuggestor<P: GithubProvider + 'static> {
48 provider: Arc<P>,
49}
50
51impl<P: GithubProvider + 'static> GithubLookupSuggestor<P> {
52 pub fn new(provider: Arc<P>) -> Self {
53 Self { provider }
54 }
55}
56
57#[async_trait]
58impl<P: GithubProvider + 'static> Suggestor for GithubLookupSuggestor<P> {
59 fn name(&self) -> &'static str {
60 "GithubLookupSuggestor"
61 }
62
63 fn dependencies(&self) -> &[ContextKey] {
64 &[ContextKey::Seeds]
65 }
66
67 fn provenance(&self) -> &'static str {
68 GITHUB_PROVENANCE.as_str()
69 }
70
71 fn accepts(&self, ctx: &dyn Context) -> bool {
72 ctx.get(ContextKey::Seeds)
73 .iter()
74 .any(|fact| fact.payload::<GithubRequest>().is_some())
75 }
76
77 async fn execute(&self, ctx: &dyn Context) -> AgentEffect {
78 let mut proposals = Vec::new();
79
80 for seed in ctx.get(ContextKey::Seeds) {
81 let Some(request) = seed.payload::<GithubRequest>() else {
82 continue;
83 };
84
85 let response = match self
86 .provider
87 .fetch(request, &embassy_pack::CallContext::default())
88 .await
89 {
90 Ok(resp) => resp,
91 Err(err) => {
92 tracing::warn!(
93 seed = %seed.id(),
94 provider = self.provider.name(),
95 error = %err,
96 "github fetch failed; skipping seed"
97 );
98 continue;
99 }
100 };
101
102 for (idx, observation) in response.records.into_iter().enumerate() {
103 let runtime_config = ExecutionIdentity::runtime_config_from_typed(request);
104 let execution_identity = ExecutionIdentity::non_native(
105 env!("CARGO_PKG_NAME"),
106 env!("CARGO_PKG_VERSION"),
107 self.provider.name().to_string(),
108 runtime_config,
109 );
110
111 let payload_value = GithubOrganizationPayload {
112 organization: observation.content,
113 request_hash: observation.request_hash,
114 vendor: observation.vendor,
115 latency_ms: observation.latency_ms,
116 execution_identity,
117 };
118
119 proposals.push(
120 ProposedFact::new(
121 ContextKey::Hypotheses,
122 format!("github:{}:{idx}", seed.id()),
123 payload_value,
124 GITHUB_PROVENANCE.as_str(),
125 )
126 .with_confidence(0.95),
127 );
128 }
129 }
130
131 if proposals.is_empty() {
132 AgentEffect::empty()
133 } else {
134 AgentEffect::with_proposals(proposals)
135 }
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::provider::StubGithubProvider;
143
144 #[test]
145 fn suggestor_declares_seeds_dependency() {
146 let s = GithubLookupSuggestor::new(Arc::new(StubGithubProvider));
151 assert_eq!(s.dependencies(), &[ContextKey::Seeds]);
152 }
153
154 #[test]
155 fn suggestor_provenance_is_canonical() {
156 let s = GithubLookupSuggestor::new(Arc::new(StubGithubProvider));
160 assert_eq!(s.provenance(), GITHUB_PROVENANCE.as_str());
161 }
162
163 #[test]
164 fn payload_family_and_version_are_stable() {
165 assert_eq!(
170 GithubOrganizationPayload::FAMILY,
171 "embassy.github.organization"
172 );
173 assert_eq!(GithubOrganizationPayload::VERSION, 1);
174 }
175}