bamboo_engine/external_agents/
config.rs1use serde::Deserialize;
2use std::collections::HashMap;
3
4use bamboo_llm::Config;
5
6#[derive(Debug, Clone, Deserialize)]
7pub struct ExternalAgentProfile {
8 pub agent_id: String,
9 pub protocol: ExternalAgentProtocol,
10 #[serde(default, skip_serializing_if = "Option::is_none")]
11 pub agent_card_url: Option<String>,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 pub rpc_url_override: Option<String>,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub auth_ref: Option<String>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub tenant: Option<String>,
18 pub permission_profile: String,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub skill: Option<String>,
21 #[serde(default)]
22 pub allow_non_streaming_fallback: bool,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub worker_bin: Option<String>,
26 #[serde(default)]
30 pub worker_args: Vec<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub fabric_dir: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub executor: Option<String>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub enum ExternalAgentProtocol {
44 #[serde(rename = "a2a_jsonrpc")]
45 A2aJsonRpc,
46 #[serde(rename = "actor", alias = "subprocess")]
48 Actor,
49}
50
51#[derive(Debug, Clone, Deserialize)]
52pub struct SubagentRouting {
53 pub runtime: String, #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub agent_id: Option<String>,
56}
57
58pub fn parse_external_agents(config: &Config) -> HashMap<String, ExternalAgentProfile> {
60 let Some(value) = config.extra.get("externalAgents") else {
61 return HashMap::new();
62 };
63
64 match serde_json::from_value(value.clone()) {
65 Ok(agents) => agents,
66 Err(error) => {
67 tracing::error!(
68 "Invalid externalAgents config; external agent routing disabled: {}",
69 error
70 );
71 HashMap::new()
72 }
73 }
74}
75
76pub fn parse_subagent_routing(config: &Config) -> HashMap<String, SubagentRouting> {
78 let Some(value) = config.extra.get("subagentRouting") else {
79 return HashMap::new();
80 };
81
82 match serde_json::from_value(value.clone()) {
83 Ok(routing) => routing,
84 Err(error) => {
85 tracing::error!(
86 "Invalid subagentRouting config; external subagent routing disabled: {}",
87 error
88 );
89 HashMap::new()
90 }
91 }
92}
93
94pub const LOCAL_ACTOR_AGENT_ID: &str = "local-actor";
97
98pub fn resolve_runtime_metadata(config: &Config, subagent_type: &str) -> HashMap<String, String> {
107 let local_actor_metadata = || {
108 HashMap::from([
109 ("runtime.kind".to_string(), "external".to_string()),
110 ("external.protocol".to_string(), "actor".to_string()),
111 (
112 "external.agent_id".to_string(),
113 LOCAL_ACTOR_AGENT_ID.to_string(),
114 ),
115 ])
116 };
117
118 let routing = parse_subagent_routing(config);
119 let agents = parse_external_agents(config);
120
121 if let Some(route) = routing.get(subagent_type) {
123 if route.runtime == "external" {
124 let mut metadata = HashMap::new();
125 metadata.insert("runtime.kind".to_string(), "external".to_string());
126
127 if let Some(agent_id) = &route.agent_id {
128 metadata.insert("external.agent_id".to_string(), agent_id.clone());
129
130 if let Some(profile) = agents.get(agent_id) {
131 metadata.insert(
132 "external.protocol".to_string(),
133 match profile.protocol {
134 ExternalAgentProtocol::A2aJsonRpc => "a2a_jsonrpc".to_string(),
135 ExternalAgentProtocol::Actor => "actor".to_string(),
136 },
137 );
138 metadata.insert(
139 "external.permission_profile".to_string(),
140 profile.permission_profile.clone(),
141 );
142 if let Some(url) = &profile.agent_card_url {
143 metadata.insert("external.agent_card_url".to_string(), url.clone());
144 }
145 }
146 }
147
148 return metadata;
149 }
150 }
153
154 local_actor_metadata()
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn parse_external_agents_from_config_extra() {
164 let mut config = Config::default();
165 config.extra.insert(
166 "externalAgents".to_string(),
167 serde_json::json!({
168 "remote_impl": {
169 "agent_id": "remote_impl",
170 "protocol": "a2a_jsonrpc",
171 "agent_card_url": "https://example.com/agent-card.json",
172 "auth_ref": "REMOTE_IMPL_TOKEN",
173 "permission_profile": "remote_limited"
174 }
175 }),
176 );
177
178 let agents = parse_external_agents(&config);
179 assert_eq!(agents.len(), 1);
180 let profile = agents.get("remote_impl").unwrap();
181 assert_eq!(profile.agent_id, "remote_impl");
182 assert!(matches!(
183 profile.protocol,
184 ExternalAgentProtocol::A2aJsonRpc
185 ));
186 assert_eq!(
187 profile.agent_card_url,
188 Some("https://example.com/agent-card.json".to_string())
189 );
190 assert_eq!(profile.auth_ref, Some("REMOTE_IMPL_TOKEN".to_string()));
191 }
192
193 #[test]
194 fn parse_subagent_routing_from_config_extra() {
195 let mut config = Config::default();
196 config.extra.insert(
197 "subagentRouting".to_string(),
198 serde_json::json!({
199 "impl": { "runtime": "external", "agent_id": "remote_impl" },
200 "plan": { "runtime": "bamboo" }
201 }),
202 );
203
204 let routing = parse_subagent_routing(&config);
205 assert_eq!(routing.len(), 2);
206 assert_eq!(routing.get("impl").unwrap().runtime, "external");
207 assert_eq!(
208 routing.get("impl").unwrap().agent_id,
209 Some("remote_impl".to_string())
210 );
211 assert_eq!(routing.get("plan").unwrap().runtime, "bamboo");
212 }
213
214 #[test]
215 fn resolve_runtime_metadata_routes_impl_to_external() {
216 let mut config = Config::default();
217 config.extra.insert(
218 "externalAgents".to_string(),
219 serde_json::json!({
220 "remote_impl": {
221 "agent_id": "remote_impl",
222 "protocol": "a2a_jsonrpc",
223 "permission_profile": "remote_limited"
224 }
225 }),
226 );
227 config.extra.insert(
228 "subagentRouting".to_string(),
229 serde_json::json!({
230 "impl": { "runtime": "external", "agent_id": "remote_impl" }
231 }),
232 );
233
234 let metadata = resolve_runtime_metadata(&config, "impl");
235 assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
236 assert_eq!(
237 metadata.get("external.protocol"),
238 Some(&"a2a_jsonrpc".to_string())
239 );
240 assert_eq!(
241 metadata.get("external.agent_id"),
242 Some(&"remote_impl".to_string())
243 );
244 }
245
246 #[test]
247 fn unknown_type_defaults_to_local_actor() {
248 let mut config = Config::default();
251 config.subagents = Default::default();
252 let metadata = resolve_runtime_metadata(&config, "unknown");
253 assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
254 assert_eq!(
255 metadata.get("external.protocol"),
256 Some(&"actor".to_string())
257 );
258 assert_eq!(
259 metadata.get("external.agent_id"),
260 Some(&LOCAL_ACTOR_AGENT_ID.to_string())
261 );
262 }
263
264 #[test]
265 fn every_type_defaults_to_local_actor() {
266 let config = Config::default();
267 let metadata = resolve_runtime_metadata(&config, "researcher");
268 assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
269 assert_eq!(
270 metadata.get("external.protocol"),
271 Some(&"actor".to_string())
272 );
273 assert_eq!(
274 metadata.get("external.agent_id"),
275 Some(&LOCAL_ACTOR_AGENT_ID.to_string())
276 );
277 }
278
279 #[test]
280 fn legacy_bamboo_routing_falls_through_to_local_actor() {
281 let mut config = Config::default();
284 config.extra.insert(
285 "subagentRouting".to_string(),
286 serde_json::json!({
287 "plan": { "runtime": "bamboo" }
288 }),
289 );
290
291 let metadata = resolve_runtime_metadata(&config, "plan");
292 assert_eq!(
293 metadata.get("external.agent_id"),
294 Some(&LOCAL_ACTOR_AGENT_ID.to_string())
295 );
296 }
297
298 #[test]
299 fn legacy_external_routing_selects_remote_agent() {
300 let mut config = Config::default();
301 config.extra.insert(
302 "externalAgents".to_string(),
303 serde_json::json!({
304 "remote_impl": {
305 "agent_id": "remote_impl",
306 "protocol": "a2a_jsonrpc",
307 "permission_profile": "remote_limited"
308 }
309 }),
310 );
311 config.extra.insert(
312 "subagentRouting".to_string(),
313 serde_json::json!({
314 "impl": { "runtime": "external", "agent_id": "remote_impl" }
315 }),
316 );
317
318 let metadata = resolve_runtime_metadata(&config, "impl");
320 assert_eq!(
321 metadata.get("external.agent_id"),
322 Some(&"remote_impl".to_string())
323 );
324 }
325}