1use std::collections::HashSet;
4use std::sync::{Arc, Mutex, MutexGuard};
5
6use anyhow::Result;
7use async_trait::async_trait;
8use futures::StreamExt;
9use goose_provider_types::base::Provider;
10use goose_provider_types::conversation::message::{InferenceMetadata, Message, MessageContent};
11use goose_provider_types::conversation::token_usage::ProviderUsage;
12use goose_provider_types::conversation::{
13 effective_role, fix_conversation, merge_consecutive_messages_for_request, Conversation,
14 EffectiveRole,
15};
16use goose_provider_types::errors::ProviderError;
17use goose_provider_types::model::ModelConfig;
18use tracing_futures::Instrument;
19
20use crate::machine::MachineSession;
21use crate::operation::{
22 applied, messages_since_kickoff, not_applicable, trailing_error, Emitter, Inference,
23 InferenceInput, Operation, OperationResult, CLIENT_LOG,
24};
25use goose_provider_types::maybe_send::{MaybeSend, MaybeSync};
26
27pub struct PreparedInferenceRequest {
28 pub system_prompt: String,
29 pub tools: Vec<rmcp::model::Tool>,
30 pub additional_messages: Vec<Message>,
31}
32
33#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
34#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
35pub trait InferenceRequestPreparer<S>: MaybeSend + MaybeSync {
36 async fn prepare_session(&self, _session: &S) -> Result<Option<S>> {
37 Ok(None)
38 }
39
40 async fn prepare(
41 &self,
42 session: &S,
43 conversation: &Conversation,
44 input: InferenceInput,
45 ) -> Result<PreparedInferenceRequest>;
46}
47
48pub struct IdentityInferenceRequestPreparer;
49
50#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
51#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
52impl<S: MaybeSync> InferenceRequestPreparer<S> for IdentityInferenceRequestPreparer {
53 async fn prepare(
54 &self,
55 _session: &S,
56 _conversation: &Conversation,
57 input: InferenceInput,
58 ) -> Result<PreparedInferenceRequest> {
59 Ok(PreparedInferenceRequest {
60 system_prompt: input
61 .prompt_parts
62 .into_iter()
63 .map(|(_, part)| part)
64 .collect::<Vec<_>>()
65 .join("\n\n"),
66 tools: input.tools,
67 additional_messages: Vec::new(),
68 })
69 }
70}
71
72pub trait InferenceEffect: From<Message> + MaybeSend + 'static {
73 fn record_usage(usage: ProviderUsage) -> Self;
74}
75
76pub const EMPTY_RESPONSE_MESSAGE: &str =
77 "The model returned an empty response. Please resend your message to continue.";
78const MAX_EMPTY_RESPONSE_RETRIES: usize = 3;
79const EMPTY_RESPONSE_NOTE_SCOPE: &str = "inference";
80const EMPTY_RESPONSE_NOTE: &str = "empty_response";
81
82pub fn is_empty_response_marker(message: &Message) -> bool {
86 message
87 .metadata
88 .operation_note(EMPTY_RESPONSE_NOTE_SCOPE, EMPTY_RESPONSE_NOTE)
89 .is_some()
90}
91
92fn is_thinking(content: &MessageContent) -> bool {
93 matches!(
94 content,
95 MessageContent::Thinking(_) | MessageContent::RedactedThinking(_)
96 )
97}
98
99fn drop_repeated_tool_call_thinking(accumulator: &Conversation, chunk: &mut Message) {
100 if !chunk
101 .content
102 .iter()
103 .any(|content| matches!(content, MessageContent::ToolRequest(_)))
104 {
105 return;
106 }
107 let prior: Vec<&MessageContent> = accumulator
108 .iter()
109 .filter(|message| message.role == chunk.role)
110 .flat_map(|message| message.content.iter())
111 .filter(|content| is_thinking(content))
112 .collect();
113 chunk
114 .content
115 .retain(|content| !(is_thinking(content) && prior.contains(&content)));
116}
117
118fn pending_operation_logs(messages: &[Message]) -> Vec<String> {
119 let mut seen = messages
120 .iter()
121 .flat_map(|message| message.metadata.operation_logs.iter().cloned())
122 .collect::<HashSet<_>>();
123 let mut logs = Vec::new();
124
125 for (operation, line) in messages
126 .iter()
127 .filter_map(|message| message.metadata.operations.as_deref())
128 .flat_map(|operations| operations.iter())
129 .filter_map(|(operation, notes)| {
130 notes
131 .get(CLIENT_LOG)
132 .and_then(serde_json::Value::as_str)
133 .map(|line| (operation, line))
134 })
135 {
136 let log = format!("ops_{operation}: {line}");
137 if seen.insert(log.clone()) {
138 logs.push(log);
139 }
140 }
141
142 logs
143}
144
145fn attach_operation_logs(message: &mut Message, logs: &mut Vec<String>) {
146 if message.role == rmcp::model::Role::Assistant && !logs.is_empty() {
147 message.metadata.operation_logs = std::mem::take(logs);
148 }
149}
150
151pub fn chat_span(
152 provider: &dyn Provider,
153 model_config: &ModelConfig,
154 session_id: &str,
155 purpose: &'static str,
156) -> tracing::Span {
157 let span = tracing::info_span!(
158 target: "goose::state_machine",
159 "chat",
160 "gen_ai.operation.name" = "chat",
161 "gen_ai.provider.name" = %provider.get_name(),
162 "gen_ai.request.model" = %model_config.model_name,
163 "gen_ai.request.temperature" = tracing::field::Empty,
164 "gen_ai.request.max_tokens" = tracing::field::Empty,
165 "gen_ai.response.model" = tracing::field::Empty,
166 "gen_ai.response.finish_reasons" = tracing::field::Empty,
167 "gen_ai.response.id" = tracing::field::Empty,
168 "gen_ai.request.id" = tracing::field::Empty,
169 "gen_ai.usage.input_tokens" = tracing::field::Empty,
170 "gen_ai.usage.output_tokens" = tracing::field::Empty,
171 "goose.chat.purpose" = purpose,
172 "error.type" = tracing::field::Empty,
173 session.id = %session_id,
174 );
175 record_request_params(&span, model_config);
176 span
177}
178
179fn is_empty_response(message: &Message) -> bool {
180 message.content.iter().all(|content| match content {
181 MessageContent::Text(text) => text.text.trim().is_empty(),
182 MessageContent::Thinking(thinking) => {
183 thinking.thinking.trim().is_empty() && thinking.signature.is_empty()
184 }
185 _ => false,
186 })
187}
188
189pub fn ends_with_successful_tool_response(messages: &[Message]) -> bool {
190 let Some(message) = messages.last() else {
191 return false;
192 };
193 let mut responses = message
194 .content
195 .iter()
196 .filter_map(MessageContent::as_tool_response)
197 .peekable();
198 responses.peek().is_some()
199 && responses.all(|response| {
200 response
201 .tool_result
202 .as_ref()
203 .is_ok_and(|result| !result.is_error.unwrap_or(false))
204 })
205}
206
207fn record_request_params(span: &tracing::Span, model_config: &ModelConfig) {
208 if let Some(temperature) = model_config.temperature {
209 span.record("gen_ai.request.temperature", temperature as f64);
210 }
211 if let Some(max_tokens) = model_config.max_tokens {
212 span.record("gen_ai.request.max_tokens", max_tokens as i64);
213 }
214}
215
216pub fn record_chat_usage(span: &tracing::Span, usage: &ProviderUsage) {
217 span.record("gen_ai.response.model", usage.model.as_str());
218 if let Some(tokens) = usage.usage.input_tokens {
219 span.record("gen_ai.usage.input_tokens", tokens);
220 }
221 if let Some(tokens) = usage.usage.output_tokens {
222 span.record("gen_ai.usage.output_tokens", tokens);
223 }
224 if let Some(tokens) = usage.usage.cache_read_input_tokens {
225 span.record("gen_ai.usage.cache_read.input_tokens", tokens);
226 }
227 if let Some(tokens) = usage.usage.cache_write_input_tokens {
228 span.record("gen_ai.usage.cache_creation.input_tokens", tokens);
229 }
230 if let Some(reasons) = &usage.finish_reasons {
231 let reasons_json = serde_json::to_string(reasons).unwrap_or_default();
232 span.record("gen_ai.response.finish_reasons", reasons_json.as_str());
233 }
234 if let Some(id) = &usage.response_id {
235 span.record("gen_ai.response.id", id.as_str());
236 }
237 if let Some(id) = &usage.request_id {
238 span.record("gen_ai.request.id", id.as_str());
239 }
240}
241
242#[derive(Default)]
243struct InferenceOutput {
244 accumulator: Conversation,
245 additional_messages: Vec<Message>,
246 usage: Vec<ProviderUsage>,
247}
248
249pub struct InferenceRunner<'a, S, E> {
250 provider: Arc<dyn Provider>,
251 model_config: ModelConfig,
252 request_preparer: Arc<dyn InferenceRequestPreparer<S> + 'a>,
253 output: Mutex<InferenceOutput>,
254 effect: std::marker::PhantomData<fn() -> E>,
255}
256
257fn messages_for_provider(
260 conversation: &Conversation,
261 turn: &[Message],
262 keep_empty_messages: bool,
263) -> Vec<Message> {
264 let answered: HashSet<&str> = conversation
265 .messages()
266 .iter()
267 .flat_map(|message| message.get_tool_response_ids())
268 .collect();
269 let start = conversation.len() - turn.len();
270 conversation
271 .messages()
272 .iter()
273 .enumerate()
274 .filter(|(_, message)| message.is_agent_visible())
275 .map(|(index, message)| {
276 let mut message = message.agent_visible_content();
277 if index < start {
278 message.content.retain(|content| match content {
279 MessageContent::ToolRequest(request) => answered.contains(request.id.as_str()),
280 _ => true,
281 });
282 }
283 message
284 })
285 .filter(|message| keep_empty_messages || !message.content.is_empty())
286 .collect()
287}
288
289fn latest_provider_session_id<'a>(
290 conversation: &'a Conversation,
291 provider: &str,
292) -> Option<&'a str> {
293 conversation
294 .messages()
295 .iter()
296 .rev()
297 .find_map(|message| message.metadata.inference.as_ref())
298 .filter(|inference| inference.provider == provider)
299 .and_then(|inference| inference.provider_session_id.as_deref())
300}
301
302fn ends_with_provider_turn(messages: &[Message]) -> bool {
303 messages.last().is_some_and(|message| {
304 matches!(
305 effective_role(message),
306 EffectiveRole::User | EffectiveRole::Tool
307 )
308 })
309}
310
311fn should_infer(conversation: &Conversation, turn: &[Message]) -> bool {
312 let projected = messages_for_provider(conversation, turn, true);
313 if projected
314 .last()
315 .is_some_and(|message| message.content.is_empty())
316 {
317 return false;
318 }
319 ends_with_provider_turn(&messages_for_provider(conversation, turn, false))
320}
321
322fn inference_span(provider: &dyn Provider, model_config: &ModelConfig) -> tracing::Span {
323 let span = tracing::info_span!(
324 target: "goose::state_machine",
325 "chat",
326 "gen_ai.operation.name" = "chat",
327 "gen_ai.provider.name" = %provider.get_name(),
328 "gen_ai.request.model" = %model_config.model_name,
329 "gen_ai.request.temperature" = tracing::field::Empty,
330 "gen_ai.request.max_tokens" = tracing::field::Empty,
331 "gen_ai.response.model" = tracing::field::Empty,
332 "gen_ai.response.finish_reasons" = tracing::field::Empty,
333 "gen_ai.response.id" = tracing::field::Empty,
334 "gen_ai.request.id" = tracing::field::Empty,
335 "gen_ai.usage.input_tokens" = tracing::field::Empty,
336 "gen_ai.usage.output_tokens" = tracing::field::Empty,
337 "error.type" = tracing::field::Empty,
338 );
339 record_request_params(&span, model_config);
340 span
341}
342
343impl<'a, S: MaybeSync, E: InferenceEffect> InferenceRunner<'a, S, E> {
344 pub fn new(provider: Arc<dyn Provider>, model_config: ModelConfig) -> Self {
345 Self {
346 provider,
347 model_config,
348 request_preparer: Arc::new(IdentityInferenceRequestPreparer),
349 output: Mutex::new(InferenceOutput::default()),
350 effect: std::marker::PhantomData,
351 }
352 }
353
354 pub fn with_request_preparer(
355 mut self,
356 request_preparer: Arc<dyn InferenceRequestPreparer<S> + 'a>,
357 ) -> Self {
358 self.request_preparer = request_preparer;
359 self
360 }
361
362 fn output(&self) -> MutexGuard<'_, InferenceOutput> {
363 self.output.lock().unwrap()
364 }
365
366 fn take_output(&self) -> Vec<E> {
367 let output = std::mem::take(&mut *self.output());
368 let mut effects: Vec<E> = output
369 .additional_messages
370 .into_iter()
371 .map(E::from)
372 .collect();
373 effects.extend(output.usage.into_iter().map(E::record_usage));
374 effects.extend(output.accumulator.into_iter().map(E::from));
375 effects
376 }
377
378 fn emit_message(&self, message: Message, emit: &Emitter) {
379 let message = message.with_generated_id_if_missing();
380 self.output().accumulator.push(message.clone());
381 emit.message(message);
382 }
383
384 fn error_outcome(
385 &self,
386 err: &ProviderError,
387 operation_logs: &mut Vec<String>,
388 emit: &Emitter,
389 ) -> Result<OperationResult<E>> {
390 tracing::Span::current().record("error.type", err.telemetry_type());
391 tracing::error!("LLM provider error: {err}");
392 let mut message = Message::from_provider_error(err);
393 attach_operation_logs(&mut message, operation_logs);
394 self.emit_message(message, emit);
395 applied(self.take_output())
396 }
397}
398
399#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
400#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
401impl<S: MaybeSync, E: InferenceEffect> Operation<S, E> for InferenceRunner<'_, S, E> {
402 fn name(&self) -> &'static str {
403 "llm"
404 }
405
406 async fn finalize_cancellation(
407 &self,
408 _session: &S,
409 _conversation: &Conversation,
410 _emit: &Emitter,
411 ) -> Vec<E> {
412 self.take_output()
413 }
414}
415
416#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
417#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
418impl<S: MachineSession, E: InferenceEffect> Inference<S, E> for InferenceRunner<'_, S, E> {
419 fn applies(&self, conversation: &Conversation) -> bool {
420 let Ok(turn) = messages_since_kickoff(conversation) else {
421 return false;
422 };
423 trailing_error(conversation).is_none() && should_infer(conversation, turn)
424 }
425
426 async fn prepare_session(&self, session: &S) -> Result<Option<S>> {
427 self.request_preparer.prepare_session(session).await
428 }
429
430 async fn infer(
431 &self,
432 session: &S,
433 conversation: &Conversation,
434 input: InferenceInput,
435 emit: &Emitter,
436 ) -> Result<OperationResult<E>> {
437 let messages = messages_since_kickoff(conversation)?;
438 if trailing_error(conversation).is_some() {
439 return not_applicable();
440 }
441
442 if !should_infer(conversation, messages) {
443 return not_applicable();
444 }
445 let mut operation_logs = pending_operation_logs(messages);
446 let mut messages_for_provider = messages_for_provider(conversation, messages, false);
447
448 let model_config = session
449 .thinking_effort()
450 .map(|effort| self.model_config.clone().with_thinking_effort(effort))
451 .unwrap_or_else(|| self.model_config.clone());
452 let span = inference_span(self.provider.as_ref(), &model_config);
453
454 async {
455 let PreparedInferenceRequest {
456 system_prompt,
457 tools,
458 additional_messages,
459 } = self
460 .request_preparer
461 .prepare(session, conversation, input)
462 .await?;
463
464 for message in &additional_messages {
465 messages_for_provider.push(message.clone());
466 }
467 self.output().additional_messages = additional_messages;
468
469 let provider_name = self.provider.get_name();
470 if let Some(session_id) = latest_provider_session_id(conversation, provider_name) {
471 if let Err(error) = self.provider.resume(session_id).await {
472 tracing::warn!(
473 provider = provider_name,
474 %error,
475 "Could not resume provider session; continuing with a handoff"
476 );
477 }
478 }
479
480 let projected =
481 Conversation::new_unvalidated(messages_for_provider).agent_visible_messages();
482 let (fixed, _) = fix_conversation(Conversation::new_unvalidated(projected));
483 let conversation_for_provider = Conversation::new_unvalidated(
484 merge_consecutive_messages_for_request(fixed.messages().clone()),
485 );
486 let successful_tool_response =
487 ends_with_successful_tool_response(conversation.messages());
488 let mut empty_responses = 0;
489 let empty_output = loop {
490 let attempt_start = self.output().usage.len();
491 let stream = self
492 .provider
493 .stream(
494 &model_config,
495 &system_prompt,
496 conversation_for_provider.messages(),
497 &tools,
498 )
499 .await;
500
501 let mut stream = match stream {
502 Ok(stream) => stream,
503 Err(err) => return self.error_outcome(&err, &mut operation_logs, emit),
504 };
505
506 let requested_model = model_config.model_name.clone();
507 let resolved_model = self
508 .provider
509 .fetch_model_info(&requested_model)
510 .await
511 .ok()
512 .and_then(|model_info| model_info.resolved_model);
513 let provider_session_id = self.provider.provider_session_id();
514 let inference = Some(InferenceMetadata {
515 provider: self.provider.get_name().to_string(),
516 requested_model,
517 resolved_model,
518 provider_session_id,
519 });
520
521 let mut tool_request_ids = HashSet::new();
522 while let Some(result) = stream.next().await {
523 let (msg_opt, usage_opt) = match result {
524 Ok(chunk) => chunk,
525 Err(err) => return self.error_outcome(&err, &mut operation_logs, emit),
526 };
527 if let Some(usage) = usage_opt {
528 record_chat_usage(&tracing::Span::current(), &usage);
529 let mut output = self.output();
530 output.usage.truncate(attempt_start);
531 output.usage.push(usage);
532 }
533 if let Some(mut chunk) = msg_opt {
534 if let Some(inference) = &inference {
535 chunk = chunk.with_inference_if_assistant(inference.clone());
536 }
537 chunk.content.retain(|content| match content {
538 MessageContent::ToolRequest(request) => {
539 tool_request_ids.insert(request.id.clone())
540 }
541 _ => true,
542 });
543 drop_repeated_tool_call_thinking(&self.output().accumulator, &mut chunk);
544 if chunk.content.is_empty() && !chunk.metadata.output_token_limit_reached {
545 self.output().accumulator.push(chunk);
546 } else {
547 attach_operation_logs(&mut chunk, &mut operation_logs);
548 self.emit_message(chunk, emit);
549 }
550 }
551 }
552
553 let empty_output = {
554 let output = self.output();
555 !output
556 .accumulator
557 .iter()
558 .any(|message| message.metadata.output_token_limit_reached)
559 && output.accumulator.iter().all(is_empty_response)
560 };
561 if !empty_output || successful_tool_response || emit.cancel_token().is_cancelled() {
562 break empty_output;
563 }
564 self.output().accumulator.clear();
565 if empty_responses < MAX_EMPTY_RESPONSE_RETRIES {
566 empty_responses += 1;
567 tracing::warn!(
568 "Provider returned an empty response; retrying ({empty_responses}/{MAX_EMPTY_RESPONSE_RETRIES})"
569 );
570 continue;
571 }
572 let mut marker = Message::assistant()
573 .with_text(EMPTY_RESPONSE_MESSAGE)
574 .with_visibility(false, false);
575 attach_operation_logs(&mut marker, &mut operation_logs);
576 marker.metadata.set_operation_note(
577 EMPTY_RESPONSE_NOTE_SCOPE,
578 EMPTY_RESPONSE_NOTE,
579 serde_json::Value::Bool(true),
580 );
581 self.output().accumulator.push(marker);
582 return applied(self.take_output());
583 };
584
585 if empty_output && successful_tool_response {
586 let message = {
587 let mut output = self.output();
588 let mut message = output
589 .accumulator
590 .last()
591 .cloned()
592 .unwrap_or_else(Message::assistant);
593 message.content.clear();
594 message.metadata.user_visible = false;
595 message.metadata.agent_visible = true;
596 attach_operation_logs(&mut message, &mut operation_logs);
597 output.accumulator.clear();
598 message
599 };
600 self.emit_message(message, emit);
601 }
602 applied(self.take_output())
603 }
604 .instrument(span)
605 .await
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn provider_session_id_comes_only_from_latest_inference() {
615 let conversation = Conversation::new_unvalidated([
616 Message::assistant().with_inference(InferenceMetadata {
617 provider: "provider-a".to_string(),
618 requested_model: "model".to_string(),
619 resolved_model: None,
620 provider_session_id: Some("session-a".to_string()),
621 }),
622 Message::assistant().with_inference(InferenceMetadata {
623 provider: "provider-b".to_string(),
624 requested_model: "model".to_string(),
625 resolved_model: None,
626 provider_session_id: Some("session-b".to_string()),
627 }),
628 ]);
629
630 assert_eq!(
631 latest_provider_session_id(&conversation, "provider-b"),
632 Some("session-b")
633 );
634 assert_eq!(
635 latest_provider_session_id(&conversation, "provider-a"),
636 None
637 );
638 }
639
640 #[test]
641 fn signed_thinking_without_text_is_not_an_empty_response() {
642 assert!(is_empty_response(
643 &Message::assistant().with_content(MessageContent::thinking("", ""))
644 ));
645 assert!(!is_empty_response(
646 &Message::assistant().with_content(MessageContent::thinking("", "sig-omitted"))
647 ));
648 }
649
650 #[test]
651 fn whitespace_only_text_is_an_empty_response() {
652 assert!(is_empty_response(&Message::assistant().with_text(" \n\t ")));
653 }
654}