Skip to main content

systemprompt_agent/services/a2a_server/processing/
ai_executor.rs

1//! Direct AI-provider calls for the processing pipeline.
2//!
3//! [`process_without_tools`] streams a plain generation, and
4//! [`synthesize_tool_results_with_artifacts`] asks the model for a brief
5//! conversational summary after tools have run. Both resolve
6//! provider/model/token settings from the request context and agent runtime
7//! before calling the provider.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use futures::StreamExt;
13use std::sync::Arc;
14use tokio::sync::mpsc;
15
16use crate::models::a2a::Artifact;
17use crate::services::SkillService;
18use systemprompt_models::{
19    AiMessage, AiProvider, AiRequest, CallToolResult, MessageRole, RequestContext, StreamChunk,
20    ToolCall, ToolResultFormatter,
21};
22
23use super::message::StreamEvent;
24use crate::models::AgentRuntimeInfo;
25
26pub fn resolve_provider_config(
27    request_context: &RequestContext,
28    agent_runtime: &AgentRuntimeInfo,
29    ai_service: &dyn AiProvider,
30) -> (String, String, u32) {
31    let tool_config = request_context.tool_model_config();
32
33    let provider = tool_config
34        .and_then(|c| c.provider.as_deref())
35        .or(agent_runtime.provider.as_deref())
36        .unwrap_or_else(|| ai_service.default_provider())
37        .to_owned();
38    let model = tool_config
39        .and_then(|c| c.model.as_deref())
40        .or(agent_runtime.model.as_deref())
41        .unwrap_or_else(|| ai_service.default_model())
42        .to_owned();
43    let max_tokens = tool_config
44        .and_then(|c| c.max_output_tokens)
45        .or(agent_runtime.max_output_tokens)
46        .unwrap_or_else(|| ai_service.default_max_output_tokens());
47
48    if tool_config.is_some() {
49        tracing::debug!(
50            provider,
51            model,
52            max_output_tokens = max_tokens,
53            "Using tool_model_config"
54        );
55    }
56
57    (provider, model, max_tokens)
58}
59
60#[expect(
61    missing_debug_implementations,
62    reason = "params struct holds non-Debug references"
63)]
64pub struct SynthesizeToolResultsParams<'a> {
65    pub ai_service: Arc<dyn AiProvider>,
66    pub agent_runtime: &'a AgentRuntimeInfo,
67    pub original_messages: Vec<AiMessage>,
68    pub initial_response: &'a str,
69    pub tool_calls: &'a [ToolCall],
70    pub tool_results: &'a [CallToolResult],
71    pub artifacts: &'a [Artifact],
72    pub tx: mpsc::Sender<StreamEvent>,
73    pub request_context: RequestContext,
74    pub skill_service: Arc<SkillService>,
75}
76
77pub async fn synthesize_tool_results_with_artifacts(
78    params: SynthesizeToolResultsParams<'_>,
79) -> Result<String, ()> {
80    let SynthesizeToolResultsParams {
81        ai_service,
82        agent_runtime,
83        original_messages,
84        initial_response,
85        tool_calls,
86        tool_results,
87        artifacts,
88        tx,
89        request_context,
90        skill_service: _skill_service,
91    } = params;
92    let tool_results_context = ToolResultFormatter::format_for_synthesis(tool_calls, tool_results);
93    let artifact_references = build_artifact_references(artifacts);
94
95    let synthesis_prompt = build_synthesis_prompt(
96        tool_calls.len(),
97        &tool_results_context,
98        &artifact_references,
99    );
100
101    let mut synthesis_messages = original_messages;
102    synthesis_messages.push(AiMessage {
103        role: MessageRole::Assistant,
104        content: initial_response.to_owned(),
105        parts: Vec::new(),
106    });
107    synthesis_messages.push(AiMessage {
108        role: MessageRole::User,
109        content: synthesis_prompt,
110        parts: Vec::new(),
111    });
112
113    tracing::info!(
114        tool_result_count = tool_results.len(),
115        "Calling AI to synthesize tool results"
116    );
117
118    let (provider, model, max_output_tokens) =
119        resolve_provider_config(&request_context, agent_runtime, ai_service.as_ref());
120
121    let synthesis_request = AiRequest::builder(
122        synthesis_messages,
123        &provider,
124        &model,
125        max_output_tokens,
126        request_context,
127    )
128    .build();
129
130    match ai_service.generate(&synthesis_request).await {
131        Ok(response) => {
132            let synthesized_text = response.content;
133
134            tracing::info!(text_len = synthesized_text.len(), "Synthesis complete");
135
136            if tx
137                .try_send(StreamEvent::Text(synthesized_text.clone()))
138                .is_err()
139            {
140                tracing::debug!("Stream receiver dropped during synthesis");
141            }
142
143            Ok(synthesized_text)
144        },
145        Err(e) => {
146            tracing::error!(error = %e, "Synthesis failed");
147            Err(())
148        },
149    }
150}
151
152pub async fn process_without_tools(
153    ai_service: Arc<dyn AiProvider>,
154    agent_runtime: &AgentRuntimeInfo,
155    ai_messages: Vec<AiMessage>,
156    tx: mpsc::Sender<StreamEvent>,
157    request_context: RequestContext,
158) -> Result<(String, Vec<ToolCall>, Vec<CallToolResult>), ()> {
159    let (provider, model, max_output_tokens) =
160        resolve_provider_config(&request_context, agent_runtime, ai_service.as_ref());
161
162    let generate_request = AiRequest::builder(
163        ai_messages,
164        &provider,
165        &model,
166        max_output_tokens,
167        request_context,
168    )
169    .build();
170
171    match ai_service.generate_stream(&generate_request).await {
172        Ok(mut stream) => {
173            let mut accumulated_text = String::new();
174            while let Some(chunk) = stream.next().await {
175                match chunk {
176                    Ok(StreamChunk::Text(text)) => {
177                        accumulated_text.push_str(&text);
178                        if tx.try_send(StreamEvent::Text(text)).is_err() {
179                            tracing::debug!("Stream receiver dropped during generation");
180                        }
181                    },
182                    Ok(StreamChunk::Usage { .. }) => {},
183                    Err(e) => {
184                        if tx.try_send(StreamEvent::Error(e.to_string())).is_err() {
185                            tracing::debug!("Stream receiver dropped while sending error");
186                        }
187                        return Err(());
188                    },
189                }
190            }
191            Ok((accumulated_text, Vec::new(), Vec::new()))
192        },
193        Err(e) => {
194            if tx.try_send(StreamEvent::Error(e.to_string())).is_err() {
195                tracing::debug!("Stream receiver dropped while sending error");
196            }
197            Err(())
198        },
199    }
200}
201
202fn build_synthesis_prompt(
203    tool_count: usize,
204    tool_results_context: &str,
205    artifact_references: &str,
206) -> String {
207    format!(
208        r#"# Tool Execution Complete
209
210You executed {} tool(s). Now provide a BRIEF conversational response.
211
212## Tool Results Summary
213
214{}
215
216## Artifacts Created
217
218{}
219
220## CRITICAL RULES - READ CAREFULLY
221
2221. **NEVER repeat artifact content** - The user sees artifacts separately. Your message should REFERENCE them, never duplicate their content.
2232. **Maximum 100 words** - Be extremely concise. 2-3 sentences is ideal.
2243. **Describe what was done, not what it contains** - Say "I've created a blog post about X" NOT "Here is the blog post: [full content]"
2254. **Be conversational** - Natural, friendly summary. Not a report or transcript.
2265. **Reference artifacts naturally** - Use format like "(see the artifact for the full content)"
227
228## BAD EXAMPLE (DO NOT DO THIS)
229"I've created your blog post. Here's the content:
230
231[2000 words of article text]
232
233Let me know if you'd like any changes."
234
235## GOOD EXAMPLE
236"Done! I've created a blog post exploring the Human-AI collaboration workflow. The article covers the key differences between automation and augmentation approaches, with practical steps for maintaining your authentic voice. Take a look at the artifact and let me know if you'd like any adjustments."
237
238---
239
240Provide your brief, conversational response now. Remember: the artifact has the content - your message is just the friendly summary."#,
241        tool_count, tool_results_context, artifact_references
242    )
243}
244
245fn build_artifact_references(artifacts: &[Artifact]) -> String {
246    if artifacts.is_empty() {
247        return "No artifacts were created.".to_owned();
248    }
249
250    artifacts
251        .iter()
252        .map(|artifact| {
253            let artifact_type = &artifact.metadata.artifact_type;
254            let artifact_name = artifact
255                .title
256                .clone()
257                .unwrap_or_else(|| artifact.id.to_string());
258
259            format!(
260                "- **{}** ({}): Reference as '(see {} for details)'",
261                artifact_name, artifact_type, artifact_name
262            )
263        })
264        .collect::<Vec<_>>()
265        .join("\n")
266}