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