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    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub learning_trace: Option<Value>,
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
348pub struct ChatChoice {
349    pub index: u32,
350    pub message: ChatMessage,
351    pub finish_reason: String,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct TokenUsage {
356    pub prompt_tokens: u32,
357    pub completion_tokens: u32,
358    pub total_tokens: u32,
359}
360
361#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
362pub struct ResponsesRequest {
363    #[serde(default)]
364    pub model: Option<String>,
365    #[serde(default)]
366    pub input: Value,
367    #[serde(default)]
368    pub instructions: Option<String>,
369    #[serde(default)]
370    pub temperature: Option<f32>,
371    #[serde(default)]
372    pub stream: bool,
373    /// Function tools advertised on the Responses surface (`{type:"function",
374    /// name, parameters}` — flatter than Chat Completions). Issue #468.
375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
376    pub tools: Vec<Value>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub tool_choice: Option<Value>,
379}
380
381impl ResponsesRequest {
382    /// Translate the Responses envelope into the shared [`ChatCompletionRequest`]
383    /// so the agentic planner (issue #468) drives `/v1/responses` exactly as it
384    /// drives `/v1/chat/completions`. `instructions` becomes a leading `system`
385    /// message; `input` items become chat messages, with `function_call` items
386    /// mapped to assistant `tool_calls` and `function_call_output` items to `tool`
387    /// results (so the planner can track progress); advertised `tools` /
388    /// `tool_choice` pass straight through (the planner reads names from either the
389    /// flat Responses shape or the nested Chat shape).
390    #[must_use]
391    pub fn to_chat_completion_request(&self) -> ChatCompletionRequest {
392        let mut messages = Vec::new();
393        if let Some(instructions) = self.instructions.as_deref() {
394            if !instructions.trim().is_empty() {
395                messages.push(ChatMessage::new("system", instructions.trim()));
396            }
397        }
398        let mut tool_names_by_id: HashMap<String, String> = HashMap::new();
399        append_response_input(&self.input, &mut messages, &mut tool_names_by_id);
400        ChatCompletionRequest {
401            model: self.model.clone(),
402            messages,
403            temperature: self.temperature,
404            stream: false,
405            tools: self.tools.clone(),
406            tool_choice: self.tool_choice.clone(),
407            functions: Vec::new(),
408            function_call: None,
409            stream_options: None,
410        }
411    }
412}
413
414fn responses_input_tokens(request: &ResponsesRequest) -> u32 {
415    message_input_tokens(&request.to_chat_completion_request().messages)
416}
417
418/// Append the Responses `input` (a bare string, a single item, or an array of
419/// items) to the chat `messages` being built, threading a `call_id → tool name`
420/// map so each `function_call_output` can be labelled with the tool that produced
421/// it (the planner resolves capabilities by message `name` first).
422fn append_response_input(
423    input: &Value,
424    out: &mut Vec<ChatMessage>,
425    tool_names_by_id: &mut HashMap<String, String>,
426) {
427    match input {
428        Value::String(text) => {
429            if !text.trim().is_empty() {
430                out.push(ChatMessage::user(text.clone()));
431            }
432        }
433        Value::Array(items) => {
434            for item in items {
435                append_response_item(item, out, tool_names_by_id);
436            }
437        }
438        Value::Object(_) => append_response_item(input, out, tool_names_by_id),
439        _ => {}
440    }
441}
442
443/// Append a single Responses `input` item — a message, a `function_call`, or a
444/// `function_call_output` — to `out` as the equivalent chat message(s).
445fn append_response_item(
446    item: &Value,
447    out: &mut Vec<ChatMessage>,
448    tool_names_by_id: &mut HashMap<String, String>,
449) {
450    let item_type = item
451        .get("type")
452        .and_then(Value::as_str)
453        .unwrap_or("message");
454    match item_type {
455        "function_call" => {
456            let call_id = item
457                .get("call_id")
458                .or_else(|| item.get("id"))
459                .and_then(Value::as_str)
460                .unwrap_or_default()
461                .to_owned();
462            let name = item
463                .get("name")
464                .and_then(Value::as_str)
465                .unwrap_or_default()
466                .to_owned();
467            let arguments = item
468                .get("arguments")
469                .and_then(Value::as_str)
470                .unwrap_or("{}")
471                .to_owned();
472            if !name.is_empty() {
473                tool_names_by_id.insert(call_id.clone(), name.clone());
474            }
475            out.push(ChatMessage::assistant_tool_calls(vec![ToolCall::function(
476                call_id, name, arguments,
477            )]));
478        }
479        "function_call_output" => {
480            let call_id = item
481                .get("call_id")
482                .and_then(Value::as_str)
483                .unwrap_or_default()
484                .to_owned();
485            let output = item
486                .get("output")
487                .map_or_else(String::new, value_to_prompt_text);
488            let name = tool_names_by_id.get(&call_id).cloned();
489            out.push(ChatMessage {
490                role: String::from("tool"),
491                content: MessageContent::Text(output),
492                tool_call_id: Some(call_id),
493                name,
494                ..ChatMessage::default()
495            });
496        }
497        _ => {
498            let role = item
499                .get("role")
500                .and_then(Value::as_str)
501                .unwrap_or("user")
502                .to_owned();
503            let content = item
504                .get("content")
505                .map_or_else(String::new, value_to_prompt_text);
506            if !content.trim().is_empty() {
507                out.push(ChatMessage::new(role, content));
508            }
509        }
510    }
511}
512
513#[must_use]
514pub fn create_chat_completion(request: &ChatCompletionRequest) -> ChatCompletion {
515    create_chat_completion_with_solver(request, &UniversalSolver::default())
516}
517
518#[must_use]
519pub fn create_chat_completion_with_solver(
520    request: &ChatCompletionRequest,
521    solver: &UniversalSolver,
522) -> ChatCompletion {
523    create_chat_completion_with_solver_and_memory(request, solver, &[])
524}
525
526#[must_use]
527pub fn create_chat_completion_with_solver_and_memory(
528    request: &ChatCompletionRequest,
529    solver: &UniversalSolver,
530    memory_events: &[MemoryEvent],
531) -> ChatCompletion {
532    let (prompt, history) = chat_prompt_and_history(&request.messages);
533
534    match agentic_outcome(request, solver.config.agent_mode) {
535        AgenticOutcome::Refused(answer) => {
536            return chat_completion_from_symbolic(request, &prompt, answer)
537        }
538        AgenticOutcome::Planned(plan) => {
539            return chat_completion_from_plan(request, &prompt, plan, memory_events)
540        }
541        AgenticOutcome::Fallthrough => {}
542    }
543
544    if let Some(mut symbolic_answer) =
545        answer_from_memory_if_requested(&prompt, &history, memory_events)
546    {
547        // Memory-recall answers honour standing requirements too — recall must
548        // not become a side door around retained learning.
549        apply_retained_amendments(&prompt, &mut symbolic_answer, memory_events);
550        return chat_completion_from_symbolic(request, &prompt, symbolic_answer);
551    }
552
553    // Standing requirements are injected into the solving context itself (as if
554    // the user restated them), not appended after the fact.
555    let symbolic_answer =
556        solve_with_standing_requirements(solver, &prompt, &history, memory_events);
557    if let Some(plan) = command_reroute_plan(request, solver.config.agent_mode, &symbolic_answer) {
558        return chat_completion_from_plan(request, &prompt, plan, memory_events);
559    }
560    chat_completion_from_symbolic(request, &prompt, symbolic_answer)
561}
562
563fn command_reroute_plan(
564    request: &ChatCompletionRequest,
565    agent_mode: bool,
566    symbolic_answer: &SymbolicAnswer,
567) -> Option<AgenticPlan> {
568    if !agent_mode || !request.requests_tool_execution() {
569        return None;
570    }
571    let owned_names = request.requested_tool_names();
572    let tool_names: Vec<&str> = owned_names.iter().map(String::as_str).collect();
573    plan_symbolic_command_reroute(&request.messages, &tool_names, symbolic_answer)
574}
575
576/// The deterministic agentic decision for a tool-bearing request. Shared by every
577/// OpenAI-shaped surface — `/v1/chat/completions`, `/v1/messages`, and
578/// `/v1/responses` — so the issue #468 agentic loop behaves identically across all
579/// three. Pure and deterministic: same `request` + `agent_mode` ⇒ same outcome.
580enum AgenticOutcome {
581    /// Tools were requested but refused — either agent mode is off (the real
582    /// guard) or a requested tool failed the per-tool permission gate.
583    Refused(SymbolicAnswer),
584    /// The planner produced the next deterministic step (`tool_calls` or final).
585    Planned(AgenticPlan),
586    /// Not an agentic request — no tools were requested, or the task is not one
587    /// the planner recognises — so the caller should answer symbolically.
588    Fallthrough,
589}
590
591/// Decide the [`AgenticOutcome`] for `request` under the given `agent_mode`,
592/// applying the two gates (agent mode, then per-tool permissions) before letting
593/// [`plan_chat_step`] drive the loop. An unrecognised task yields
594/// [`AgenticOutcome::Fallthrough`] so ordinary chat stays untouched.
595fn agentic_outcome(request: &ChatCompletionRequest, agent_mode: bool) -> AgenticOutcome {
596    let trace = std::env::var("FORMAL_AI_TRACE_REQUESTS").as_deref() == Ok("1");
597    if !request.requests_tool_execution() {
598        if trace {
599            eprintln!("[trace] agentic_outcome: fallthrough (no tool execution requested)");
600        }
601        return AgenticOutcome::Fallthrough;
602    }
603    if !agent_mode {
604        if trace {
605            eprintln!("[trace] agentic_outcome: refused (agent_mode off)");
606        }
607        return AgenticOutcome::Refused(tool_call_refusal_answer());
608    }
609    let owned_names = request.requested_tool_names();
610    if trace {
611        eprintln!(
612            "[trace] agentic_outcome: {} advertised tools: {owned_names:?}",
613            owned_names.len()
614        );
615    }
616    if let Some(denial) = agentic_tool_permission_denial(&owned_names) {
617        if trace {
618            eprintln!("[trace] agentic_outcome: refused by permission gate: {denial:?}");
619        }
620        return AgenticOutcome::Refused(tool_permission_refusal_answer(&denial));
621    }
622    // Agent mode with tools permitted: the deterministic agentic planner drives
623    // the loop, emitting `tool_calls` or a final answer. An unrecognised task
624    // yields `None` and falls through to the solver.
625    let tool_names: Vec<&str> = owned_names.iter().map(String::as_str).collect();
626    let outcome = plan_chat_step(&request.messages, &tool_names)
627        .map_or(AgenticOutcome::Fallthrough, AgenticOutcome::Planned);
628    if trace {
629        match &outcome {
630            AgenticOutcome::Planned(plan) => eprintln!("[trace] agentic_outcome: planned {plan:?}"),
631            AgenticOutcome::Fallthrough => {
632                eprintln!("[trace] agentic_outcome: fallthrough (task unrecognised)");
633            }
634            AgenticOutcome::Refused(_) => {}
635        }
636    }
637    outcome
638}
639
640/// Build a chat completion from a deterministic [`AgenticPlan`]. A
641/// [`AgenticPlan::ToolCalls`] plan emits an assistant turn carrying `tool_calls`
642/// with `finish_reason: "tool_calls"`; a [`AgenticPlan::Final`] plan emits plain
643/// assistant text with `finish_reason: "stop"`.
644fn chat_completion_from_plan(
645    request: &ChatCompletionRequest,
646    prompt: &str,
647    plan: AgenticPlan,
648    memory_events: &[MemoryEvent],
649) -> ChatCompletion {
650    let model = resolved_request_model(request.model.as_deref());
651    let prompt_tokens = message_input_tokens(&request.messages);
652
653    let (message, finish_reason, completion_tokens) = match plan {
654        AgenticPlan::ToolCalls(calls) => {
655            let narration = tool_action_narration(prompt, &calls);
656            let tool_calls: Vec<_> = calls
657                .into_iter()
658                .enumerate()
659                .map(|(index, call)| {
660                    let seed = format!("{prompt}|{index}|{}|{}", call.tool, call.arguments);
661                    let arguments = response_arguments_for_tool(
662                        &request.tools,
663                        &call.tool,
664                        call.arguments,
665                        prompt,
666                    );
667                    ToolCall::function(stable_id("call", &seed), call.tool, arguments)
668                })
669                .collect();
670            let completion_tokens = estimate_tokens(&narration).saturating_add(
671                tool_calls
672                    .iter()
673                    .map(|call| {
674                        estimate_tokens(&call.function.name)
675                            .saturating_add(estimate_tokens(&call.function.arguments))
676                    })
677                    .sum(),
678            );
679            (
680                ChatMessage::assistant_tool_calls_with_content(narration, tool_calls),
681                String::from("tool_calls"),
682                completion_tokens,
683            )
684        }
685        AgenticPlan::Final(answer) => {
686            let answer = amended_answer(prompt, &answer, memory_events);
687            let completion_tokens = estimate_tokens(&answer);
688            (
689                ChatMessage::assistant(answer),
690                String::from("stop"),
691                completion_tokens,
692            )
693        }
694    };
695
696    ChatCompletion {
697        id: stable_id("chatcmpl", prompt),
698        object: String::from("chat.completion"),
699        created: response_timestamp(),
700        model,
701        choices: vec![ChatChoice {
702            index: 0,
703            message,
704            finish_reason,
705        }],
706        usage: TokenUsage {
707            prompt_tokens,
708            completion_tokens,
709            total_tokens: prompt_tokens.saturating_add(completion_tokens),
710        },
711        learning_trace: None,
712    }
713}
714
715fn chat_completion_from_symbolic(
716    request: &ChatCompletionRequest,
717    prompt: &str,
718    symbolic_answer: SymbolicAnswer,
719) -> ChatCompletion {
720    let model = resolved_request_model(request.model.as_deref());
721    let prompt_tokens = message_input_tokens(&request.messages);
722    let completion_tokens = estimate_tokens(&symbolic_answer.answer);
723    let learning_trace =
724        crate::self_improvement::learning_trace_from_symbolic_answer(prompt, &symbolic_answer);
725    let thinking_steps = symbolic_answer.thinking_steps;
726    let reasoning = render_thinking_steps(&thinking_steps);
727    let mut message = ChatMessage::assistant(symbolic_answer.answer);
728    message.thinking_steps = thinking_steps;
729    message.reasoning_content.clone_from(&reasoning);
730    message.reasoning = reasoning;
731
732    ChatCompletion {
733        id: stable_id("chatcmpl", prompt),
734        object: String::from("chat.completion"),
735        created: response_timestamp(),
736        model,
737        choices: vec![ChatChoice {
738            index: 0,
739            message,
740            finish_reason: String::from("stop"),
741        }],
742        usage: TokenUsage {
743            prompt_tokens,
744            completion_tokens,
745            total_tokens: prompt_tokens.saturating_add(completion_tokens),
746        },
747        learning_trace,
748    }
749}
750
751#[must_use]
752pub fn create_response(request: &ResponsesRequest) -> ResponseObject {
753    let prompt = response_prompt(request);
754    let symbolic_answer = FormalAiEngine.answer(&prompt);
755    response_from_symbolic(request, &prompt, symbolic_answer)
756}
757
758#[must_use]
759pub fn create_response_with_solver(
760    request: &ResponsesRequest,
761    solver: &UniversalSolver,
762) -> ResponseObject {
763    create_response_with_solver_and_memory(request, solver, &[])
764}
765
766#[must_use]
767pub fn create_response_with_solver_and_memory(
768    request: &ResponsesRequest,
769    solver: &UniversalSolver,
770    memory_events: &[MemoryEvent],
771) -> ResponseObject {
772    let prompt = response_prompt(request);
773    let chat_request = request.to_chat_completion_request();
774    match agentic_outcome(&chat_request, solver.config.agent_mode) {
775        AgenticOutcome::Refused(answer) => return response_from_symbolic(request, &prompt, answer),
776        AgenticOutcome::Planned(plan) => {
777            return response_from_plan(request, &prompt, plan, memory_events)
778        }
779        AgenticOutcome::Fallthrough => {}
780    }
781    let (memory_prompt, history) = chat_prompt_and_history(&chat_request.messages);
782    let memory_prompt = if memory_prompt.trim().is_empty() {
783        prompt.as_str()
784    } else {
785        memory_prompt.as_str()
786    };
787    if let Some(mut symbolic_answer) =
788        answer_from_memory_if_requested(memory_prompt, &history, memory_events)
789    {
790        apply_retained_amendments(&prompt, &mut symbolic_answer, memory_events);
791        return response_from_symbolic(request, &prompt, symbolic_answer);
792    }
793    let symbolic_answer =
794        solve_with_standing_requirements(solver, memory_prompt, &history, memory_events);
795    if let Some(plan) =
796        command_reroute_plan(&chat_request, solver.config.agent_mode, &symbolic_answer)
797    {
798        return response_from_plan(request, &prompt, plan, memory_events);
799    }
800    response_from_symbolic(request, &prompt, symbolic_answer)
801}
802
803/// Build a Responses object from a deterministic [`AgenticPlan`] — the Responses
804/// mirror of [`chat_completion_from_plan`]. A [`AgenticPlan::ToolCalls`] plan emits
805/// `function_call` output items (the same stable `call` ids the chat surface uses);
806/// a [`AgenticPlan::Final`] plan emits a single assistant message.
807fn response_from_plan(
808    request: &ResponsesRequest,
809    prompt: &str,
810    plan: AgenticPlan,
811    memory_events: &[MemoryEvent],
812) -> ResponseObject {
813    let model = resolved_request_model(request.model.as_deref());
814    let input_tokens = responses_input_tokens(request);
815
816    let (output, output_tokens) = match plan {
817        AgenticPlan::ToolCalls(calls) => {
818            let narration = tool_action_narration(prompt, &calls);
819            let mut items = Vec::with_capacity(calls.len().saturating_add(1));
820            let mut output_tokens = estimate_tokens(&narration);
821            items.push(ResponseOutputItem::Message(ResponseOutputMessage {
822                id: stable_id("msg", &narration),
823                kind: String::from("message"),
824                role: String::from("assistant"),
825                content: vec![ResponseOutputContent {
826                    kind: String::from("output_text"),
827                    text: narration,
828                }],
829                thinking_steps: Vec::new(),
830            }));
831            for (index, call) in calls.into_iter().enumerate() {
832                let tool = call.tool;
833                let planned_arguments = call.arguments;
834                let seed = format!("{prompt}|{index}|{tool}|{planned_arguments}");
835                let arguments =
836                    response_arguments_for_tool(&request.tools, &tool, planned_arguments, prompt);
837                output_tokens = output_tokens.saturating_add(
838                    estimate_tokens(&tool).saturating_add(estimate_tokens(&arguments)),
839                );
840                if request
841                    .tools
842                    .iter()
843                    .any(|definition| is_hosted_tool_definition(definition, &tool))
844                    && tool == "web_search"
845                {
846                    let query = serde_json::from_str::<Value>(&arguments)
847                        .ok()
848                        .and_then(|value| {
849                            value
850                                .get("query")
851                                .and_then(Value::as_str)
852                                .map(str::to_owned)
853                        })
854                        .unwrap_or_else(|| prompt.to_owned());
855                    items.push(ResponseOutputItem::WebSearchCall(
856                        ResponseWebSearchToolCall {
857                            id: stable_id("ws", &seed),
858                            kind: String::from("web_search_call"),
859                            status: String::from("completed"),
860                            action: ResponseWebSearchAction {
861                                kind: String::from("search"),
862                                queries: vec![query.clone()],
863                                query,
864                            },
865                        },
866                    ));
867                } else {
868                    let (name, namespace) = response_tool_call_identity(&request.tools, &tool);
869                    items.push(ResponseOutputItem::FunctionCall(ResponseFunctionToolCall {
870                        id: stable_id("fc", &seed),
871                        kind: function_call_kind(),
872                        call_id: stable_id("call", &seed),
873                        name,
874                        namespace,
875                        arguments,
876                        status: String::from("completed"),
877                    }));
878                }
879            }
880            (items, output_tokens)
881        }
882        AgenticPlan::Final(answer) => {
883            let answer = amended_answer(prompt, &answer, memory_events);
884            let output_tokens = estimate_tokens(&answer);
885            let message = ResponseOutputItem::Message(ResponseOutputMessage {
886                id: stable_id("msg", &answer),
887                kind: String::from("message"),
888                role: String::from("assistant"),
889                content: vec![ResponseOutputContent {
890                    kind: String::from("output_text"),
891                    text: answer,
892                }],
893                thinking_steps: Vec::new(),
894            });
895            (vec![message], output_tokens)
896        }
897    };
898
899    ResponseObject {
900        id: stable_id("resp", prompt),
901        object: String::from("response"),
902        created_at: response_timestamp(),
903        status: String::from("completed"),
904        model,
905        output,
906        usage: ResponseUsage {
907            input_tokens,
908            output_tokens,
909            total_tokens: input_tokens.saturating_add(output_tokens),
910        },
911        evidence_links: Vec::new(),
912        thinking_steps: Vec::new(),
913    }
914}
915
916fn response_from_symbolic(
917    request: &ResponsesRequest,
918    prompt: &str,
919    symbolic_answer: SymbolicAnswer,
920) -> ResponseObject {
921    let model = resolved_request_model(request.model.as_deref());
922    let input_tokens = responses_input_tokens(request);
923    let output_tokens = estimate_tokens(&symbolic_answer.answer);
924    let answer = symbolic_answer.answer;
925    let thinking_steps = symbolic_answer.thinking_steps;
926    let mut output = vec![ResponseOutputItem::Message(ResponseOutputMessage {
927        id: stable_id("msg", &answer),
928        kind: String::from("message"),
929        role: String::from("assistant"),
930        content: vec![ResponseOutputContent {
931            kind: String::from("output_text"),
932            text: answer,
933        }],
934        thinking_steps: thinking_steps.clone(),
935    })];
936    if let Some(reasoning) = response_reasoning_item(prompt, &thinking_steps) {
937        output.push(reasoning);
938    }
939
940    ResponseObject {
941        id: stable_id("resp", prompt),
942        object: String::from("response"),
943        created_at: response_timestamp(),
944        status: String::from("completed"),
945        model,
946        output,
947        usage: ResponseUsage {
948            input_tokens,
949            output_tokens,
950            total_tokens: input_tokens.saturating_add(output_tokens),
951        },
952        evidence_links: symbolic_answer.evidence_links,
953        thinking_steps,
954    }
955}
956
957fn response_reasoning_item(
958    prompt: &str,
959    thinking_steps: &[ThinkingStep],
960) -> Option<ResponseOutputItem> {
961    let text = render_thinking_steps(thinking_steps);
962    if text.is_empty() {
963        return None;
964    }
965    Some(ResponseOutputItem::Reasoning(ResponseReasoningItem {
966        id: stable_id("rs", prompt),
967        kind: String::from("reasoning"),
968        summary: vec![ResponseReasoningSummaryText {
969            kind: String::from("summary_text"),
970            text,
971        }],
972    }))
973}