telos_agent/orchestration/subagent/
fork.rs1use futures_util::future::join_all;
10use serde_json::Value;
11use std::sync::Arc;
12use tokio::sync::Semaphore;
13
14use crate::config::AgentConfig;
15use crate::knowledge::tasks::{Task, TaskManager, TaskStatus};
16use crate::model::message::Message;
17use crate::model::provider::{CompletionRequest, ModelProvider};
18use crate::tools::api::ToolRegistry;
19
20pub struct ForkShared {
22 pub provider: Arc<dyn ModelProvider + Send + Sync>,
23 pub tool_registry: ToolRegistry,
24 pub messages: Arc<Vec<Message>>,
25 pub config: AgentConfig,
26}
27
28#[derive(Debug, Clone)]
30pub struct ForkLens {
31 pub lens: String,
33 pub system_prompt: String,
35 pub task: String,
37 pub output_schema: Option<Value>,
39 pub allowed_tools: Vec<String>,
41}
42
43#[derive(Debug, Clone)]
45pub enum ForkResult {
46 Text(String),
47 Structured(Value),
48}
49
50pub struct ForkExecution {
52 pub results: Vec<Option<ForkResult>>,
53 pub task_ids: Vec<String>,
54}
55
56pub struct Synapse {
58 semaphore: Arc<Semaphore>,
59}
60
61impl Synapse {
62 pub fn new(max_concurrent: usize) -> Self {
63 Synapse { semaphore: Arc::new(Semaphore::new(max_concurrent.max(1))) }
64 }
65
66 pub async fn run_all(
71 &self,
72 shared: &ForkShared,
73 lenses: Vec<ForkLens>,
74 task_manager: Option<&TaskManager>,
75 ) -> ForkExecution {
76 let task_ids: Vec<String> = if let Some(tm) = &task_manager {
77 lenses
78 .iter()
79 .map(|lens| {
80 let id = uuid_v4();
81 let task = Task {
82 id: id.clone(),
83 subject: format!("fork lens: {}", lens.lens),
84 description: lens.task.clone(),
85 status: TaskStatus::InProgress,
86 blocked_by: vec![],
87 blocks: vec![],
88 output: None,
89 kind: None,
90 agent_id: None,
91 agent_type: None,
92 worktree_path: None,
93 error: None,
94 };
95 tm.create(task);
96 id
97 })
98 .collect()
99 } else {
100 vec![]
101 };
102
103 let results = join_all(lenses.into_iter().map(|lens| {
104 let sem = self.semaphore.clone();
105 let shared = shared.clone();
106 async move {
107 let _permit = sem.acquire().await.ok()?;
108 execute_lens(&shared, &lens).await
109 }
110 }))
111 .await;
112
113 if let Some(tm) = &task_manager {
115 for (i, _result) in results.iter().enumerate() {
116 let status = TaskStatus::Completed;
117 if let Some(task_id) = task_ids.get(i) {
118 tm.update(task_id, status);
119 }
120 }
121 }
122
123 ForkExecution { results, task_ids }
124 }
125}
126
127fn uuid_v4() -> String {
129 use std::time::{SystemTime, UNIX_EPOCH};
130 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
131 format!("task_{:x}", now.as_nanos())
132}
133
134impl Clone for ForkShared {
135 fn clone(&self) -> Self {
136 Self {
137 provider: self.provider.clone(),
138 tool_registry: self.tool_registry.clone(),
139 messages: self.messages.clone(),
140 config: self.config.clone(),
141 }
142 }
143}
144
145async fn execute_lens(shared: &ForkShared, lens: &ForkLens) -> Option<ForkResult> {
147 let context_summary = build_context_summary(&shared.messages);
149
150 let fork_messages = vec![
151 Message::system(&lens.system_prompt),
152 Message::user(format!("## Context\n\n{context_summary}\n\n## Task\n\n{}", lens.task)),
153 ];
154
155 let tools = if lens.allowed_tools.is_empty() {
157 shared.tool_registry.definitions()
158 } else {
159 shared
160 .tool_registry
161 .definitions()
162 .into_iter()
163 .filter(|d| lens.allowed_tools.contains(&d.name))
164 .collect()
165 };
166
167 let request = CompletionRequest {
168 system_prompt_blocks: vec![], messages: fork_messages,
170 tools,
171 model_hint: None,
172 max_tokens: None,
173 };
174
175 let response = match shared.provider.complete(request).await {
176 Ok(r) => r,
177 Err(_) => return None,
178 };
179
180 let text = response.message.text_content();
181
182 if let Some(_schema) = &lens.output_schema {
184 if let Ok(val) = serde_json::from_str::<Value>(&text) {
185 Some(ForkResult::Structured(val))
186 } else {
187 Some(ForkResult::Text(text))
189 }
190 } else {
191 Some(ForkResult::Text(text))
192 }
193}
194
195fn build_context_summary(messages: &[Message]) -> String {
197 let recent: Vec<&Message> = messages.iter().rev().take(6).collect();
198 if recent.is_empty() {
199 return String::new();
200 }
201 let mut parts = vec!["## Recent Conversation".to_string()];
202 for msg in recent.iter().rev() {
203 let text = msg.text_content();
204 if text.is_empty() {
205 continue;
206 }
207 let truncated: String = text.chars().take(2000).collect();
208 parts.push(format!("[{}]: {}", role_str(&msg.role), truncated));
209 }
210 parts.join("\n")
211}
212
213fn role_str(role: &crate::model::message::Role) -> &str {
214 match role {
215 crate::model::message::Role::System => "system",
216 crate::model::message::Role::User => "user",
217 crate::model::message::Role::Assistant => "assistant",
218 crate::model::message::Role::Tool => "tool",
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use crate::config::AgentConfig;
226 use crate::model::mock::MockProvider;
227
228 #[tokio::test]
229 async fn synapse_runs_multiple_lenses_concurrently() {
230 let provider = Arc::new(MockProvider::new(vec![]));
231 let tool_registry = ToolRegistry::new();
232 let messages = Arc::new(vec![Message::user("Original request")]);
233
234 let shared = ForkShared {
235 provider,
236 tool_registry: tool_registry.clone(),
237 messages,
238 config: AgentConfig::default(),
239 };
240
241 let lenses = vec![
242 ForkLens {
243 lens: "a".into(),
244 system_prompt: "You are lens A".into(),
245 task: "Task A".into(),
246 output_schema: None,
247 allowed_tools: vec![],
248 },
249 ForkLens {
250 lens: "b".into(),
251 system_prompt: "You are lens B".into(),
252 task: "Task B".into(),
253 output_schema: None,
254 allowed_tools: vec![],
255 },
256 ];
257
258 let synapse = Synapse::new(2);
259 let execution = synapse.run_all(&shared, lenses, None).await;
260 assert_eq!(execution.results.len(), 2);
261 assert!(execution.results.iter().all(|r| r.is_none()));
263 }
264
265 #[test]
266 fn build_context_summary_truncates_long_messages() {
267 let long_text = "x".repeat(5000);
268 let msgs = vec![Message::user(&long_text), Message::assistant("short response")];
269 let summary = build_context_summary(&msgs);
270 assert!(summary.contains("short response"));
271 assert!(!summary.contains(&long_text));
272 }
273}