Skip to main content

llm_pipeline/
tool_loop.rs

1use crate::{
2    client::LlmConfig, exec_ctx::ExecCtx, streaming::StreamingDecoder, PipelineError, Result,
3};
4use futures::StreamExt;
5use llm_tool_runtime::{
6    render_ollama_tool, render_openai_tool, ApprovalGrant, ToolBudgetContext, ToolCall, ToolCtx,
7    ToolDescriptor, ToolError, ToolExecutionPermit, ToolExposureDecision, ToolExposureRequest,
8    ToolOriginKind, ToolPlannerStage, ToolReceiptSink, ToolRegistry, ToolResult, ToolRetryOwner,
9    ToolRuntime,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use stack_ids::{AttemptId, ScopeKey, TraceCtx, TrialId};
14use std::collections::BTreeMap;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::atomic::Ordering;
18use std::sync::Arc;
19use std::time::Duration;
20
21type JsonFetchFuture<'a> = Pin<Box<dyn Future<Output = Result<Value>> + 'a>>;
22type JsonFetcher<'a> = dyn FnMut(Value) -> JsonFetchFuture<'a> + 'a;
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ToolLoopChoice {
27    Auto,
28    None,
29    Required { tool_name: String },
30}
31
32#[derive(Debug, Clone)]
33pub struct ToolLoopRequest {
34    pub model: String,
35    pub user_input: String,
36    pub instructions: Option<String>,
37    pub config: LlmConfig,
38    pub attempt_id: Option<AttemptId>,
39    pub planner_stage: ToolPlannerStage,
40    pub retry_owner: ToolRetryOwner,
41    pub allowed_tools: Option<Vec<String>>,
42    pub tool_choice: ToolLoopChoice,
43    pub max_round_trips: u32,
44    pub strict: bool,
45    pub parallel_tool_calls: bool,
46    pub dry_run: bool,
47    pub scope: Option<ScopeKey>,
48    pub approval_grant: Option<ApprovalGrant>,
49    pub execution_permit: Option<std::sync::Arc<ToolExecutionPermit>>,
50    pub stream: bool,
51    pub api_key: Option<String>,
52    pub organization: Option<String>,
53}
54
55impl ToolLoopRequest {
56    pub fn new(model: impl Into<String>, user_input: impl Into<String>) -> Self {
57        Self {
58            model: model.into(),
59            user_input: user_input.into(),
60            instructions: None,
61            config: LlmConfig::default(),
62            attempt_id: None,
63            planner_stage: ToolPlannerStage::Execution,
64            retry_owner: ToolRetryOwner::LlmPipeline,
65            allowed_tools: None,
66            tool_choice: ToolLoopChoice::Auto,
67            max_round_trips: 8,
68            strict: true,
69            parallel_tool_calls: false,
70            dry_run: false,
71            scope: None,
72            approval_grant: None,
73            execution_permit: None,
74            stream: false,
75            api_key: None,
76            organization: None,
77        }
78    }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ToolInvocation {
83    pub call: ToolCall,
84    pub outcome: std::result::Result<ToolResult, ToolError>,
85    pub receipt: llm_tool_runtime::ToolReceipt,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ToolLoopResponse {
90    pub final_text: String,
91    pub rounds: u32,
92    pub trace_ctx: TraceCtx,
93    pub invocations: Vec<ToolInvocation>,
94    pub exposure_decisions: Vec<ToolExposureDecision>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub provider_response_id: Option<String>,
97}
98
99pub struct ToolLoopRunner {
100    runtime: Arc<ToolRuntime>,
101}
102
103struct PreparedToolLoop {
104    descriptors_by_name: BTreeMap<String, ToolDescriptor>,
105    tools: Vec<Value>,
106    exposure_decisions: Vec<ToolExposureDecision>,
107}
108
109impl ToolLoopRunner {
110    pub fn new(runtime: Arc<ToolRuntime>) -> Self {
111        Self { runtime }
112    }
113
114    pub fn from_registry(registry: ToolRegistry) -> Self {
115        Self::new(Arc::new(ToolRuntime::new(registry)))
116    }
117
118    pub fn from_registry_with_receipt_sink(
119        registry: ToolRegistry,
120        receipt_sink: Arc<dyn ToolReceiptSink>,
121    ) -> Self {
122        Self::new(Arc::new(
123            ToolRuntime::new(registry).with_receipt_sink(receipt_sink),
124        ))
125    }
126
127    pub fn runtime(&self) -> &Arc<ToolRuntime> {
128        &self.runtime
129    }
130
131    fn prepare_tool_loop<F>(
132        &self,
133        request: &ToolLoopRequest,
134        mut render_tool: F,
135    ) -> Result<PreparedToolLoop>
136    where
137        F: FnMut(&ToolDescriptor) -> std::result::Result<Value, ToolError>,
138    {
139        let exposure = self.runtime.registry().plan_exposure(&ToolExposureRequest {
140            allowed_names: request.allowed_tools.clone(),
141            planner_stage: request.planner_stage.clone(),
142            include_hidden: false,
143            max_tools: None,
144        });
145        let descriptors = exposure
146            .tools
147            .iter()
148            .map(|tool| tool.descriptor().clone())
149            .collect::<Vec<_>>();
150        let tools = descriptors
151            .iter()
152            .map(&mut render_tool)
153            .collect::<std::result::Result<Vec<_>, _>>()
154            .map_err(tool_error_to_pipeline)?;
155        let descriptors_by_name = descriptors
156            .into_iter()
157            .map(|descriptor| (descriptor.name.clone(), descriptor))
158            .collect::<BTreeMap<_, _>>();
159
160        Ok(PreparedToolLoop {
161            descriptors_by_name,
162            tools,
163            exposure_decisions: exposure.decisions,
164        })
165    }
166
167    async fn execute_tool_call(
168        &self,
169        ctx: &ExecCtx,
170        request: &ToolLoopRequest,
171        attempt_id: &AttemptId,
172        call: ToolCall,
173        invocations: &mut Vec<ToolInvocation>,
174    ) -> String {
175        let tool_ctx = build_tool_ctx(ctx, request, attempt_id);
176        let execution = self
177            .runtime
178            .execute(
179                &tool_ctx,
180                &call,
181                request.execution_permit.clone(),
182                ctx.cancel_flag(),
183            )
184            .await;
185        let tool_output = execution
186            .result
187            .as_ref()
188            .map(ToolResult::to_model_output)
189            .unwrap_or_else(|error| {
190                json!({
191                    "error_class": error.class,
192                    "message": error.message,
193                })
194                .to_string()
195            });
196        invocations.push(ToolInvocation {
197            call,
198            outcome: execution.result,
199            receipt: execution.receipt,
200        });
201        tool_output
202    }
203
204    async fn execute_openai_function_calls(
205        &self,
206        ctx: &ExecCtx,
207        request: &ToolLoopRequest,
208        attempt_id: &AttemptId,
209        descriptors_by_name: &BTreeMap<String, ToolDescriptor>,
210        function_calls: Vec<OpenAiFunctionCall>,
211        invocations: &mut Vec<ToolInvocation>,
212    ) -> Result<Vec<Value>> {
213        let mut outputs = Vec::with_capacity(function_calls.len());
214        for function_call in function_calls {
215            let descriptor = descriptors_by_name
216                .get(&function_call.name)
217                .ok_or_else(|| {
218                    PipelineError::Other(format!(
219                        "provider requested unknown or unexposed tool {}",
220                        function_call.name
221                    ))
222                })?;
223            let call = ToolCall {
224                descriptor_name: descriptor.name.clone(),
225                descriptor_version: descriptor.version.clone(),
226                arguments: function_call.arguments.clone(),
227                origin_kind: ToolOriginKind::OpenAiResponses,
228                provider_call_id: Some(function_call.call_id.clone()),
229                tool_run_id: uuid::Uuid::new_v4().to_string(),
230            };
231            let tool_output = self
232                .execute_tool_call(ctx, request, attempt_id, call, invocations)
233                .await;
234            outputs.push(json!({
235                "type": "function_call_output",
236                "call_id": function_call.call_id,
237                "output": tool_output,
238            }));
239        }
240        Ok(outputs)
241    }
242
243    async fn execute_ollama_tool_calls(
244        &self,
245        ctx: &ExecCtx,
246        request: &ToolLoopRequest,
247        attempt_id: &AttemptId,
248        descriptors_by_name: &BTreeMap<String, ToolDescriptor>,
249        tool_calls: Vec<OllamaFunctionCall>,
250        invocations: &mut Vec<ToolInvocation>,
251    ) -> Result<Vec<Value>> {
252        let mut outputs = Vec::with_capacity(tool_calls.len());
253        for tool_call in tool_calls {
254            let descriptor = descriptors_by_name.get(&tool_call.name).ok_or_else(|| {
255                PipelineError::Other(format!(
256                    "provider requested unknown or unexposed tool {}",
257                    tool_call.name
258                ))
259            })?;
260            let call = ToolCall {
261                descriptor_name: descriptor.name.clone(),
262                descriptor_version: descriptor.version.clone(),
263                arguments: tool_call.arguments.clone(),
264                origin_kind: ToolOriginKind::OllamaChat,
265                provider_call_id: None,
266                tool_run_id: uuid::Uuid::new_v4().to_string(),
267            };
268            let tool_output = self
269                .execute_tool_call(ctx, request, attempt_id, call, invocations)
270                .await;
271            outputs.push(json!({
272                "role": "tool",
273                "name": tool_call.name,
274                "content": tool_output,
275            }));
276        }
277        Ok(outputs)
278    }
279
280    pub async fn run_openai_responses(
281        &self,
282        ctx: &ExecCtx,
283        request: ToolLoopRequest,
284    ) -> Result<ToolLoopResponse> {
285        let api_key = request.api_key.clone();
286        let organization = request.organization.clone();
287        let mut fetch = |body: Value| -> JsonFetchFuture<'_> {
288            let api_key = api_key.clone();
289            let organization = organization.clone();
290            Box::pin(async move {
291                post_json_with_backoff(
292                    ctx,
293                    "/v1/responses",
294                    &body,
295                    api_key.as_deref(),
296                    organization.as_deref(),
297                )
298                .await
299            })
300        };
301        self.run_openai_responses_with_fetcher(ctx, request, &mut fetch)
302            .await
303    }
304
305    async fn run_openai_responses_with_fetcher(
306        &self,
307        ctx: &ExecCtx,
308        request: ToolLoopRequest,
309        fetch: &mut JsonFetcher<'_>,
310    ) -> Result<ToolLoopResponse> {
311        let prepared = self.prepare_tool_loop(&request, |descriptor| {
312            render_openai_tool(descriptor, request.strict)
313        })?;
314
315        let mut input_items = vec![json!({
316            "role": "user",
317            "content": [{"type": "input_text", "text": request.user_input}],
318        })];
319        let mut invocations = Vec::new();
320        let attempt_id = request
321            .attempt_id
322            .clone()
323            .unwrap_or_else(AttemptId::generate);
324
325        for round in 0..request.max_round_trips {
326            ctx.check_cancelled()?;
327            let response =
328                fetch(openai_request_body(&request, &input_items, &prepared.tools)).await?;
329
330            let provider_response_id = response
331                .get("id")
332                .and_then(|value| value.as_str())
333                .map(|value| value.to_string());
334
335            let output_items = response
336                .get("output")
337                .and_then(|value| value.as_array())
338                .cloned()
339                .unwrap_or_default();
340            let function_calls = parse_openai_function_calls(&output_items)?;
341
342            if function_calls.is_empty() {
343                return Ok(ToolLoopResponse {
344                    final_text: openai_output_text(&response),
345                    rounds: round + 1,
346                    trace_ctx: ctx.trace_ctx.clone(),
347                    invocations,
348                    exposure_decisions: prepared.exposure_decisions.clone(),
349                    provider_response_id,
350                });
351            }
352
353            if function_calls.len() > 1 && !request.parallel_tool_calls {
354                return Err(PipelineError::InvalidConfig(
355                    "parallel tool calls are disabled by default".into(),
356                ));
357            }
358
359            input_items.extend(output_items);
360            input_items.extend(
361                self.execute_openai_function_calls(
362                    ctx,
363                    &request,
364                    &attempt_id,
365                    &prepared.descriptors_by_name,
366                    function_calls,
367                    &mut invocations,
368                )
369                .await?,
370            );
371        }
372
373        Err(PipelineError::Other(format!(
374            "tool loop exceeded max_round_trips={}",
375            request.max_round_trips
376        )))
377    }
378
379    pub async fn run_ollama(
380        &self,
381        ctx: &ExecCtx,
382        request: ToolLoopRequest,
383    ) -> Result<ToolLoopResponse> {
384        if request.stream {
385            return self.run_ollama_streaming(ctx, request).await;
386        }
387
388        let mut fetch = |body: Value| -> JsonFetchFuture<'_> {
389            Box::pin(
390                async move { post_json_with_backoff(ctx, "/api/chat", &body, None, None).await },
391            )
392        };
393        self.run_ollama_with_fetcher(ctx, request, &mut fetch).await
394    }
395
396    async fn run_ollama_with_fetcher(
397        &self,
398        ctx: &ExecCtx,
399        request: ToolLoopRequest,
400        fetch: &mut JsonFetcher<'_>,
401    ) -> Result<ToolLoopResponse> {
402        let prepared = self.prepare_tool_loop(&request, render_ollama_tool)?;
403
404        let mut messages = Vec::new();
405        if let Some(instructions) = &request.instructions {
406            messages.push(json!({"role": "system", "content": instructions}));
407        }
408        messages.push(json!({"role": "user", "content": request.user_input}));
409
410        let mut invocations = Vec::new();
411        let attempt_id = request
412            .attempt_id
413            .clone()
414            .unwrap_or_else(AttemptId::generate);
415        for round in 0..request.max_round_trips {
416            ctx.check_cancelled()?;
417            let response = fetch(ollama_request_body(
418                &request,
419                &messages,
420                &prepared.tools,
421                false,
422            ))
423            .await?;
424
425            let message = response
426                .get("message")
427                .cloned()
428                .ok_or_else(|| PipelineError::Other("Ollama response missing message".into()))?;
429            let tool_calls = parse_ollama_tool_calls(&message)?;
430            messages.push(message.clone());
431
432            if tool_calls.is_empty() {
433                return Ok(ToolLoopResponse {
434                    final_text: message
435                        .get("content")
436                        .and_then(|value| value.as_str())
437                        .unwrap_or_default()
438                        .to_string(),
439                    rounds: round + 1,
440                    trace_ctx: ctx.trace_ctx.clone(),
441                    invocations,
442                    exposure_decisions: prepared.exposure_decisions.clone(),
443                    provider_response_id: None,
444                });
445            }
446
447            if tool_calls.len() > 1 && !request.parallel_tool_calls {
448                return Err(PipelineError::InvalidConfig(
449                    "parallel tool calls are disabled by default".into(),
450                ));
451            }
452
453            messages.extend(
454                self.execute_ollama_tool_calls(
455                    ctx,
456                    &request,
457                    &attempt_id,
458                    &prepared.descriptors_by_name,
459                    tool_calls,
460                    &mut invocations,
461                )
462                .await?,
463            );
464        }
465
466        Err(PipelineError::Other(format!(
467            "tool loop exceeded max_round_trips={}",
468            request.max_round_trips
469        )))
470    }
471
472    async fn run_ollama_streaming(
473        &self,
474        ctx: &ExecCtx,
475        request: ToolLoopRequest,
476    ) -> Result<ToolLoopResponse> {
477        let prepared = self.prepare_tool_loop(&request, render_ollama_tool)?;
478
479        let mut messages = Vec::new();
480        if let Some(instructions) = &request.instructions {
481            messages.push(json!({"role": "system", "content": instructions}));
482        }
483        messages.push(json!({"role": "user", "content": request.user_input}));
484
485        let mut invocations = Vec::new();
486        let attempt_id = request
487            .attempt_id
488            .clone()
489            .unwrap_or_else(AttemptId::generate);
490        for round in 0..request.max_round_trips {
491            ctx.check_cancelled()?;
492            let response = post_ollama_stream(ctx, &request, &messages, &prepared.tools).await?;
493
494            let message = response
495                .get("message")
496                .cloned()
497                .ok_or_else(|| PipelineError::Other("Ollama response missing message".into()))?;
498            let tool_calls = parse_ollama_tool_calls(&message)?;
499            messages.push(message.clone());
500
501            if tool_calls.is_empty() {
502                return Ok(ToolLoopResponse {
503                    final_text: message
504                        .get("content")
505                        .and_then(|value| value.as_str())
506                        .unwrap_or_default()
507                        .to_string(),
508                    rounds: round + 1,
509                    trace_ctx: ctx.trace_ctx.clone(),
510                    invocations,
511                    exposure_decisions: prepared.exposure_decisions.clone(),
512                    provider_response_id: None,
513                });
514            }
515
516            if tool_calls.len() > 1 && !request.parallel_tool_calls {
517                return Err(PipelineError::InvalidConfig(
518                    "parallel tool calls are disabled by default".into(),
519                ));
520            }
521
522            messages.extend(
523                self.execute_ollama_tool_calls(
524                    ctx,
525                    &request,
526                    &attempt_id,
527                    &prepared.descriptors_by_name,
528                    tool_calls,
529                    &mut invocations,
530                )
531                .await?,
532            );
533        }
534
535        Err(PipelineError::Other(format!(
536            "tool loop exceeded max_round_trips={}",
537            request.max_round_trips
538        )))
539    }
540}
541
542#[derive(Debug, Clone)]
543struct OpenAiFunctionCall {
544    name: String,
545    call_id: String,
546    arguments: Value,
547}
548
549#[derive(Debug, Clone)]
550struct OllamaFunctionCall {
551    name: String,
552    arguments: Value,
553}
554
555fn build_tool_ctx(ctx: &ExecCtx, request: &ToolLoopRequest, attempt_id: &AttemptId) -> ToolCtx {
556    let deadline = chrono::Utc::now()
557        + chrono::Duration::from_std(ctx.limits.request_timeout)
558            .unwrap_or_else(|_| chrono::Duration::seconds(60));
559    ToolCtx {
560        trace_ctx: ctx.trace_ctx.clone(),
561        attempt_id: attempt_id.clone(),
562        trial_id: TrialId::generate(),
563        deadline: Some(deadline.to_rfc3339()),
564        workload_class: Some("llm_pipeline_tool_loop".into()),
565        budget_context: Some(ToolBudgetContext {
566            budget_kind: Some("tool_loop".into()),
567            max_steps: Some(request.max_round_trips),
568            time_budget_ms: Some(ctx.limits.request_timeout.as_millis() as u64),
569            cost_budget_units: None,
570        }),
571        scope: request.scope.clone(),
572        dry_run: request.dry_run,
573        approval_grant: request.approval_grant.clone(),
574        execution_permit: request.execution_permit.clone(),
575        idempotency_key: None,
576        caller: format!("llm-pipeline:{}", request.model),
577        planner_stage: request.planner_stage.clone(),
578        parent_receipt_id: None,
579        family_receipt_id: None,
580        replay_parent_receipt_id: None,
581        remote_oracle_lease_id: None,
582        remote_slice_result_id: None,
583        attestation_envelope_id: None,
584        cross_runtime_replay_ticket_id: None,
585        retry_owner: Some(request.retry_owner.clone()),
586    }
587}
588
589fn openai_request_body(request: &ToolLoopRequest, input_items: &[Value], tools: &[Value]) -> Value {
590    let mut body = json!({
591        "model": request.model,
592        "input": input_items,
593        "parallel_tool_calls": request.parallel_tool_calls,
594    });
595
596    if let Some(instructions) = &request.instructions {
597        body["instructions"] = json!(instructions);
598    }
599
600    if !tools.is_empty() {
601        body["tools"] = Value::Array(tools.to_vec());
602        body["tool_choice"] = match &request.tool_choice {
603            ToolLoopChoice::Auto => json!("auto"),
604            ToolLoopChoice::None => json!("none"),
605            ToolLoopChoice::Required { tool_name } => json!({
606                "type": "function",
607                "name": tool_name,
608            }),
609        };
610    }
611
612    body
613}
614
615fn ollama_request_body(
616    request: &ToolLoopRequest,
617    messages: &[Value],
618    tools: &[Value],
619    stream: bool,
620) -> Value {
621    let mut body = json!({
622        "model": request.model,
623        "messages": messages,
624        "stream": stream,
625        "options": {
626            "temperature": request.config.temperature,
627            "num_predict": request.config.max_tokens,
628        }
629    });
630
631    if request.config.thinking {
632        body["think"] = json!(true);
633    }
634    if request.config.json_mode {
635        body["format"] = json!("json");
636    }
637    if let Some(options) = request.config.options.clone() {
638        body["options"] = merge_json_objects(body["options"].clone(), options);
639    }
640    if !tools.is_empty() && !matches!(request.tool_choice, ToolLoopChoice::None) {
641        body["tools"] = Value::Array(tools.to_vec());
642    }
643
644    body
645}
646
647async fn post_json_with_backoff(
648    ctx: &ExecCtx,
649    path: &str,
650    body: &Value,
651    api_key: Option<&str>,
652    organization: Option<&str>,
653) -> Result<Value> {
654    let url = format!("{}{}", ctx.base_url.trim_end_matches('/'), path);
655    let mut attempt = 0u32;
656
657    loop {
658        ctx.check_cancelled()?;
659        let mut request = ctx.client.post(&url).json(body);
660        if let Some(api_key) = api_key {
661            request = request.header("Authorization", format!("Bearer {}", api_key));
662        }
663        if let Some(organization) = organization {
664            request = request.header("OpenAI-Organization", organization);
665        }
666        let response = request.send().await.map_err(PipelineError::Request)?;
667
668        if response.status().is_success() {
669            return response.json().await.map_err(PipelineError::Request);
670        }
671
672        let retry_after = response
673            .headers()
674            .get("retry-after")
675            .and_then(|value| value.to_str().ok())
676            .and_then(|value| value.parse::<u64>().ok())
677            .map(Duration::from_secs);
678        let error = PipelineError::HttpError {
679            status: response.status().as_u16(),
680            body: response.text().await.unwrap_or_default(),
681            retry_after,
682        };
683
684        if attempt >= ctx.backoff.max_retries || !crate::backend::is_retryable(&error, &ctx.backoff)
685        {
686            return Err(error);
687        }
688
689        let delay = retry_after
690            .filter(|_| ctx.backoff.respect_retry_after)
691            .unwrap_or_else(|| ctx.backoff.delay_for_attempt(attempt));
692        tokio::time::sleep(delay).await;
693        if let Some(flag) = ctx.cancel_flag() {
694            if flag.load(Ordering::Relaxed) {
695                return Err(PipelineError::Cancelled);
696            }
697        }
698        attempt += 1;
699    }
700}
701
702async fn post_ollama_stream(
703    ctx: &ExecCtx,
704    request: &ToolLoopRequest,
705    messages: &[Value],
706    tools: &[Value],
707) -> Result<Value> {
708    let url = format!("{}/api/chat", ctx.base_url.trim_end_matches('/'));
709    let response = ctx
710        .client
711        .post(&url)
712        .json(&ollama_request_body(request, messages, tools, true))
713        .send()
714        .await
715        .map_err(PipelineError::Request)?;
716
717    if !response.status().is_success() {
718        return Err(PipelineError::HttpError {
719            status: response.status().as_u16(),
720            body: response.text().await.unwrap_or_default(),
721            retry_after: None,
722        });
723    }
724
725    let stream = response.bytes_stream().map(|chunk| {
726        chunk
727            .map(|chunk| chunk.to_vec())
728            .map_err(PipelineError::Request)
729    });
730
731    consume_ollama_stream(ctx, stream).await
732}
733
734async fn consume_ollama_stream<S>(ctx: &ExecCtx, mut stream: S) -> Result<Value>
735where
736    S: futures::Stream<Item = Result<Vec<u8>>> + Unpin,
737{
738    let mut decoder = StreamingDecoder::new();
739    let mut content = String::new();
740    let mut thinking = String::new();
741    let mut tool_calls = Vec::new();
742
743    loop {
744        ctx.check_cancelled()?;
745        let Some(chunk) = stream.next().await else {
746            break;
747        };
748        let chunk = chunk?;
749        for value in decoder.decode(&chunk) {
750            accumulate_ollama_stream_message(&value, &mut content, &mut thinking, &mut tool_calls);
751        }
752    }
753    ctx.check_cancelled()?;
754    if let Some(value) = decoder.flush() {
755        accumulate_ollama_stream_message(&value, &mut content, &mut thinking, &mut tool_calls);
756    }
757
758    Ok(json!({
759        "message": {
760            "role": "assistant",
761            "content": content,
762            "thinking": thinking,
763            "tool_calls": tool_calls,
764        }
765    }))
766}
767
768fn accumulate_ollama_stream_message(
769    value: &Value,
770    content: &mut String,
771    thinking: &mut String,
772    tool_calls: &mut Vec<Value>,
773) {
774    let Some(message) = value.get("message") else {
775        return;
776    };
777    if let Some(partial_content) = message.get("content").and_then(|value| value.as_str()) {
778        content.push_str(partial_content);
779    }
780    if let Some(partial_thinking) = message.get("thinking").and_then(|value| value.as_str()) {
781        thinking.push_str(partial_thinking);
782    }
783    if let Some(partial_tool_calls) = message.get("tool_calls").and_then(|value| value.as_array()) {
784        tool_calls.extend(partial_tool_calls.iter().cloned());
785    }
786}
787
788fn parse_openai_function_calls(output_items: &[Value]) -> Result<Vec<OpenAiFunctionCall>> {
789    let mut calls = Vec::new();
790    for item in output_items {
791        if item.get("type").and_then(|value| value.as_str()) != Some("function_call") {
792            continue;
793        }
794
795        let name = item
796            .get("name")
797            .and_then(|value| value.as_str())
798            .ok_or_else(|| PipelineError::Other("OpenAI function call missing name".into()))?;
799        let call_id = item
800            .get("call_id")
801            .or_else(|| item.get("id"))
802            .and_then(|value| value.as_str())
803            .ok_or_else(|| PipelineError::Other("OpenAI function call missing call_id".into()))?;
804        let arguments = match item.get("arguments") {
805            Some(Value::String(arguments)) if arguments.trim().is_empty() => {
806                Value::Object(Default::default())
807            }
808            Some(Value::String(arguments)) => serde_json::from_str(arguments)
809                .map_err(|error| PipelineError::Other(error.to_string()))?,
810            Some(arguments) => arguments.clone(),
811            None => Value::Object(Default::default()),
812        };
813
814        calls.push(OpenAiFunctionCall {
815            name: name.to_string(),
816            call_id: call_id.to_string(),
817            arguments,
818        });
819    }
820
821    Ok(calls)
822}
823
824fn parse_ollama_tool_calls(message: &Value) -> Result<Vec<OllamaFunctionCall>> {
825    let raw_calls = message
826        .get("tool_calls")
827        .and_then(|value| value.as_array())
828        .cloned()
829        .unwrap_or_default();
830    let mut calls = Vec::new();
831
832    for item in raw_calls {
833        let function = item
834            .get("function")
835            .ok_or_else(|| PipelineError::Other("Ollama tool call missing function".into()))?;
836        let name = function
837            .get("name")
838            .and_then(|value| value.as_str())
839            .ok_or_else(|| PipelineError::Other("Ollama tool call missing name".into()))?;
840        let arguments = function
841            .get("arguments")
842            .cloned()
843            .unwrap_or_else(|| Value::Object(Default::default()));
844        calls.push(OllamaFunctionCall {
845            name: name.to_string(),
846            arguments,
847        });
848    }
849
850    Ok(calls)
851}
852
853fn openai_output_text(response: &Value) -> String {
854    if let Some(output_text) = response.get("output_text").and_then(|value| value.as_str()) {
855        if !output_text.is_empty() {
856            return output_text.to_string();
857        }
858    }
859
860    let mut rendered = String::new();
861    if let Some(items) = response.get("output").and_then(|value| value.as_array()) {
862        for item in items {
863            if item.get("type").and_then(|value| value.as_str()) != Some("message") {
864                continue;
865            }
866            if let Some(content) = item.get("content").and_then(|value| value.as_array()) {
867                for part in content {
868                    if let Some(text) = part.get("text").and_then(|value| value.as_str()) {
869                        rendered.push_str(text);
870                    }
871                }
872            }
873        }
874    }
875    rendered
876}
877
878fn merge_json_objects(left: Value, right: Value) -> Value {
879    let mut merged = left.as_object().cloned().unwrap_or_default();
880    for (key, value) in right.as_object().cloned().unwrap_or_default() {
881        merged.insert(key, value);
882    }
883    Value::Object(merged)
884}
885
886fn tool_error_to_pipeline(error: ToolError) -> PipelineError {
887    PipelineError::Other(error.to_string())
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893    use async_trait::async_trait;
894    use forge_engine::{ForgeStore, ForgeToolReceiptSink};
895    use llm_tool_runtime::{
896        Tool, ToolApprovalKind, ToolBackendKind, ToolDescriptor, ToolExposureMode,
897        ToolExposurePolicy, ToolIdempotencyClass, ToolOutputMode, ToolReceiptPersistence,
898        ToolSideEffectClass,
899    };
900    use std::collections::VecDeque;
901
902    #[derive(Clone)]
903    struct AddTool {
904        descriptor: ToolDescriptor,
905    }
906
907    #[async_trait]
908    impl Tool for AddTool {
909        fn descriptor(&self) -> &ToolDescriptor {
910            &self.descriptor
911        }
912
913        async fn invoke(
914            &self,
915            _ctx: &ToolCtx,
916            call: &ToolCall,
917        ) -> std::result::Result<ToolResult, ToolError> {
918            let a = call
919                .arguments
920                .get("a")
921                .and_then(|value| value.as_i64())
922                .unwrap_or(0);
923            let b = call
924                .arguments
925                .get("b")
926                .and_then(|value| value.as_i64())
927                .unwrap_or(0);
928            Ok(ToolResult::text((a + b).to_string()))
929        }
930    }
931
932    fn add_descriptor() -> ToolDescriptor {
933        ToolDescriptor {
934            name: "add".into(),
935            version: "1.0.0".into(),
936            description: Some("Add two numbers".into()),
937            backend_kind: ToolBackendKind::LocalFunction,
938            input_schema: json!({
939                "type": "object",
940                "required": ["a", "b"],
941                "properties": {
942                    "a": {"type": "integer"},
943                    "b": {"type": "integer"}
944                },
945                "additionalProperties": false
946            }),
947            output_mode: ToolOutputMode::Text,
948            read_only: true,
949            side_effect_class: ToolSideEffectClass::ReadOnly,
950            idempotency_class: ToolIdempotencyClass::Idempotent,
951            approval_kind: ToolApprovalKind::None,
952            timeout_ms: 5_000,
953            concurrency_key: None,
954            cache_ttl_ms: None,
955            exposure_mode: ToolExposureMode::Auto,
956            mcp_surface_kind: llm_tool_runtime::McpSurfaceKind::Tool,
957            exposure_policy: ToolExposurePolicy::default(),
958            receipt_persistence: ToolReceiptPersistence::Ephemeral,
959            output_size_limit_bytes: None,
960            provider_payload: None,
961            effect_target: Default::default(),
962            rollback_contract: None,
963        }
964    }
965
966    fn runner() -> ToolLoopRunner {
967        let mut registry = llm_tool_runtime::ToolRegistry::new();
968        registry.register(AddTool {
969            descriptor: add_descriptor(),
970        });
971        ToolLoopRunner::from_registry(registry)
972    }
973
974    fn durable_runner(store: Arc<ForgeStore>) -> ToolLoopRunner {
975        let mut registry = llm_tool_runtime::ToolRegistry::new();
976        let mut descriptor = add_descriptor();
977        descriptor.receipt_persistence = ToolReceiptPersistence::ForgeRaw;
978        registry.register(AddTool { descriptor });
979        ToolLoopRunner::from_registry_with_receipt_sink(
980            registry,
981            Arc::new(ForgeToolReceiptSink::new(store)),
982        )
983    }
984
985    fn canned_fetcher(responses: Vec<Value>) -> impl FnMut(Value) -> JsonFetchFuture<'static> {
986        let mut responses = VecDeque::from(responses);
987        move |_body: Value| {
988            let response = responses
989                .pop_front()
990                .expect("canned provider response should be available");
991            Box::pin(async move { Ok(response) })
992        }
993    }
994
995    #[tokio::test]
996    async fn openai_tool_loop_executes_local_function_calls() {
997        let ctx = ExecCtx::builder("http://provider.invalid").build();
998        let mut fetch = canned_fetcher(vec![
999            json!({
1000                "id": "resp_1",
1001                "output": [
1002                    {
1003                        "type": "function_call",
1004                        "call_id": "call_1",
1005                        "name": "add",
1006                        "arguments": "{\"a\":2,\"b\":3}"
1007                    }
1008                ]
1009            }),
1010            json!({
1011                "id": "resp_2",
1012                "output_text": "5"
1013            }),
1014        ]);
1015        let output = runner()
1016            .run_openai_responses_with_fetcher(
1017                &ctx,
1018                ToolLoopRequest::new("gpt-4.1", "add 2 and 3"),
1019                &mut fetch,
1020            )
1021            .await
1022            .unwrap();
1023
1024        assert_eq!(output.final_text, "5");
1025        assert_eq!(output.invocations.len(), 1);
1026    }
1027
1028    #[tokio::test]
1029    async fn ollama_tool_loop_executes_local_function_calls() {
1030        let ctx = ExecCtx::builder("http://provider.invalid").build();
1031        let mut fetch = canned_fetcher(vec![
1032            json!({
1033                "message": {
1034                    "role": "assistant",
1035                    "content": "",
1036                    "tool_calls": [
1037                        {
1038                            "type": "function",
1039                            "function": {
1040                                "index": 0,
1041                                "name": "add",
1042                                "arguments": {"a": 4, "b": 6}
1043                            }
1044                        }
1045                    ]
1046                }
1047            }),
1048            json!({
1049                "message": {
1050                    "role": "assistant",
1051                    "content": "10"
1052                }
1053            }),
1054        ]);
1055        let output = runner()
1056            .run_ollama_with_fetcher(
1057                &ctx,
1058                ToolLoopRequest::new("qwen3", "add 4 and 6"),
1059                &mut fetch,
1060            )
1061            .await
1062            .unwrap();
1063
1064        assert_eq!(output.final_text, "10");
1065        assert_eq!(output.invocations.len(), 1);
1066    }
1067
1068    #[tokio::test]
1069    async fn openai_tool_loop_persists_forge_raw_receipts_via_receipt_sink() {
1070        let dir = tempfile::TempDir::new().unwrap();
1071        let store = Arc::new(ForgeStore::open(&dir.path().join("forge.db")).unwrap());
1072        let ctx = ExecCtx::builder("http://provider.invalid").build();
1073        let mut fetch = canned_fetcher(vec![
1074            json!({
1075                "id": "resp_receipt_1",
1076                "output": [
1077                    {
1078                        "type": "function_call",
1079                        "call_id": "call_receipt_1",
1080                        "name": "add",
1081                        "arguments": "{\"a\":1,\"b\":2}"
1082                    }
1083                ]
1084            }),
1085            json!({
1086                "id": "resp_receipt_2",
1087                "output_text": "3"
1088            }),
1089        ]);
1090
1091        let output = durable_runner(store.clone())
1092            .run_openai_responses_with_fetcher(
1093                &ctx,
1094                ToolLoopRequest::new("gpt-4.1", "add 1 and 2"),
1095                &mut fetch,
1096            )
1097            .await
1098            .unwrap();
1099
1100        assert_eq!(output.final_text, "3");
1101        assert_eq!(output.invocations.len(), 1);
1102
1103        let receipt_id = &output.invocations[0].receipt.receipt_id;
1104        let stored = store
1105            .get_tool_receipt(receipt_id)
1106            .unwrap()
1107            .expect("receipt should persist to Forge");
1108
1109        assert_eq!(stored.tool_name, "add");
1110        assert_eq!(stored.tool_version, "1.0.0");
1111        assert_eq!(stored.provider_call_id.as_deref(), Some("call_receipt_1"));
1112        assert_eq!(stored.trace_id, output.trace_ctx.trace_id);
1113    }
1114
1115    #[tokio::test]
1116    async fn ollama_streaming_checks_cancellation_between_chunks() {
1117        use futures::stream::poll_fn;
1118        use std::task::Poll;
1119
1120        let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false));
1121        let ctx = ExecCtx::builder("http://provider.invalid")
1122            .cancellation(Some(cancel.clone()))
1123            .build();
1124        let first = b"{\"message\":{\"content\":\"hello\"}}\n".to_vec();
1125        let second = b"{\"message\":{\"content\":\" world\"}}\n".to_vec();
1126        let cancel_for_stream = cancel.clone();
1127        let mut step = 0;
1128
1129        let stream = poll_fn(move |_cx| match step {
1130            0 => {
1131                step = 1;
1132                cancel_for_stream.store(true, Ordering::Relaxed);
1133                Poll::Ready(Some(Ok(first.clone())))
1134            }
1135            1 => {
1136                step = 2;
1137                Poll::Ready(Some(Ok(second.clone())))
1138            }
1139            _ => Poll::Ready(None),
1140        });
1141
1142        let err = consume_ollama_stream(&ctx, stream).await.unwrap_err();
1143        assert!(matches!(err, PipelineError::Cancelled));
1144    }
1145}