Skip to main content

formal_ai/
protocol.rs

1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::agentic_coding::command_reroute::plan_symbolic_command_reroute;
8use crate::agentic_coding::narration::tool_action_narration;
9use crate::agentic_coding::planner::{plan_chat_step, AgenticPlan};
10use crate::dreaming_application::{
11    amended_answer, apply_retained_amendments, solve_with_standing_requirements,
12};
13use crate::engine::{
14    estimate_tokens, render_thinking_steps, stable_id, FormalAiEngine, SymbolicAnswer, ThinkingStep,
15};
16use crate::memory::MemoryEvent;
17use crate::protocol_memory::answer_from_memory_if_requested;
18use crate::protocol_policy::{
19    agentic_tool_permission_denial, is_hosted_tool_definition, is_tool_choice_request,
20    matches_tool_choice_none, response_tool_call_identity, tool_call_refusal_answer,
21    tool_choice_function_name, tool_definition_names, tool_permission_refusal_answer,
22};
23use crate::protocol_responses::response_arguments_for_tool;
24use crate::solver::UniversalSolver;
25
26mod content;
27mod output;
28mod recording;
29pub use output::*;
30pub use recording::{
31    chat_exchange_to_record, chat_tool_executions, messages_exchange_to_record,
32    responses_exchange_to_record,
33};
34use recording::{chat_prompt_and_history, response_prompt, value_to_prompt_text};
35
36fn resolved_request_model(model: Option<&str>) -> String {
37    crate::seed::resolve_model_id(model)
38}
39
40fn response_timestamp() -> u64 {
41    SystemTime::now()
42        .duration_since(UNIX_EPOCH)
43        .map_or(1, |duration| duration.as_secs().max(1))
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct ChatCompletionRequest {
48    #[serde(default)]
49    pub model: Option<String>,
50    #[serde(default)]
51    pub messages: Vec<ChatMessage>,
52    #[serde(default)]
53    pub temperature: Option<f32>,
54    #[serde(default)]
55    pub stream: bool,
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub tools: Vec<Value>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub tool_choice: Option<Value>,
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub functions: Vec<Value>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub function_call: Option<Value>,
64    /// OpenAI streaming knobs — most relevantly `include_usage`, which asks the
65    /// server to emit a final chunk carrying the `usage` block. The AI SDK's
66    /// openai-compatible provider ships this and drops token counts if it's
67    /// missing (see issue link-assistant/agent#249).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub stream_options: Option<StreamOptions>,
70}
71
72/// OpenAI-compatible `stream_options` field.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
74pub struct StreamOptions {
75    #[serde(default)]
76    pub include_usage: bool,
77}
78
79impl ChatCompletionRequest {
80    #[must_use]
81    pub fn requests_tool_execution(&self) -> bool {
82        if self
83            .tool_choice
84            .as_ref()
85            .is_some_and(is_tool_choice_request)
86            || self
87                .function_call
88                .as_ref()
89                .is_some_and(is_tool_choice_request)
90        {
91            return true;
92        }
93
94        let tool_calls_disabled = self
95            .tool_choice
96            .as_ref()
97            .is_some_and(matches_tool_choice_none);
98        let function_calls_disabled = self
99            .function_call
100            .as_ref()
101            .is_some_and(matches_tool_choice_none);
102
103        (!self.tools.is_empty() && !tool_calls_disabled)
104            || (!self.functions.is_empty() && !function_calls_disabled)
105    }
106
107    fn requested_tool_names(&self) -> Vec<String> {
108        let mut names = Vec::new();
109        if let Some(name) = self
110            .tool_choice
111            .as_ref()
112            .and_then(tool_choice_function_name)
113        {
114            names.push(name);
115        }
116        if let Some(name) = self
117            .function_call
118            .as_ref()
119            .and_then(tool_choice_function_name)
120        {
121            names.push(name);
122        }
123        if !self
124            .tool_choice
125            .as_ref()
126            .is_some_and(matches_tool_choice_none)
127        {
128            names.extend(self.tools.iter().flat_map(tool_definition_names));
129        }
130        if !self
131            .function_call
132            .as_ref()
133            .is_some_and(matches_tool_choice_none)
134        {
135            names.extend(self.functions.iter().flat_map(tool_definition_names));
136        }
137        names.sort();
138        names.dedup();
139        names
140    }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
144pub struct ChatMessage {
145    pub role: String,
146    #[serde(default, deserialize_with = "deserialize_null_content")]
147    pub content: MessageContent,
148    /// Tool calls an `assistant` turn is requesting (OpenAI `tool_calls`).
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub tool_calls: Vec<ToolCall>,
151    /// For a `tool` role message: the id of the tool call this result answers.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub tool_call_id: Option<String>,
154    /// For a `tool` role message: the name of the tool that produced the result.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub name: Option<String>,
157    /// Ordered solver-thinking projection attached to assistant answers.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub thinking_steps: Vec<ThinkingStep>,
160    /// OpenAI-compatible reasoning text used by clients that display assistant
161    /// reasoning in a standard message field.
162    #[serde(default, skip_serializing_if = "String::is_empty")]
163    pub reasoning_content: String,
164    /// Alias consumed by some OpenAI-compatible clients for the same reasoning
165    /// text as `reasoning_content`.
166    #[serde(default, skip_serializing_if = "String::is_empty")]
167    pub reasoning: String,
168}
169
170impl ChatMessage {
171    /// A message with the given `role` and plain-text `content`.
172    #[must_use]
173    pub fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
174        Self {
175            role: role.into(),
176            content: MessageContent::Text(content.into()),
177            ..Self::default()
178        }
179    }
180
181    /// A `user` message carrying `text`.
182    #[must_use]
183    pub fn user(text: impl Into<String>) -> Self {
184        Self::new("user", text)
185    }
186
187    /// An `assistant` message carrying `text`.
188    #[must_use]
189    pub fn assistant(text: impl Into<String>) -> Self {
190        Self::new("assistant", text)
191    }
192
193    /// An `assistant` message requesting `tool_calls` (no textual content).
194    #[must_use]
195    pub fn assistant_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
196        Self {
197            role: String::from("assistant"),
198            tool_calls,
199            ..Self::default()
200        }
201    }
202
203    /// An `assistant` message that explains an action before requesting it.
204    ///
205    /// Tool-capable protocol surfaces preserve both fields, so agentic clients
206    /// can show the user what is about to happen while they execute the
207    /// machine-readable call (issue #781).
208    #[must_use]
209    pub fn assistant_tool_calls_with_content(
210        content: impl Into<String>,
211        tool_calls: Vec<ToolCall>,
212    ) -> Self {
213        Self {
214            role: String::from("assistant"),
215            content: MessageContent::Text(content.into()),
216            tool_calls,
217            ..Self::default()
218        }
219    }
220
221    /// A `tool` message carrying the `result` for the call `tool_call_id` made
222    /// against the tool `name`.
223    #[must_use]
224    pub fn tool_result(
225        tool_call_id: impl Into<String>,
226        name: impl Into<String>,
227        result: impl Into<String>,
228    ) -> Self {
229        Self {
230            role: String::from("tool"),
231            content: MessageContent::Text(result.into()),
232            tool_call_id: Some(tool_call_id.into()),
233            name: Some(name.into()),
234            ..Self::default()
235        }
236    }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(untagged)]
241pub enum MessageContent {
242    Text(String),
243    Parts(Vec<MessageContentPart>),
244}
245
246impl Default for MessageContent {
247    fn default() -> Self {
248        Self::Text(String::new())
249    }
250}
251
252fn message_content_tokens(content: &MessageContent) -> u32 {
253    match content {
254        MessageContent::Text(text) => estimate_tokens(text),
255        MessageContent::Parts(parts) => parts.iter().fold(0, |total, part| {
256            total.saturating_add(part.text.as_deref().map_or(0, estimate_tokens))
257        }),
258    }
259}
260
261/// Count only role-visible message content. Tool call names and arguments are
262/// excluded from input usage because they are protocol metadata, while tool
263/// result content is included like every other message body.
264fn message_input_tokens(messages: &[ChatMessage]) -> u32 {
265    messages.iter().fold(0, |total, message| {
266        total.saturating_add(message_content_tokens(&message.content))
267    })
268}
269
270/// Deserialize [`ChatMessage::content`], mapping an explicit JSON `null` to the
271/// default (empty text) instead of failing.
272///
273/// `MessageContent` is an untagged enum (`Text | Parts`) with no unit variant,
274/// so `#[serde(default)]` alone only covers an *absent* `content` key — an
275/// explicit `"content": null` is still handed to the untagged enum and fails
276/// with `data did not match any variant of untagged enum MessageContent`.
277/// `content: null` on an assistant tool-call turn is the standard OpenAI shape
278/// (emitted by e.g. Qwen Code), so we accept it by treating `null` as the
279/// default. See issue #682.
280fn deserialize_null_content<'de, D>(deserializer: D) -> Result<MessageContent, D::Error>
281where
282    D: serde::Deserializer<'de>,
283{
284    Ok(Option::<MessageContent>::deserialize(deserializer)?.unwrap_or_default())
285}
286
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct MessageContentPart {
289    #[serde(rename = "type")]
290    pub kind: String,
291    #[serde(default)]
292    pub text: Option<String>,
293}
294
295/// A tool call an assistant turn is requesting (OpenAI `tool_calls` shape).
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297pub struct ToolCall {
298    pub id: String,
299    #[serde(rename = "type", default = "tool_call_kind")]
300    pub kind: String,
301    pub function: FunctionCall,
302}
303
304fn tool_call_kind() -> String {
305    String::from("function")
306}
307
308impl ToolCall {
309    /// A `function` tool call with the given `id`, function `name` and
310    /// JSON-encoded `arguments`.
311    #[must_use]
312    pub fn function(
313        id: impl Into<String>,
314        name: impl Into<String>,
315        arguments: impl Into<String>,
316    ) -> Self {
317        Self {
318            id: id.into(),
319            kind: tool_call_kind(),
320            function: FunctionCall {
321                name: name.into(),
322                arguments: arguments.into(),
323            },
324        }
325    }
326}
327
328/// The function `name` plus JSON-encoded `arguments` of a [`ToolCall`].
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
330pub struct FunctionCall {
331    pub name: String,
332    pub arguments: String,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
336pub struct ChatCompletion {
337    pub id: String,
338    pub object: String,
339    pub created: u64,
340    pub model: String,
341    pub choices: Vec<ChatChoice>,
342    pub usage: TokenUsage,
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
346pub struct ChatChoice {
347    pub index: u32,
348    pub message: ChatMessage,
349    pub finish_reason: String,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct TokenUsage {
354    pub prompt_tokens: u32,
355    pub completion_tokens: u32,
356    pub total_tokens: u32,
357}
358
359#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
360pub struct ResponsesRequest {
361    #[serde(default)]
362    pub model: Option<String>,
363    #[serde(default)]
364    pub input: Value,
365    #[serde(default)]
366    pub instructions: Option<String>,
367    #[serde(default)]
368    pub temperature: Option<f32>,
369    #[serde(default)]
370    pub stream: bool,
371    /// Function tools advertised on the Responses surface (`{type:"function",
372    /// name, parameters}` — flatter than Chat Completions). Issue #468.
373    #[serde(default, skip_serializing_if = "Vec::is_empty")]
374    pub tools: Vec<Value>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub tool_choice: Option<Value>,
377}
378
379impl ResponsesRequest {
380    /// Translate the Responses envelope into the shared [`ChatCompletionRequest`]
381    /// so the agentic planner (issue #468) drives `/v1/responses` exactly as it
382    /// drives `/v1/chat/completions`. `instructions` becomes a leading `system`
383    /// message; `input` items become chat messages, with `function_call` items
384    /// mapped to assistant `tool_calls` and `function_call_output` items to `tool`
385    /// results (so the planner can track progress); advertised `tools` /
386    /// `tool_choice` pass straight through (the planner reads names from either the
387    /// flat Responses shape or the nested Chat shape).
388    #[must_use]
389    pub fn to_chat_completion_request(&self) -> ChatCompletionRequest {
390        let mut messages = Vec::new();
391        if let Some(instructions) = self.instructions.as_deref() {
392            if !instructions.trim().is_empty() {
393                messages.push(ChatMessage::new("system", instructions.trim()));
394            }
395        }
396        let mut tool_names_by_id: HashMap<String, String> = HashMap::new();
397        append_response_input(&self.input, &mut messages, &mut tool_names_by_id);
398        ChatCompletionRequest {
399            model: self.model.clone(),
400            messages,
401            temperature: self.temperature,
402            stream: false,
403            tools: self.tools.clone(),
404            tool_choice: self.tool_choice.clone(),
405            functions: Vec::new(),
406            function_call: None,
407            stream_options: None,
408        }
409    }
410}
411
412fn responses_input_tokens(request: &ResponsesRequest) -> u32 {
413    message_input_tokens(&request.to_chat_completion_request().messages)
414}
415
416/// Append the Responses `input` (a bare string, a single item, or an array of
417/// items) to the chat `messages` being built, threading a `call_id → tool name`
418/// map so each `function_call_output` can be labelled with the tool that produced
419/// it (the planner resolves capabilities by message `name` first).
420fn append_response_input(
421    input: &Value,
422    out: &mut Vec<ChatMessage>,
423    tool_names_by_id: &mut HashMap<String, String>,
424) {
425    match input {
426        Value::String(text) => {
427            if !text.trim().is_empty() {
428                out.push(ChatMessage::user(text.clone()));
429            }
430        }
431        Value::Array(items) => {
432            for item in items {
433                append_response_item(item, out, tool_names_by_id);
434            }
435        }
436        Value::Object(_) => append_response_item(input, out, tool_names_by_id),
437        _ => {}
438    }
439}
440
441/// Append a single Responses `input` item — a message, a `function_call`, or a
442/// `function_call_output` — to `out` as the equivalent chat message(s).
443fn append_response_item(
444    item: &Value,
445    out: &mut Vec<ChatMessage>,
446    tool_names_by_id: &mut HashMap<String, String>,
447) {
448    let item_type = item
449        .get("type")
450        .and_then(Value::as_str)
451        .unwrap_or("message");
452    match item_type {
453        "function_call" => {
454            let call_id = item
455                .get("call_id")
456                .or_else(|| item.get("id"))
457                .and_then(Value::as_str)
458                .unwrap_or_default()
459                .to_owned();
460            let name = item
461                .get("name")
462                .and_then(Value::as_str)
463                .unwrap_or_default()
464                .to_owned();
465            let arguments = item
466                .get("arguments")
467                .and_then(Value::as_str)
468                .unwrap_or("{}")
469                .to_owned();
470            if !name.is_empty() {
471                tool_names_by_id.insert(call_id.clone(), name.clone());
472            }
473            out.push(ChatMessage::assistant_tool_calls(vec![ToolCall::function(
474                call_id, name, arguments,
475            )]));
476        }
477        "function_call_output" => {
478            let call_id = item
479                .get("call_id")
480                .and_then(Value::as_str)
481                .unwrap_or_default()
482                .to_owned();
483            let output = item
484                .get("output")
485                .map_or_else(String::new, value_to_prompt_text);
486            let name = tool_names_by_id.get(&call_id).cloned();
487            out.push(ChatMessage {
488                role: String::from("tool"),
489                content: MessageContent::Text(output),
490                tool_call_id: Some(call_id),
491                name,
492                ..ChatMessage::default()
493            });
494        }
495        _ => {
496            let role = item
497                .get("role")
498                .and_then(Value::as_str)
499                .unwrap_or("user")
500                .to_owned();
501            let content = item
502                .get("content")
503                .map_or_else(String::new, value_to_prompt_text);
504            if !content.trim().is_empty() {
505                out.push(ChatMessage::new(role, content));
506            }
507        }
508    }
509}
510
511#[must_use]
512pub fn create_chat_completion(request: &ChatCompletionRequest) -> ChatCompletion {
513    create_chat_completion_with_solver(request, &UniversalSolver::default())
514}
515
516#[must_use]
517pub fn create_chat_completion_with_solver(
518    request: &ChatCompletionRequest,
519    solver: &UniversalSolver,
520) -> ChatCompletion {
521    create_chat_completion_with_solver_and_memory(request, solver, &[])
522}
523
524#[must_use]
525pub fn create_chat_completion_with_solver_and_memory(
526    request: &ChatCompletionRequest,
527    solver: &UniversalSolver,
528    memory_events: &[MemoryEvent],
529) -> ChatCompletion {
530    let (prompt, history) = chat_prompt_and_history(&request.messages);
531
532    match agentic_outcome(request, solver.config.agent_mode) {
533        AgenticOutcome::Refused(answer) => {
534            return chat_completion_from_symbolic(request, &prompt, answer)
535        }
536        AgenticOutcome::Planned(plan) => {
537            return chat_completion_from_plan(request, &prompt, plan, memory_events)
538        }
539        AgenticOutcome::Fallthrough => {}
540    }
541
542    if let Some(mut symbolic_answer) =
543        answer_from_memory_if_requested(&prompt, &history, memory_events)
544    {
545        // Memory-recall answers honour standing requirements too — recall must
546        // not become a side door around retained learning.
547        apply_retained_amendments(&prompt, &mut symbolic_answer, memory_events);
548        return chat_completion_from_symbolic(request, &prompt, symbolic_answer);
549    }
550
551    // Standing requirements are injected into the solving context itself (as if
552    // the user restated them), not appended after the fact.
553    let symbolic_answer =
554        solve_with_standing_requirements(solver, &prompt, &history, memory_events);
555    if let Some(plan) = command_reroute_plan(request, solver.config.agent_mode, &symbolic_answer) {
556        return chat_completion_from_plan(request, &prompt, plan, memory_events);
557    }
558    chat_completion_from_symbolic(request, &prompt, symbolic_answer)
559}
560
561fn command_reroute_plan(
562    request: &ChatCompletionRequest,
563    agent_mode: bool,
564    symbolic_answer: &SymbolicAnswer,
565) -> Option<AgenticPlan> {
566    if !agent_mode || !request.requests_tool_execution() {
567        return None;
568    }
569    let owned_names = request.requested_tool_names();
570    let tool_names: Vec<&str> = owned_names.iter().map(String::as_str).collect();
571    plan_symbolic_command_reroute(&request.messages, &tool_names, symbolic_answer)
572}
573
574/// The deterministic agentic decision for a tool-bearing request. Shared by every
575/// OpenAI-shaped surface — `/v1/chat/completions`, `/v1/messages`, and
576/// `/v1/responses` — so the issue #468 agentic loop behaves identically across all
577/// three. Pure and deterministic: same `request` + `agent_mode` ⇒ same outcome.
578enum AgenticOutcome {
579    /// Tools were requested but refused — either agent mode is off (the real
580    /// guard) or a requested tool failed the per-tool permission gate.
581    Refused(SymbolicAnswer),
582    /// The planner produced the next deterministic step (`tool_calls` or final).
583    Planned(AgenticPlan),
584    /// Not an agentic request — no tools were requested, or the task is not one
585    /// the planner recognises — so the caller should answer symbolically.
586    Fallthrough,
587}
588
589/// Decide the [`AgenticOutcome`] for `request` under the given `agent_mode`,
590/// applying the two gates (agent mode, then per-tool permissions) before letting
591/// [`plan_chat_step`] drive the loop. An unrecognised task yields
592/// [`AgenticOutcome::Fallthrough`] so ordinary chat stays untouched.
593fn agentic_outcome(request: &ChatCompletionRequest, agent_mode: bool) -> AgenticOutcome {
594    let trace = std::env::var("FORMAL_AI_TRACE_REQUESTS").as_deref() == Ok("1");
595    if !request.requests_tool_execution() {
596        if trace {
597            eprintln!("[trace] agentic_outcome: fallthrough (no tool execution requested)");
598        }
599        return AgenticOutcome::Fallthrough;
600    }
601    if !agent_mode {
602        if trace {
603            eprintln!("[trace] agentic_outcome: refused (agent_mode off)");
604        }
605        return AgenticOutcome::Refused(tool_call_refusal_answer());
606    }
607    let owned_names = request.requested_tool_names();
608    if trace {
609        eprintln!(
610            "[trace] agentic_outcome: {} advertised tools: {owned_names:?}",
611            owned_names.len()
612        );
613    }
614    if let Some(denial) = agentic_tool_permission_denial(&owned_names) {
615        if trace {
616            eprintln!("[trace] agentic_outcome: refused by permission gate: {denial:?}");
617        }
618        return AgenticOutcome::Refused(tool_permission_refusal_answer(&denial));
619    }
620    // Agent mode with tools permitted: the deterministic agentic planner drives
621    // the loop, emitting `tool_calls` or a final answer. An unrecognised task
622    // yields `None` and falls through to the solver.
623    let tool_names: Vec<&str> = owned_names.iter().map(String::as_str).collect();
624    let outcome = plan_chat_step(&request.messages, &tool_names)
625        .map_or(AgenticOutcome::Fallthrough, AgenticOutcome::Planned);
626    if trace {
627        match &outcome {
628            AgenticOutcome::Planned(plan) => eprintln!("[trace] agentic_outcome: planned {plan:?}"),
629            AgenticOutcome::Fallthrough => {
630                eprintln!("[trace] agentic_outcome: fallthrough (task unrecognised)");
631            }
632            AgenticOutcome::Refused(_) => {}
633        }
634    }
635    outcome
636}
637
638/// Build a chat completion from a deterministic [`AgenticPlan`]. A
639/// [`AgenticPlan::ToolCalls`] plan emits an assistant turn carrying `tool_calls`
640/// with `finish_reason: "tool_calls"`; a [`AgenticPlan::Final`] plan emits plain
641/// assistant text with `finish_reason: "stop"`.
642fn chat_completion_from_plan(
643    request: &ChatCompletionRequest,
644    prompt: &str,
645    plan: AgenticPlan,
646    memory_events: &[MemoryEvent],
647) -> ChatCompletion {
648    let model = resolved_request_model(request.model.as_deref());
649    let prompt_tokens = message_input_tokens(&request.messages);
650
651    let (message, finish_reason, completion_tokens) = match plan {
652        AgenticPlan::ToolCalls(calls) => {
653            let narration = tool_action_narration(prompt, &calls);
654            let tool_calls: Vec<_> = calls
655                .into_iter()
656                .enumerate()
657                .map(|(index, call)| {
658                    let seed = format!("{prompt}|{index}|{}|{}", call.tool, call.arguments);
659                    let arguments = response_arguments_for_tool(
660                        &request.tools,
661                        &call.tool,
662                        call.arguments,
663                        prompt,
664                    );
665                    ToolCall::function(stable_id("call", &seed), call.tool, arguments)
666                })
667                .collect();
668            let completion_tokens = estimate_tokens(&narration).saturating_add(
669                tool_calls
670                    .iter()
671                    .map(|call| {
672                        estimate_tokens(&call.function.name)
673                            .saturating_add(estimate_tokens(&call.function.arguments))
674                    })
675                    .sum(),
676            );
677            (
678                ChatMessage::assistant_tool_calls_with_content(narration, tool_calls),
679                String::from("tool_calls"),
680                completion_tokens,
681            )
682        }
683        AgenticPlan::Final(answer) => {
684            let answer = amended_answer(prompt, &answer, memory_events);
685            let completion_tokens = estimate_tokens(&answer);
686            (
687                ChatMessage::assistant(answer),
688                String::from("stop"),
689                completion_tokens,
690            )
691        }
692    };
693
694    ChatCompletion {
695        id: stable_id("chatcmpl", prompt),
696        object: String::from("chat.completion"),
697        created: response_timestamp(),
698        model,
699        choices: vec![ChatChoice {
700            index: 0,
701            message,
702            finish_reason,
703        }],
704        usage: TokenUsage {
705            prompt_tokens,
706            completion_tokens,
707            total_tokens: prompt_tokens.saturating_add(completion_tokens),
708        },
709    }
710}
711
712fn chat_completion_from_symbolic(
713    request: &ChatCompletionRequest,
714    prompt: &str,
715    symbolic_answer: SymbolicAnswer,
716) -> ChatCompletion {
717    let model = resolved_request_model(request.model.as_deref());
718    let prompt_tokens = message_input_tokens(&request.messages);
719    let completion_tokens = estimate_tokens(&symbolic_answer.answer);
720    let thinking_steps = symbolic_answer.thinking_steps;
721    let reasoning = render_thinking_steps(&thinking_steps);
722    let mut message = ChatMessage::assistant(symbolic_answer.answer);
723    message.thinking_steps = thinking_steps;
724    message.reasoning_content.clone_from(&reasoning);
725    message.reasoning = reasoning;
726
727    ChatCompletion {
728        id: stable_id("chatcmpl", prompt),
729        object: String::from("chat.completion"),
730        created: response_timestamp(),
731        model,
732        choices: vec![ChatChoice {
733            index: 0,
734            message,
735            finish_reason: String::from("stop"),
736        }],
737        usage: TokenUsage {
738            prompt_tokens,
739            completion_tokens,
740            total_tokens: prompt_tokens.saturating_add(completion_tokens),
741        },
742    }
743}
744
745#[must_use]
746pub fn create_response(request: &ResponsesRequest) -> ResponseObject {
747    let prompt = response_prompt(request);
748    let symbolic_answer = FormalAiEngine.answer(&prompt);
749    response_from_symbolic(request, &prompt, symbolic_answer)
750}
751
752#[must_use]
753pub fn create_response_with_solver(
754    request: &ResponsesRequest,
755    solver: &UniversalSolver,
756) -> ResponseObject {
757    create_response_with_solver_and_memory(request, solver, &[])
758}
759
760#[must_use]
761pub fn create_response_with_solver_and_memory(
762    request: &ResponsesRequest,
763    solver: &UniversalSolver,
764    memory_events: &[MemoryEvent],
765) -> ResponseObject {
766    let prompt = response_prompt(request);
767    let chat_request = request.to_chat_completion_request();
768    match agentic_outcome(&chat_request, solver.config.agent_mode) {
769        AgenticOutcome::Refused(answer) => return response_from_symbolic(request, &prompt, answer),
770        AgenticOutcome::Planned(plan) => {
771            return response_from_plan(request, &prompt, plan, memory_events)
772        }
773        AgenticOutcome::Fallthrough => {}
774    }
775    let (memory_prompt, history) = chat_prompt_and_history(&chat_request.messages);
776    let memory_prompt = if memory_prompt.trim().is_empty() {
777        prompt.as_str()
778    } else {
779        memory_prompt.as_str()
780    };
781    if let Some(mut symbolic_answer) =
782        answer_from_memory_if_requested(memory_prompt, &history, memory_events)
783    {
784        apply_retained_amendments(&prompt, &mut symbolic_answer, memory_events);
785        return response_from_symbolic(request, &prompt, symbolic_answer);
786    }
787    let symbolic_answer =
788        solve_with_standing_requirements(solver, memory_prompt, &history, memory_events);
789    if let Some(plan) =
790        command_reroute_plan(&chat_request, solver.config.agent_mode, &symbolic_answer)
791    {
792        return response_from_plan(request, &prompt, plan, memory_events);
793    }
794    response_from_symbolic(request, &prompt, symbolic_answer)
795}
796
797/// Build a Responses object from a deterministic [`AgenticPlan`] — the Responses
798/// mirror of [`chat_completion_from_plan`]. A [`AgenticPlan::ToolCalls`] plan emits
799/// `function_call` output items (the same stable `call` ids the chat surface uses);
800/// a [`AgenticPlan::Final`] plan emits a single assistant message.
801fn response_from_plan(
802    request: &ResponsesRequest,
803    prompt: &str,
804    plan: AgenticPlan,
805    memory_events: &[MemoryEvent],
806) -> ResponseObject {
807    let model = resolved_request_model(request.model.as_deref());
808    let input_tokens = responses_input_tokens(request);
809
810    let (output, output_tokens) = match plan {
811        AgenticPlan::ToolCalls(calls) => {
812            let narration = tool_action_narration(prompt, &calls);
813            let mut items = Vec::with_capacity(calls.len().saturating_add(1));
814            let mut output_tokens = estimate_tokens(&narration);
815            items.push(ResponseOutputItem::Message(ResponseOutputMessage {
816                id: stable_id("msg", &narration),
817                kind: String::from("message"),
818                role: String::from("assistant"),
819                content: vec![ResponseOutputContent {
820                    kind: String::from("output_text"),
821                    text: narration,
822                }],
823                thinking_steps: Vec::new(),
824            }));
825            for (index, call) in calls.into_iter().enumerate() {
826                let tool = call.tool;
827                let planned_arguments = call.arguments;
828                let seed = format!("{prompt}|{index}|{tool}|{planned_arguments}");
829                let arguments =
830                    response_arguments_for_tool(&request.tools, &tool, planned_arguments, prompt);
831                output_tokens = output_tokens.saturating_add(
832                    estimate_tokens(&tool).saturating_add(estimate_tokens(&arguments)),
833                );
834                if request
835                    .tools
836                    .iter()
837                    .any(|definition| is_hosted_tool_definition(definition, &tool))
838                    && tool == "web_search"
839                {
840                    let query = serde_json::from_str::<Value>(&arguments)
841                        .ok()
842                        .and_then(|value| {
843                            value
844                                .get("query")
845                                .and_then(Value::as_str)
846                                .map(str::to_owned)
847                        })
848                        .unwrap_or_else(|| prompt.to_owned());
849                    items.push(ResponseOutputItem::WebSearchCall(
850                        ResponseWebSearchToolCall {
851                            id: stable_id("ws", &seed),
852                            kind: String::from("web_search_call"),
853                            status: String::from("completed"),
854                            action: ResponseWebSearchAction {
855                                kind: String::from("search"),
856                                queries: vec![query.clone()],
857                                query,
858                            },
859                        },
860                    ));
861                } else {
862                    let (name, namespace) = response_tool_call_identity(&request.tools, &tool);
863                    items.push(ResponseOutputItem::FunctionCall(ResponseFunctionToolCall {
864                        id: stable_id("fc", &seed),
865                        kind: function_call_kind(),
866                        call_id: stable_id("call", &seed),
867                        name,
868                        namespace,
869                        arguments,
870                        status: String::from("completed"),
871                    }));
872                }
873            }
874            (items, output_tokens)
875        }
876        AgenticPlan::Final(answer) => {
877            let answer = amended_answer(prompt, &answer, memory_events);
878            let output_tokens = estimate_tokens(&answer);
879            let message = ResponseOutputItem::Message(ResponseOutputMessage {
880                id: stable_id("msg", &answer),
881                kind: String::from("message"),
882                role: String::from("assistant"),
883                content: vec![ResponseOutputContent {
884                    kind: String::from("output_text"),
885                    text: answer,
886                }],
887                thinking_steps: Vec::new(),
888            });
889            (vec![message], output_tokens)
890        }
891    };
892
893    ResponseObject {
894        id: stable_id("resp", prompt),
895        object: String::from("response"),
896        created_at: response_timestamp(),
897        status: String::from("completed"),
898        model,
899        output,
900        usage: ResponseUsage {
901            input_tokens,
902            output_tokens,
903            total_tokens: input_tokens.saturating_add(output_tokens),
904        },
905        evidence_links: Vec::new(),
906        thinking_steps: Vec::new(),
907    }
908}
909
910fn response_from_symbolic(
911    request: &ResponsesRequest,
912    prompt: &str,
913    symbolic_answer: SymbolicAnswer,
914) -> ResponseObject {
915    let model = resolved_request_model(request.model.as_deref());
916    let input_tokens = responses_input_tokens(request);
917    let output_tokens = estimate_tokens(&symbolic_answer.answer);
918    let answer = symbolic_answer.answer;
919    let thinking_steps = symbolic_answer.thinking_steps;
920    let mut output = vec![ResponseOutputItem::Message(ResponseOutputMessage {
921        id: stable_id("msg", &answer),
922        kind: String::from("message"),
923        role: String::from("assistant"),
924        content: vec![ResponseOutputContent {
925            kind: String::from("output_text"),
926            text: answer,
927        }],
928        thinking_steps: thinking_steps.clone(),
929    })];
930    if let Some(reasoning) = response_reasoning_item(prompt, &thinking_steps) {
931        output.push(reasoning);
932    }
933
934    ResponseObject {
935        id: stable_id("resp", prompt),
936        object: String::from("response"),
937        created_at: response_timestamp(),
938        status: String::from("completed"),
939        model,
940        output,
941        usage: ResponseUsage {
942            input_tokens,
943            output_tokens,
944            total_tokens: input_tokens.saturating_add(output_tokens),
945        },
946        evidence_links: symbolic_answer.evidence_links,
947        thinking_steps,
948    }
949}
950
951fn response_reasoning_item(
952    prompt: &str,
953    thinking_steps: &[ThinkingStep],
954) -> Option<ResponseOutputItem> {
955    let text = render_thinking_steps(thinking_steps);
956    if text.is_empty() {
957        return None;
958    }
959    Some(ResponseOutputItem::Reasoning(ResponseReasoningItem {
960        id: stable_id("rs", prompt),
961        kind: String::from("reasoning"),
962        summary: vec![ResponseReasoningSummaryText {
963            kind: String::from("summary_text"),
964            text,
965        }],
966    }))
967}