gemini_adk_rs/orchestration/
mod.rs1use std::future::Future;
20use std::sync::Arc;
21
22use serde_json::Value;
23
24use crate::AsyncSourceFn;
25use crate::error::AgentError;
26use crate::llm::{BaseLlm, LlmRequest};
27use crate::state::State;
28use crate::text::TextAgent;
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum AgentMode {
33 Call,
36 Dispatch,
38 Background,
41}
42
43pub fn result_key(name: &str) -> String {
45 format!("{name}:result")
46}
47
48pub fn error_key(name: &str) -> String {
50 format!("{name}:error")
51}
52
53pub fn provenance(state: &State, key: &str) -> Option<String> {
56 state
57 .get::<serde_json::Value>(&format!("state_meta:{key}"))
58 .and_then(|m| m.get("source").and_then(|s| s.as_str().map(String::from)))
59}
60
61pub async fn call_agent(
68 name: &str,
69 agent: Arc<dyn TextAgent>,
70 state: &State,
71) -> Result<String, AgentError> {
72 let result = agent.run(state).await;
73 match &result {
74 Ok(r) => {
75 let key = result_key(name);
76 let _ = state.set(
77 format!("state_meta:{key}"),
78 serde_json::json!({ "source": "agent", "resolver": name }),
79 );
80 let _ = state.set(key, r);
81 }
82 Err(e) => {
83 let _ = state.set(error_key(name), e.to_string());
84 }
85 }
86 result
87}
88
89enum Source {
90 Agent(Arc<dyn TextAgent>),
92 Fetch(AsyncSourceFn),
95 Llm {
97 llm: Arc<dyn BaseLlm>,
99 prompt: String,
101 },
102}
103
104fn interpolate(template: &str, state: &State) -> String {
106 let mut out = String::with_capacity(template.len());
107 let mut rest = template;
108 while let Some(open) = rest.find('{') {
109 out.push_str(&rest[..open]);
110 let after = &rest[open + 1..];
111 let Some(close) = after.find('}') else {
112 out.push_str(&rest[open..]);
113 return out;
114 };
115 let key = after[..close].trim();
116 match state.get::<serde_json::Value>(key) {
117 Some(serde_json::Value::String(s)) => out.push_str(&s),
118 Some(v) => out.push_str(&v.to_string()),
119 None => {}
120 }
121 rest = &after[close + 1..];
122 }
123 out.push_str(rest);
124 out
125}
126
127pub struct Resolver {
139 name: String,
140 source: Source,
141}
142
143impl Resolver {
144 pub fn agent(name: impl Into<String>, agent: Arc<dyn TextAgent>) -> Self {
146 Self {
147 name: name.into(),
148 source: Source::Agent(agent),
149 }
150 }
151
152 pub fn fetch<F, Fut>(name: impl Into<String>, f: F) -> Self
156 where
157 F: Fn(State) -> Fut + Send + Sync + 'static,
158 Fut: Future<Output = Result<Value, String>> + Send + 'static,
159 {
160 let f = Arc::new(f);
161 Self {
162 name: name.into(),
163 source: Source::Fetch(Arc::new(move |state| {
164 let f = f.clone();
165 Box::pin(async move { f(state).await })
166 })),
167 }
168 }
169
170 pub fn llm(name: impl Into<String>, llm: Arc<dyn BaseLlm>, prompt: impl Into<String>) -> Self {
173 Self {
174 name: name.into(),
175 source: Source::Llm {
176 llm,
177 prompt: prompt.into(),
178 },
179 }
180 }
181
182 pub fn name(&self) -> &str {
184 &self.name
185 }
186
187 fn source_kind(&self) -> &'static str {
189 match &self.source {
190 Source::Agent(_) => "agent",
191 Source::Fetch(_) => "fetch",
192 Source::Llm { .. } => "llm",
193 }
194 }
195
196 pub async fn resolve(&self, state: &State) -> Result<Value, String> {
200 let outcome = match &self.source {
201 Source::Agent(a) => a
202 .run(state)
203 .await
204 .map(Value::from)
205 .map_err(|e| e.to_string()),
206 Source::Fetch(f) => f(state.clone()).await,
207 Source::Llm { llm, prompt } => {
208 let rendered = interpolate(prompt, state);
209 llm.generate(LlmRequest::from_text(rendered))
210 .await
211 .map(|r| Value::from(r.text()))
212 .map_err(|e| e.to_string())
213 }
214 };
215 match &outcome {
216 Ok(v) => {
217 let key = result_key(&self.name);
218 let _ = state.set(
219 format!("state_meta:{key}"),
220 serde_json::json!({ "source": self.source_kind(), "resolver": self.name }),
221 );
222 let _ = state.set(key, v.clone());
223 }
224 Err(e) => {
225 let _ = state.set(error_key(&self.name), e);
226 }
227 }
228 outcome
229 }
230
231 pub fn dispatch(self, state: State) {
235 tokio::spawn(async move {
236 let _ = self.resolve(&state).await;
237 });
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use async_trait::async_trait;
245 use serde_json::json;
246
247 struct Echo(&'static str);
248 #[async_trait]
249 impl TextAgent for Echo {
250 fn name(&self) -> &str {
251 "echo"
252 }
253 async fn run(&self, _state: &State) -> Result<String, AgentError> {
254 Ok(self.0.to_string())
255 }
256 }
257
258 struct Boom;
259 #[async_trait]
260 impl TextAgent for Boom {
261 fn name(&self) -> &str {
262 "boom"
263 }
264 async fn run(&self, _state: &State) -> Result<String, AgentError> {
265 Err(AgentError::Other("kaboom".into()))
266 }
267 }
268
269 #[tokio::test]
270 async fn call_writes_result_to_state() {
271 let state = State::new();
272 let out = call_agent("verify", Arc::new(Echo("ok-123")), &state)
273 .await
274 .unwrap();
275 assert_eq!(out, "ok-123");
276 assert_eq!(
277 state.get::<String>("verify:result").as_deref(),
278 Some("ok-123")
279 );
280 }
281
282 #[tokio::test]
283 async fn call_writes_error_to_state() {
284 let state = State::new();
285 let r = call_agent("verify", Arc::new(Boom), &state).await;
286 assert!(r.is_err());
287 assert!(state.contains("verify:error"));
288 assert!(!state.contains("verify:result"));
289 }
290
291 #[tokio::test]
292 async fn resolver_fetch_binds_state_and_writes_result() {
293 let state = State::new();
294 let _ = state.set("slot", "afternoon");
295 let r = Resolver::fetch("availability", |s: State| async move {
296 let slot = s.get::<String>("slot").unwrap_or_default();
298 Ok(json!({ "open": slot == "afternoon" }))
299 });
300 let out = r.resolve(&state).await.unwrap();
301 assert_eq!(out, json!({ "open": true }));
302 assert_eq!(
303 state.get::<Value>("availability:result"),
304 Some(json!({ "open": true }))
305 );
306 assert_eq!(
308 provenance(&state, "availability:result").as_deref(),
309 Some("fetch")
310 );
311 }
312
313 #[tokio::test]
314 async fn resolver_agent_uses_result_convention() {
315 let state = State::new();
316 Resolver::agent("verify", Arc::new(Echo("ok-9")))
318 .resolve(&state)
319 .await
320 .unwrap();
321 assert_eq!(
322 state.get::<String>("verify:result").as_deref(),
323 Some("ok-9")
324 );
325 }
326
327 #[tokio::test]
328 async fn resolver_fetch_records_error() {
329 let state = State::new();
330 let r = Resolver::fetch("lookup", |_s: State| async move {
331 Err::<Value, String>("upstream 503".into())
332 });
333 assert!(r.resolve(&state).await.is_err());
334 assert_eq!(
335 state.get::<String>("lookup:error").as_deref(),
336 Some("upstream 503")
337 );
338 assert!(!state.contains("lookup:result"));
339 }
340
341 #[tokio::test]
342 async fn resolver_llm_interpolates_prompt_and_stores_text() {
343 use crate::llm::{LlmError, LlmResponse};
344 use gemini_genai_rs::prelude::Content;
345
346 struct EchoLlm;
347 #[async_trait]
348 impl BaseLlm for EchoLlm {
349 fn model_id(&self) -> &str {
350 "echo"
351 }
352 async fn generate(&self, request: LlmRequest) -> Result<LlmResponse, LlmError> {
353 let prompt = request.contents[0].parts.iter().find_map(|p| match p {
355 gemini_genai_rs::prelude::Part::Text { text } => Some(text.clone()),
356 _ => None,
357 });
358 Ok(LlmResponse {
359 content: Content::model(prompt.unwrap_or_default()),
360 finish_reason: None,
361 usage: None,
362 })
363 }
364 }
365
366 let state = State::new();
367 let _ = state.set("topic", "billing");
368 let out = Resolver::llm("summary", Arc::new(EchoLlm), "Summarize the {topic} issue")
369 .resolve(&state)
370 .await
371 .unwrap();
372 assert_eq!(out, json!("Summarize the billing issue"));
373 assert_eq!(
374 state.get::<String>("summary:result").as_deref(),
375 Some("Summarize the billing issue")
376 );
377 }
378
379 #[tokio::test]
380 async fn resolver_dispatch_runs_detached() {
381 let state = State::new();
382 Resolver::fetch("ping", |_s: State| async move { Ok(json!("pong")) })
383 .dispatch(state.clone());
384 for _ in 0..100 {
386 if state.contains("ping:result") {
387 break;
388 }
389 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
390 }
391 assert_eq!(state.get::<String>("ping:result").as_deref(), Some("pong"));
392 }
393}