Skip to main content

goose_agent/
inference.rs

1//! Provider inference operation for the unrolled agent loop.
2
3use 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
82/// The model's response stayed empty after retries. The fallback message is stored
83/// hidden so that operations owning the end of a turn (recipe retries, final output)
84/// can take over; when none does, it is revealed to the user.
85pub 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
257/// The agent-visible conversation as the provider sees it: tool requests left
258/// unanswered by an earlier turn are dropped, since nothing will answer them now.
259fn 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}