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}