feat(agent): thread per-tool reasoning through provider, session, and all surfaces (#1513)

* feat(agent): thread per-tool reasoning from LLM through to REPL, HTTP, SSE, and DB

Add end-to-end agent reasoning summaries so users can see *why* the
agent chose specific tools, not just what it did.

- Add `reasoning: Option<String>` to `ToolCall` (all providers)
- Populate from LLM response content in `Reasoning::respond_with_tools`
  and `select_tools`, with per-tool override when providers supply it
- Extend `Turn` with `narrative` and `TurnToolCall` with `rationale` +
  `tool_call_id` for identity-based result matching
- Persist reasoning in DB via existing tool_calls JSON (no migration)
- Add `StatusUpdate::ReasoningUpdate` and `SseEvent::ReasoningUpdate` +
  `SseEvent::JobReasoning` for real-time streaming
- Emit reasoning events in both chat dispatcher and worker job path
- Add `/reasoning [N|all]` command for inspecting turn reasoning
- Surface `narrative` and `rationale` in HTTP `/api/chat/history`

Based on the design from #361 and #456, reconstructed cleanly with
Option<String> to minimize blast radius (vs mandatory String that broke
compilation in #456).

Closes #456

Co-Authored-By: panosAthDBX <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address PR review feedback from Gemini and Copilot

- Fix `_ => Ok(None)` in agent_loop.rs to avoid accidental shutdown
- Fix fallback in record_tool_result_for/record_tool_error_for to use
  first pending call instead of last_mut (parallel execution safety)
- Include per-tool decisions in WASM channel reasoning messages
- Apply truncate_at_tool_tags + clean_response to shared_reasoning in
  select_tools (parity with respond_with_tools)
- Persist turn-level narrative to DB in tool_calls JSON wrapper
- Parse both old (array) and new (object) tool_calls formats in
  build_turns_from_db_messages for backward compatibility
- Populate reasoning from action.reasoning in execute_plan ToolCalls

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address second round of review comments + merge fixes

- Add reasoning: None to new github_copilot.rs ToolCall sites (from staging merge)
- Run cargo fmt on 4 files with formatting diffs
- Truncate narrative to 1000 chars before DB persistence
- Clone turn data and drop session lock in /reasoning command
- Extract ToolDecisionDto::from_json_array shared helper (deduplicate
  worker/job.rs and orchestrator/api.rs)
- Add unit tests for wrapped tool_calls JSON format with narrative

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address third round of review comments (Copilot + serrrfirat)

- Reword ToolCall.reasoning docstring to reflect provider-supplied or
  fallback contract
- Sanitize narrative through SafetyLayer before storage/emission
- Clean per-tool reasoning via truncate_at_tool_tags + clean_response
  in select_tools (parity with shared reasoning)
- Convert 4 approval-path recording sites in thread_ops.rs to
  identity-based record_tool_result_for/record_tool_error_for
- Preserve tool_call_id and reasoning through restore_from_messages
- Fix has_result/has_error to reject JSON null values
- Truncate tool_call_id to 128 chars before DB persistence
- Add 4 unit tests for record_tool_result_for/error_for edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address zmanian review — sanitize JobDelegate reasoning + warn on dropped results

- Sanitize narrative and per-tool rationale through SafetyLayer in
  JobDelegate reasoning events (parity with ChatDelegate)
- Add tracing::warn when record_tool_result_for/error_for drops a
  result because no matching or pending tool call exists
- Add 3 unit tests for reasoning normalization (thinking tags,
  tool tags, empty-after-cleaning)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address 4 remaining unreplied review comments

- Clean per-tool reasoning in respond_with_tools via truncate_at_tool_tags
  + clean_response (parity with select_tools)
- Handle wrapped JSON format in rebuild_chat_messages_from_db so cold
  hydration works after persist_tool_calls format change
- Update persist_tool_calls doc comment to describe new JSON shape
- Sanitize per-tool rationale through SafetyLayer in ChatDelegate before
  emission and storage (parity with JobDelegate)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix: address zmanian review round 2

- Add tracing::debug on fallback-to-pending path in record_tool_result_for
  and record_tool_error_for (item 1)
- Add comment explaining why /reasoning is special-cased in agent_loop.rs
  (item 4)
- Items 2 (narrative persistence), 3 (rationale sanitization), and 5
  (catch-all fix) were already addressed in prior commits

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: panosAthDBX <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-25 08:35:41 -07:00
committed by GitHub
co-authored by panosAthDBX Claude Opus 4.6
parent 6daa2f155f
commit 41ed0a0f98
33 changed files with 871 additions and 45 deletions
+55
View File
@@ -7,6 +7,32 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// A single tool decision in a reasoning update (SSE DTO).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDecisionDto {
pub tool_name: String,
pub rationale: String,
}
impl ToolDecisionDto {
/// Parse a list of tool decisions from a JSON array value.
pub fn from_json_array(value: &serde_json::Value) -> Vec<Self> {
value
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|d| {
Some(Self {
tool_name: d.get("tool_name")?.as_str()?.to_string(),
rationale: d.get("rationale")?.as_str()?.to_string(),
})
})
.collect()
})
.unwrap_or_default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum AppEvent { pub enum AppEvent {
@@ -163,6 +189,23 @@ pub enum AppEvent {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>, message: Option<String>,
}, },
/// Agent reasoning update (why it chose specific tools).
#[serde(rename = "reasoning_update")]
ReasoningUpdate {
narrative: String,
decisions: Vec<ToolDecisionDto>,
#[serde(skip_serializing_if = "Option::is_none")]
thread_id: Option<String>,
},
/// Reasoning update for a sandbox job.
#[serde(rename = "job_reasoning")]
JobReasoning {
job_id: String,
narrative: String,
decisions: Vec<ToolDecisionDto>,
},
} }
impl AppEvent { impl AppEvent {
@@ -191,6 +234,8 @@ impl AppEvent {
Self::Suggestions { .. } => "suggestions", Self::Suggestions { .. } => "suggestions",
Self::TurnCost { .. } => "turn_cost", Self::TurnCost { .. } => "turn_cost",
Self::ExtensionStatus { .. } => "extension_status", Self::ExtensionStatus { .. } => "extension_status",
Self::ReasoningUpdate { .. } => "reasoning_update",
Self::JobReasoning { .. } => "job_reasoning",
} }
} }
} }
@@ -311,6 +356,16 @@ mod tests {
status: String::new(), status: String::new(),
message: None, message: None,
}, },
AppEvent::ReasoningUpdate {
narrative: String::new(),
decisions: vec![],
thread_id: None,
},
AppEvent::JobReasoning {
job_id: String::new(),
narrative: String::new(),
decisions: vec![],
},
]; ];
for variant in &variants { for variant in &variants {
+1 -1
View File
@@ -3,5 +3,5 @@
mod event; mod event;
mod util; mod util;
pub use event::AppEvent; pub use event::{AppEvent, ToolDecisionDto};
pub use util::truncate_preview; pub use util::truncate_preview;
+16
View File
@@ -1250,6 +1250,22 @@ impl Agent {
command, command,
message.channel message.channel
); );
// /reasoning is special-cased here (not in handle_system_command)
// because it needs the session + thread_id to read turn reasoning
// data, which handle_system_command's signature doesn't provide.
if command == "reasoning" {
let result = self
.handle_reasoning_command(&args, &session, thread_id)
.await;
return match result {
SubmissionResult::Response { content } => Ok(Some(content)),
SubmissionResult::Ok { message } => Ok(message),
SubmissionResult::Error { message } => {
Ok(Some(format!("Error: {}", message)))
}
_ => Ok(Some(String::new())),
};
}
// Authorization checks (including restart channel check) are enforced in handle_system_command // Authorization checks (including restart channel check) are enforced in handle_system_command
self.handle_system_command(&command, &args, &message.channel) self.handle_system_command(&command, &args, &message.channel)
.await .await
+1
View File
@@ -414,6 +414,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}; };
let delegate = MockDelegate::new(vec![ let delegate = MockDelegate::new(vec![
tool_calls_output(vec![tool_call]), tool_calls_output(vec![tool_call]),
+89
View File
@@ -465,6 +465,94 @@ impl Agent {
} }
} }
/// Handle `/reasoning [N|all]` — show reasoning history for the active thread.
pub(super) async fn handle_reasoning_command(
&self,
args: &[String],
session: &Arc<Mutex<Session>>,
thread_id: Uuid,
) -> SubmissionResult {
// Clone the turn data we need, then drop the session lock.
let turns_snapshot: Vec<(
usize,
Option<String>,
Vec<crate::agent::session::TurnToolCall>,
)>;
{
let sess = session.lock().await;
let thread = match sess.threads.get(&thread_id) {
Some(t) => t,
None => return SubmissionResult::error("No active thread."),
};
if thread.turns.is_empty() {
return SubmissionResult::ok_with_message("No turns yet.");
}
// Parse argument: default=last turn, "all"=all turns, N=specific turn (1-based).
let selected: Vec<&crate::agent::session::Turn> = match args.first().map(|s| s.as_str())
{
Some("all") => thread.turns.iter().collect(),
Some(n) => match n.parse::<usize>() {
Ok(0) => return SubmissionResult::error("Turn numbers start at 1."),
Ok(num) if num > thread.turns.len() => {
return SubmissionResult::error(format!(
"Turn {} does not exist (max: {}).",
num,
thread.turns.len()
));
}
Ok(num) => vec![&thread.turns[num - 1]],
Err(_) => return SubmissionResult::error("Usage: /reasoning [N|all]"),
},
None => {
// Default: last turn that has tool calls
match thread.turns.iter().rev().find(|t| !t.tool_calls.is_empty()) {
Some(t) => vec![t],
None => {
return SubmissionResult::ok_with_message("No turns with tool calls.");
}
}
}
};
turns_snapshot = selected
.into_iter()
.map(|t| (t.turn_number, t.narrative.clone(), t.tool_calls.clone()))
.collect();
}
// Session lock is now dropped — format output without holding it.
let mut output = String::new();
for (turn_number, narrative, tool_calls) in &turns_snapshot {
output.push_str(&format!("--- Turn {} ---\n", turn_number + 1));
if let Some(narrative) = narrative {
output.push_str(&format!("Reasoning: {}\n", narrative));
}
if tool_calls.is_empty() {
output.push_str(" (no tool calls)\n");
} else {
for tc in tool_calls {
let status = if tc.error.is_some() {
"error"
} else if tc.result.is_some() {
"ok"
} else {
"pending"
};
output.push_str(&format!(" {} [{}]", tc.name, status));
if let Some(ref rationale) = tc.rationale {
output.push_str(&format!("{}", rationale));
}
output.push('\n');
}
}
output.push('\n');
}
SubmissionResult::response(output.trim_end())
}
/// Handle system commands that bypass thread-state checks entirely. /// Handle system commands that bypass thread-state checks entirely.
pub(super) async fn handle_system_command( pub(super) async fn handle_system_command(
&self, &self,
@@ -480,6 +568,7 @@ impl Agent {
" /version Show version info\n", " /version Show version info\n",
" /tools List available tools\n", " /tools List available tools\n",
" /debug Toggle debug mode\n", " /debug Toggle debug mode\n",
" /reasoning [N|all] Show agent reasoning for turns\n",
" /ping Connectivity check\n", " /ping Connectivity check\n",
"\n", "\n",
"Jobs:\n", "Jobs:\n",
+79 -5
View File
@@ -420,6 +420,19 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
content: Option<String>, content: Option<String>,
reason_ctx: &mut ReasoningContext, reason_ctx: &mut ReasoningContext,
) -> Result<Option<LoopOutcome>, Error> { ) -> Result<Option<LoopOutcome>, Error> {
// Extract and sanitize the narrative before consuming `content`.
let narrative = content
.as_deref()
.filter(|c| !c.trim().is_empty())
.map(|c| {
let sanitized = self
.agent
.safety()
.sanitize_tool_output("agent_narrative", c);
sanitized.content
})
.filter(|c| !c.trim().is_empty());
// Add the assistant message with tool_calls to context. // Add the assistant message with tool_calls to context.
// OpenAI protocol requires this before tool-result messages. // OpenAI protocol requires this before tool-result messages.
reason_ctx reason_ctx
@@ -440,6 +453,41 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
) )
.await; .await;
// Build per-tool decisions for the reasoning update.
// Sanitize each rationale through SafetyLayer (parity with JobDelegate).
let decisions: Vec<crate::channels::ToolDecision> = tool_calls
.iter()
.filter_map(|tc| {
tc.reasoning.as_ref().map(|r| {
let sanitized = self
.agent
.safety()
.sanitize_tool_output("tool_rationale", r)
.content;
crate::channels::ToolDecision {
tool_name: tc.name.clone(),
rationale: sanitized,
}
})
})
.collect();
// Emit reasoning update to channels.
if narrative.is_some() || !decisions.is_empty() {
let _ = self
.agent
.channels
.send_status(
&self.message.channel,
StatusUpdate::ReasoningUpdate {
narrative: narrative.clone().unwrap_or_default(),
decisions: decisions.clone(),
},
&self.message.metadata,
)
.await;
}
// Record tool calls in the thread with sensitive params redacted. // Record tool calls in the thread with sensitive params redacted.
{ {
let mut redacted_args: Vec<serde_json::Value> = Vec::with_capacity(tool_calls.len()); let mut redacted_args: Vec<serde_json::Value> = Vec::with_capacity(tool_calls.len());
@@ -455,8 +503,23 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
if let Some(thread) = sess.threads.get_mut(&self.thread_id) if let Some(thread) = sess.threads.get_mut(&self.thread_id)
&& let Some(turn) = thread.last_turn_mut() && let Some(turn) = thread.last_turn_mut()
{ {
// Set turn-level narrative.
if turn.narrative.is_none() {
turn.narrative = narrative;
}
for (tc, safe_args) in tool_calls.iter().zip(redacted_args) { for (tc, safe_args) in tool_calls.iter().zip(redacted_args) {
turn.record_tool_call(&tc.name, safe_args); let sanitized_rationale = tc.reasoning.as_ref().map(|r| {
self.agent
.safety()
.sanitize_tool_output("tool_rationale", r)
.content
});
turn.record_tool_call_with_reasoning(
&tc.name,
safe_args,
sanitized_rationale,
Some(tc.id.clone()),
);
} }
} }
} }
@@ -726,7 +789,7 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
if let Some(thread) = sess.threads.get_mut(&self.thread_id) if let Some(thread) = sess.threads.get_mut(&self.thread_id)
&& let Some(turn) = thread.last_turn_mut() && let Some(turn) = thread.last_turn_mut()
{ {
turn.record_tool_error(error_msg.clone()); turn.record_tool_error_for(&tc.id, error_msg.clone());
} }
} }
reason_ctx reason_ctx
@@ -852,16 +915,19 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
Err(e) => format!("Tool '{}' failed: {}", tc.name, e), Err(e) => format!("Tool '{}' failed: {}", tc.name, e),
}; };
// Record sanitized result in thread // Record sanitized result in thread (identity-based matching).
{ {
let mut sess = self.session.lock().await; let mut sess = self.session.lock().await;
if let Some(thread) = sess.threads.get_mut(&self.thread_id) if let Some(thread) = sess.threads.get_mut(&self.thread_id)
&& let Some(turn) = thread.last_turn_mut() && let Some(turn) = thread.last_turn_mut()
{ {
if is_tool_error { if is_tool_error {
turn.record_tool_error(result_content.clone()); turn.record_tool_error_for(&tc.id, result_content.clone());
} else { } else {
turn.record_tool_result(serde_json::json!(result_content)); turn.record_tool_result_for(
&tc.id,
serde_json::json!(result_content),
);
} }
} }
} }
@@ -1462,11 +1528,13 @@ mod tests {
id: "call_2".to_string(), id: "call_2".to_string(),
name: "http".to_string(), name: "http".to_string(),
arguments: serde_json::json!({"url": "https://example.com"}), arguments: serde_json::json!({"url": "https://example.com"}),
reasoning: None,
}, },
ToolCall { ToolCall {
id: "call_3".to_string(), id: "call_3".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({"message": "done"}), arguments: serde_json::json!({"message": "done"}),
reasoning: None,
}, },
], ],
user_timezone: None, user_timezone: None,
@@ -1652,6 +1720,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({"message": "hi"}), arguments: serde_json::json!({"message": "hi"}),
reasoning: None,
}], }],
), ),
ChatMessage::tool_result("call_1", "echo", "hi"), ChatMessage::tool_result("call_1", "echo", "hi"),
@@ -1744,11 +1813,13 @@ mod tests {
id: "c1".to_string(), id: "c1".to_string(),
name: "http".to_string(), name: "http".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}, },
ToolCall { ToolCall {
id: "c2".to_string(), id: "c2".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}, },
], ],
), ),
@@ -1782,6 +1853,7 @@ mod tests {
id: "c1".to_string(), id: "c1".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}], }],
), ),
ChatMessage::tool_result("c1", "echo", "done"), ChatMessage::tool_result("c1", "echo", "done"),
@@ -1912,6 +1984,7 @@ mod tests {
id: crate::llm::generate_tool_call_id(0, 0), id: crate::llm::generate_tool_call_id(0, 0),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({"message": "looping"}), arguments: serde_json::json!({"message": "looping"}),
reasoning: None,
}], }],
input_tokens: 0, input_tokens: 0,
output_tokens: 5, output_tokens: 5,
@@ -2065,6 +2138,7 @@ mod tests {
id: crate::llm::generate_tool_call_id(0, 0), id: crate::llm::generate_tool_call_id(0, 0),
name: "nonexistent_tool".to_string(), name: "nonexistent_tool".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}], }],
input_tokens: 0, input_tokens: 0,
output_tokens: 5, output_tokens: 5,
+192 -1
View File
@@ -449,6 +449,7 @@ impl Thread {
id: call_id.clone(), id: call_id.clone(),
name: tc.name.clone(), name: tc.name.clone(),
arguments: tc.parameters.clone(), arguments: tc.parameters.clone(),
reasoning: None,
}) })
.collect(); .collect();
@@ -522,7 +523,12 @@ impl Thread {
&& let Some(ref tcs) = assistant_msg.tool_calls && let Some(ref tcs) = assistant_msg.tool_calls
{ {
for tc in tcs { for tc in tcs {
turn.record_tool_call(&tc.name, tc.arguments.clone()); turn.record_tool_call_with_reasoning(
&tc.name,
tc.arguments.clone(),
tc.reasoning.clone(),
Some(tc.id.clone()),
);
} }
} }
@@ -602,6 +608,10 @@ pub struct Turn {
pub completed_at: Option<DateTime<Utc>>, pub completed_at: Option<DateTime<Utc>>,
/// Error message (if failed). /// Error message (if failed).
pub error: Option<String>, pub error: Option<String>,
/// Agent's reasoning narrative for this turn.
/// Cleaned via `clean_response` and sanitized through `SafetyLayer` before storage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub narrative: Option<String>,
/// Transient image content parts for multimodal LLM input. /// Transient image content parts for multimodal LLM input.
/// Not serialized — images are only needed for the current LLM call. /// Not serialized — images are only needed for the current LLM call.
/// The text description in `user_input` persists for compaction/context. /// The text description in `user_input` persists for compaction/context.
@@ -621,6 +631,7 @@ impl Turn {
started_at: Utc::now(), started_at: Utc::now(),
completed_at: None, completed_at: None,
error: None, error: None,
narrative: None,
image_content_parts: Vec::new(), image_content_parts: Vec::new(),
} }
} }
@@ -656,6 +667,26 @@ impl Turn {
parameters: params, parameters: params,
result: None, result: None,
error: None, error: None,
rationale: None,
tool_call_id: None,
});
}
/// Record a tool call with reasoning context.
pub fn record_tool_call_with_reasoning(
&mut self,
name: impl Into<String>,
params: serde_json::Value,
rationale: Option<String>,
tool_call_id: Option<String>,
) {
self.tool_calls.push(TurnToolCall {
name: name.into(),
parameters: params,
result: None,
error: None,
rationale,
tool_call_id,
}); });
} }
@@ -672,6 +703,60 @@ impl Turn {
call.error = Some(error.into()); call.error = Some(error.into());
} }
} }
/// Record a tool result by tool_call_id, with fallback to first pending call.
pub fn record_tool_result_for(&mut self, tool_call_id: &str, result: serde_json::Value) {
if let Some(call) = self
.tool_calls
.iter_mut()
.find(|c| c.tool_call_id.as_deref() == Some(tool_call_id))
{
call.result = Some(result);
} else if let Some(call) = self
.tool_calls
.iter_mut()
.find(|c| c.result.is_none() && c.error.is_none())
{
tracing::debug!(
tool_call_id = %tool_call_id,
fallback_tool = %call.name,
"tool_call_id not found, falling back to first pending call"
);
call.result = Some(result);
} else {
tracing::warn!(
tool_call_id = %tool_call_id,
"Tool result dropped: no matching or pending tool call"
);
}
}
/// Record a tool error by tool_call_id, with fallback to first pending call.
pub fn record_tool_error_for(&mut self, tool_call_id: &str, error: impl Into<String>) {
if let Some(call) = self
.tool_calls
.iter_mut()
.find(|c| c.tool_call_id.as_deref() == Some(tool_call_id))
{
call.error = Some(error.into());
} else if let Some(call) = self
.tool_calls
.iter_mut()
.find(|c| c.result.is_none() && c.error.is_none())
{
tracing::debug!(
tool_call_id = %tool_call_id,
fallback_tool = %call.name,
"tool_call_id not found, falling back to first pending call"
);
call.error = Some(error.into());
} else {
tracing::warn!(
tool_call_id = %tool_call_id,
"Tool error dropped: no matching or pending tool call"
);
}
}
} }
/// Record of a tool call made during a turn. /// Record of a tool call made during a turn.
@@ -685,6 +770,12 @@ pub struct TurnToolCall {
pub result: Option<serde_json::Value>, pub result: Option<serde_json::Value>,
/// Error from the tool (if failed). /// Error from the tool (if failed).
pub error: Option<String>, pub error: Option<String>,
/// Agent's reasoning for choosing this tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
/// The tool_call_id from the LLM, for identity-based result matching.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
} }
#[cfg(test)] #[cfg(test)]
@@ -1309,6 +1400,7 @@ mod tests {
id: "call_0".to_string(), id: "call_0".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}), arguments: serde_json::json!({"q": "test"}),
reasoning: None,
}; };
let messages = vec![ let messages = vec![
ChatMessage::user("Find test"), ChatMessage::user("Find test"),
@@ -1339,6 +1431,7 @@ mod tests {
id: "call_0".to_string(), id: "call_0".to_string(),
name: "http".to_string(), name: "http".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}; };
let messages = vec![ let messages = vec![
ChatMessage::user("Fetch URL"), ChatMessage::user("Fetch URL"),
@@ -1404,11 +1497,13 @@ mod tests {
id: "call_a".to_string(), id: "call_a".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"q": "data"}), arguments: serde_json::json!({"q": "data"}),
reasoning: None,
}; };
let tc2 = ToolCall { let tc2 = ToolCall {
id: "call_b".to_string(), id: "call_b".to_string(),
name: "write".to_string(), name: "write".to_string(),
arguments: serde_json::json!({"path": "out.txt"}), arguments: serde_json::json!({"path": "out.txt"}),
reasoning: None,
}; };
let messages = vec![ let messages = vec![
ChatMessage::user("Find and save"), ChatMessage::user("Find and save"),
@@ -1620,4 +1715,100 @@ mod tests {
let merged = thread.drain_pending_messages().unwrap(); let merged = thread.drain_pending_messages().unwrap();
assert_eq!(merged, "failed batch\nnew msg"); assert_eq!(merged, "failed batch\nnew msg");
} }
#[test]
fn test_record_tool_result_for_by_id() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call_with_reasoning(
"tool_a",
serde_json::json!({}),
None,
Some("id_a".into()),
);
turn.record_tool_call_with_reasoning(
"tool_b",
serde_json::json!({}),
None,
Some("id_b".into()),
);
// Record result for second tool by ID
turn.record_tool_result_for("id_b", serde_json::json!("result_b"));
assert!(turn.tool_calls[0].result.is_none());
assert_eq!(
turn.tool_calls[1].result.as_ref().unwrap(),
&serde_json::json!("result_b")
);
}
#[test]
fn test_record_tool_error_for_by_id() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call_with_reasoning(
"tool_a",
serde_json::json!({}),
None,
Some("id_a".into()),
);
turn.record_tool_call_with_reasoning(
"tool_b",
serde_json::json!({}),
None,
Some("id_b".into()),
);
turn.record_tool_error_for("id_a", "failed");
assert_eq!(turn.tool_calls[0].error.as_deref(), Some("failed"));
assert!(turn.tool_calls[1].error.is_none());
}
#[test]
fn test_record_tool_result_for_fallback_to_pending() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call_with_reasoning(
"tool_a",
serde_json::json!({}),
None,
Some("id_a".into()),
);
turn.record_tool_call_with_reasoning(
"tool_b",
serde_json::json!({}),
None,
Some("id_b".into()),
);
// First tool already has a result
turn.tool_calls[0].result = Some(serde_json::json!("done"));
// Unknown ID should fall back to first pending (tool_b)
turn.record_tool_result_for("unknown_id", serde_json::json!("fallback"));
assert_eq!(
turn.tool_calls[0].result.as_ref().unwrap(),
&serde_json::json!("done")
);
assert_eq!(
turn.tool_calls[1].result.as_ref().unwrap(),
&serde_json::json!("fallback")
);
}
#[test]
fn test_record_tool_result_for_no_pending_is_noop() {
let mut turn = Turn::new(0, "test");
turn.record_tool_call_with_reasoning(
"tool_a",
serde_json::json!({}),
None,
Some("id_a".into()),
);
turn.tool_calls[0].result = Some(serde_json::json!("done"));
// No pending calls, unknown ID — should be a no-op
turn.record_tool_result_for("unknown_id", serde_json::json!("lost"));
assert_eq!(
turn.tool_calls[0].result.as_ref().unwrap(),
&serde_json::json!("done")
);
}
} }
+11
View File
@@ -92,6 +92,17 @@ impl SubmissionParser {
args: vec![], args: vec![],
}; };
} }
if lower == "/reasoning" || lower.starts_with("/reasoning ") {
let args: Vec<String> = trimmed
.split_whitespace()
.skip(1)
.map(|s| s.to_string())
.collect();
return Submission::SystemCommand {
command: "reasoning".to_string(),
args,
};
}
if lower == "/restart" { if lower == "/restart" {
tracing::debug!("[SubmissionParser::parse] Recognized /restart command"); tracing::debug!("[SubmissionParser::parse] Recognized /restart command");
return Submission::SystemCommand { return Submission::SystemCommand {
+58 -11
View File
@@ -513,10 +513,10 @@ impl Agent {
}; };
thread.complete_turn(&response); thread.complete_turn(&response);
let (turn_number, tool_calls) = thread let (turn_number, tool_calls, narrative) = thread
.turns .turns
.last() .last()
.map(|t| (t.turn_number, t.tool_calls.clone())) .map(|t| (t.turn_number, t.tool_calls.clone(), t.narrative.clone()))
.unwrap_or_default(); .unwrap_or_default();
let _ = self let _ = self
.channels .channels
@@ -534,6 +534,7 @@ impl Agent {
&message.user_id, &message.user_id,
turn_number, turn_number,
&tool_calls, &tool_calls,
narrative.as_deref(),
) )
.await; .await;
self.persist_assistant_response( self.persist_assistant_response(
@@ -725,7 +726,9 @@ impl Agent {
/// ///
/// Stored between the user and assistant messages so that /// Stored between the user and assistant messages so that
/// `build_turns_from_db_messages` can reconstruct the tool call history. /// `build_turns_from_db_messages` can reconstruct the tool call history.
/// Content is a JSON array of tool call summaries. /// Content is a JSON object: `{ "calls": [...], "narrative": "..." }`.
/// The `calls` array contains tool call summaries with optional `rationale`
/// and `tool_call_id` fields. Legacy rows may be plain JSON arrays.
pub(super) async fn persist_tool_calls( pub(super) async fn persist_tool_calls(
&self, &self,
thread_id: Uuid, thread_id: Uuid,
@@ -733,6 +736,7 @@ impl Agent {
user_id: &str, user_id: &str,
turn_number: usize, turn_number: usize,
tool_calls: &[crate::agent::session::TurnToolCall], tool_calls: &[crate::agent::session::TurnToolCall],
narrative: Option<&str>,
) { ) {
if tool_calls.is_empty() { if tool_calls.is_empty() {
return; return;
@@ -767,11 +771,30 @@ impl Agent {
if let Some(ref error) = tc.error { if let Some(ref error) = tc.error {
obj["error"] = serde_json::Value::String(truncate_preview(error, 200)); obj["error"] = serde_json::Value::String(truncate_preview(error, 200));
} }
if let Some(ref rationale) = tc.rationale {
obj["rationale"] = serde_json::Value::String(truncate_preview(rationale, 500));
}
if let Some(ref tool_call_id) = tc.tool_call_id {
obj["tool_call_id"] =
serde_json::Value::String(truncate_preview(tool_call_id, 128));
}
obj obj
}) })
.collect(); .collect();
let content = match serde_json::to_string(&summaries) { // Wrap in an object with optional narrative so it can be reconstructed.
// safety: no byte-index slicing here; comment describes JSON shape
let wrapper = if let Some(n) = narrative {
serde_json::json!({
"narrative": truncate_preview(n, 1000),
"calls": summaries,
})
} else {
serde_json::json!({
"calls": summaries,
})
};
let content = match serde_json::to_string(&wrapper) {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
tracing::warn!("Failed to serialize tool calls: {}", e); tracing::warn!("Failed to serialize tool calls: {}", e);
@@ -1104,9 +1127,12 @@ impl Agent {
&& let Some(turn) = thread.last_turn_mut() && let Some(turn) = thread.last_turn_mut()
{ {
if is_tool_error { if is_tool_error {
turn.record_tool_error(result_content.clone()); turn.record_tool_error_for(&pending.tool_call_id, result_content.clone());
} else { } else {
turn.record_tool_result(serde_json::json!(result_content)); turn.record_tool_result_for(
&pending.tool_call_id,
serde_json::json!(result_content),
);
} }
} }
} }
@@ -1358,9 +1384,12 @@ impl Agent {
&& let Some(turn) = thread.last_turn_mut() && let Some(turn) = thread.last_turn_mut()
{ {
if is_deferred_error { if is_deferred_error {
turn.record_tool_error(deferred_content.clone()); turn.record_tool_error_for(&tc.id, deferred_content.clone());
} else { } else {
turn.record_tool_result(serde_json::json!(deferred_content)); turn.record_tool_result_for(
&tc.id,
serde_json::json!(deferred_content),
);
} }
} }
} }
@@ -1459,10 +1488,10 @@ impl Agent {
let (response, suggestions) = let (response, suggestions) =
crate::agent::dispatcher::extract_suggestions(&response); crate::agent::dispatcher::extract_suggestions(&response);
thread.complete_turn(&response); thread.complete_turn(&response);
let (turn_number, tool_calls) = thread let (turn_number, tool_calls, narrative) = thread
.turns .turns
.last() .last()
.map(|t| (t.turn_number, t.tool_calls.clone())) .map(|t| (t.turn_number, t.tool_calls.clone(), t.narrative.clone()))
.unwrap_or_default(); .unwrap_or_default();
// User message already persisted at turn start; save tool calls then assistant response // User message already persisted at turn start; save tool calls then assistant response
self.persist_tool_calls( self.persist_tool_calls(
@@ -1471,6 +1500,7 @@ impl Agent {
&message.user_id, &message.user_id,
turn_number, turn_number,
&tool_calls, &tool_calls,
narrative.as_deref(),
) )
.await; .await;
self.persist_assistant_response( self.persist_assistant_response(
@@ -1816,7 +1846,20 @@ fn rebuild_chat_messages_from_db(
"assistant" => result.push(ChatMessage::assistant(&msg.content)), "assistant" => result.push(ChatMessage::assistant(&msg.content)),
"tool_calls" => { "tool_calls" => {
// Try to parse the enriched JSON and rebuild tool messages. // Try to parse the enriched JSON and rebuild tool messages.
if let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(&msg.content) { // Supports two formats:
// - Old: plain JSON array of tool call summaries
// - New: wrapped object { "calls": [...], "narrative": "..." }
let calls: Vec<serde_json::Value> =
match serde_json::from_str::<serde_json::Value>(&msg.content) {
Ok(serde_json::Value::Array(arr)) => arr,
Ok(serde_json::Value::Object(obj)) => obj
.get("calls")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default(),
_ => Vec::new(),
};
{
if calls.is_empty() { if calls.is_empty() {
continue; continue;
} }
@@ -1839,6 +1882,10 @@ fn rebuild_chat_messages_from_db(
.get("parameters") .get("parameters")
.cloned() .cloned()
.unwrap_or(serde_json::json!({})), .unwrap_or(serde_json::json!({})),
reasoning: c
.get("rationale")
.and_then(|v| v.as_str())
.map(String::from),
}) })
.collect(); .collect();
+16
View File
@@ -265,6 +265,15 @@ impl OutgoingResponse {
} }
} }
/// A single tool decision within a reasoning update.
#[derive(Debug, Clone)]
pub struct ToolDecision {
/// Tool name.
pub tool_name: String,
/// Agent's reasoning for choosing this tool.
pub rationale: String,
}
/// Status update types for showing agent activity. /// Status update types for showing agent activity.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum StatusUpdate { pub enum StatusUpdate {
@@ -333,6 +342,13 @@ pub enum StatusUpdate {
}, },
/// Suggested follow-up messages for the user. /// Suggested follow-up messages for the user.
Suggestions { suggestions: Vec<String> }, Suggestions { suggestions: Vec<String> },
/// Agent reasoning update (why it chose specific tools).
ReasoningUpdate {
/// Human-readable summary of the agent's decision.
narrative: String,
/// Per-tool decisions.
decisions: Vec<ToolDecision>,
},
/// Per-turn token usage and cost summary (shown as subtle metadata). /// Per-turn token usage and cost summary (shown as subtle metadata).
TurnCost { TurnCost {
input_tokens: u64, input_tokens: u64,
+1 -1
View File
@@ -39,7 +39,7 @@ mod webhook_server;
pub use channel::{ pub use channel::{
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage, AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse, StatusUpdate, routing_target_from_metadata, MessageStream, OutgoingResponse, StatusUpdate, ToolDecision, routing_target_from_metadata,
}; };
pub use http::{HttpChannel, HttpChannelState}; pub use http::{HttpChannel, HttpChannelState};
pub use manager::ChannelManager; pub use manager::ChannelManager;
+14
View File
@@ -75,6 +75,7 @@ const SLASH_COMMANDS: &[&str] = &[
"/suggest", "/suggest",
"/thread", "/thread",
"/resume", "/resume",
"/reasoning",
]; ];
/// Rustyline helper for slash-command tab completion. /// Rustyline helper for slash-command tab completion.
@@ -841,6 +842,19 @@ impl Channel for ReplChannel {
StatusUpdate::Suggestions { .. } => { StatusUpdate::Suggestions { .. } => {
// Suggestions are only rendered by the web gateway // Suggestions are only rendered by the web gateway
} }
StatusUpdate::ReasoningUpdate {
narrative,
decisions,
} => {
if !narrative.is_empty() {
let display = truncate_for_preview(&narrative, CLI_STATUS_MAX);
eprintln!(" \x1b[94m\u{25B6} {display}\x1b[0m");
}
for d in &decisions {
let display = truncate_for_preview(&d.rationale, CLI_STATUS_MAX);
eprintln!(" \x1b[90m\u{2192} {}: {display}\x1b[0m", d.tool_name);
}
}
StatusUpdate::TurnCost { .. } => { StatusUpdate::TurnCost { .. } => {
// Cost display is handled by the TUI channel // Cost display is handled by the TUI channel
} }
+14
View File
@@ -3061,6 +3061,20 @@ fn status_to_wit(
}, },
// Suggestions and turn cost are web-gateway-only; skip for WASM channels // Suggestions and turn cost are web-gateway-only; skip for WASM channels
StatusUpdate::Suggestions { .. } | StatusUpdate::TurnCost { .. } => return None, StatusUpdate::Suggestions { .. } | StatusUpdate::TurnCost { .. } => return None,
StatusUpdate::ReasoningUpdate {
narrative,
decisions,
} => {
let mut msg = narrative.clone();
for d in decisions {
msg.push_str(&format!("\n{}: {}", d.tool_name, d.rationale));
}
wit_channel::StatusUpdate {
status: wit_channel::StatusType::Status,
message: msg,
metadata_json,
}
}
}) })
} }
+2
View File
@@ -398,8 +398,10 @@ pub async fn chat_history_handler(
truncate_preview(&s, 500) truncate_preview(&s, 500)
}), }),
error: tc.error.clone(), error: tc.error.clone(),
rationale: tc.rationale.clone(),
}) })
.collect(), .collect(),
narrative: t.narrative.clone(),
}) })
.collect(); .collect();
+14
View File
@@ -489,6 +489,20 @@ impl Channel for GatewayChannel {
}, },
StatusUpdate::Suggestions { suggestions } => AppEvent::Suggestions { StatusUpdate::Suggestions { suggestions } => AppEvent::Suggestions {
suggestions, suggestions,
thread_id: thread_id.clone(),
},
StatusUpdate::ReasoningUpdate {
narrative,
decisions,
} => AppEvent::ReasoningUpdate {
narrative,
decisions: decisions
.into_iter()
.map(|d| crate::channels::web::types::ToolDecisionDto {
tool_name: d.tool_name,
rationale: d.rationale,
})
.collect(),
thread_id, thread_id,
}, },
StatusUpdate::TurnCost { StatusUpdate::TurnCost {
+2
View File
@@ -231,6 +231,7 @@ pub fn convert_messages(messages: &[OpenAiMessage]) -> Result<Vec<ChatMessage>,
name: tc.function.name.clone(), name: tc.function.name.clone(),
arguments: serde_json::from_str(&tc.function.arguments) arguments: serde_json::from_str(&tc.function.arguments)
.unwrap_or(serde_json::Value::Object(Default::default())), .unwrap_or(serde_json::Value::Object(Default::default())),
reasoning: None,
}) })
.collect(); .collect();
Ok(ChatMessage::assistant_with_tool_calls( Ok(ChatMessage::assistant_with_tool_calls(
@@ -954,6 +955,7 @@ mod tests {
id: "call_abc".to_string(), id: "call_abc".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"query": "rust"}), arguments: serde_json::json!({"query": "rust"}),
reasoning: None,
}]; }];
let converted = convert_tool_calls_to_openai(&calls); let converted = convert_tool_calls_to_openai(&calls);
+2
View File
@@ -1725,8 +1725,10 @@ async fn chat_history_handler(
truncate_preview(&s, 500) truncate_preview(&s, 500)
}), }),
error: tc.error.clone(), error: tc.error.clone(),
rationale: tc.rationale.clone(),
}) })
.collect(), .collect(),
narrative: t.narrative.clone(),
}) })
.collect(); .collect();
+7 -1
View File
@@ -63,6 +63,9 @@ pub struct TurnInfo {
pub started_at: String, pub started_at: String,
pub completed_at: Option<String>, pub completed_at: Option<String>,
pub tool_calls: Vec<ToolCallInfo>, pub tool_calls: Vec<ToolCallInfo>,
/// Agent's reasoning narrative for this turn.
#[serde(skip_serializing_if = "Option::is_none")]
pub narrative: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -74,6 +77,9 @@ pub struct ToolCallInfo {
pub result_preview: Option<String>, pub result_preview: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>, pub error: Option<String>,
/// Agent's reasoning for choosing this tool.
#[serde(skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -116,7 +122,7 @@ pub struct ApprovalRequest {
// --- App Event (re-exported from ironclaw_common) --- // --- App Event (re-exported from ironclaw_common) ---
pub use ironclaw_common::AppEvent; pub use ironclaw_common::{AppEvent, ToolDecisionDto};
// --- Memory --- // --- Memory ---
+87 -12
View File
@@ -4,6 +4,21 @@ use crate::channels::web::types::{ToolCallInfo, TurnInfo};
pub use ironclaw_common::truncate_preview; pub use ironclaw_common::truncate_preview;
/// Parse tool call summary JSON objects into `ToolCallInfo` structs.
fn parse_tool_call_infos(calls: &[serde_json::Value]) -> Vec<ToolCallInfo> {
calls
.iter()
.map(|c| ToolCallInfo {
name: c["name"].as_str().unwrap_or("unknown").to_string(),
has_result: c.get("result_preview").is_some_and(|v| !v.is_null()),
has_error: c.get("error").is_some_and(|v| !v.is_null()),
result_preview: c["result_preview"].as_str().map(String::from),
error: c["error"].as_str().map(String::from),
rationale: c["rationale"].as_str().map(String::from),
})
.collect()
}
/// Build TurnInfo pairs from flat DB messages (user/tool_calls/assistant triples). /// Build TurnInfo pairs from flat DB messages (user/tool_calls/assistant triples).
/// ///
/// Handles three message patterns: /// Handles three message patterns:
@@ -27,6 +42,7 @@ pub fn build_turns_from_db_messages(
started_at: msg.created_at.to_rfc3339(), started_at: msg.created_at.to_rfc3339(),
completed_at: None, completed_at: None,
tool_calls: Vec::new(), tool_calls: Vec::new(),
narrative: None,
}; };
// Check if next message is a tool_calls record // Check if next message is a tool_calls record
@@ -34,18 +50,28 @@ pub fn build_turns_from_db_messages(
&& next.role == "tool_calls" && next.role == "tool_calls"
{ {
let tc_msg = iter.next().expect("peeked"); let tc_msg = iter.next().expect("peeked");
match serde_json::from_str::<Vec<serde_json::Value>>(&tc_msg.content) { // Parse tool_calls JSON — supports two formats:
Ok(calls) => { // safety: no byte-index slicing; comment describes JSON shape
turn.tool_calls = calls match serde_json::from_str::<serde_json::Value>(&tc_msg.content) {
.iter() Ok(serde_json::Value::Array(calls)) => {
.map(|c| ToolCallInfo { // Old format: plain array
name: c["name"].as_str().unwrap_or("unknown").to_string(), turn.tool_calls = parse_tool_call_infos(&calls);
has_result: c.get("result_preview").is_some(), }
has_error: c.get("error").is_some(), Ok(serde_json::Value::Object(obj)) => {
result_preview: c["result_preview"].as_str().map(String::from), // New wrapped format with narrative
error: c["error"].as_str().map(String::from), turn.narrative = obj
}) .get("narrative")
.collect(); .and_then(|v| v.as_str())
.map(String::from);
if let Some(serde_json::Value::Array(calls)) = obj.get("calls") {
turn.tool_calls = parse_tool_call_infos(calls);
}
}
Ok(_) => {
tracing::warn!(
message_id = %tc_msg.id,
"Unexpected tool_calls JSON shape in DB, skipping"
);
} }
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
@@ -83,6 +109,7 @@ pub fn build_turns_from_db_messages(
started_at: msg.created_at.to_rfc3339(), started_at: msg.created_at.to_rfc3339(),
completed_at: Some(msg.created_at.to_rfc3339()), completed_at: Some(msg.created_at.to_rfc3339()),
tool_calls: Vec::new(), tool_calls: Vec::new(),
narrative: None,
}); });
turn_number += 1; turn_number += 1;
} }
@@ -201,4 +228,52 @@ mod tests {
assert!(turns[0].tool_calls.is_empty()); assert!(turns[0].tool_calls.is_empty());
assert_eq!(turns[0].state, "Completed"); assert_eq!(turns[0].state, "Completed");
} }
#[test]
fn test_build_turns_with_wrapped_tool_calls_format() {
let tc_json = serde_json::json!({
"narrative": "Searching memory for context before proceeding.",
"calls": [
{"name": "memory_search", "result_preview": "found 3 items", "rationale": "consult prior context"},
{"name": "shell", "error": "permission denied"}
]
});
let messages = vec![
make_msg("user", "Find info", 0),
make_msg("tool_calls", &tc_json.to_string(), 500),
make_msg("assistant", "Here's what I found", 1000),
];
let turns = build_turns_from_db_messages(&messages);
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0].narrative.as_deref(),
Some("Searching memory for context before proceeding.")
);
assert_eq!(turns[0].tool_calls.len(), 2);
assert_eq!(turns[0].tool_calls[0].name, "memory_search");
assert_eq!(
turns[0].tool_calls[0].rationale.as_deref(),
Some("consult prior context")
);
assert!(turns[0].tool_calls[0].has_result);
assert_eq!(turns[0].tool_calls[1].name, "shell");
assert!(turns[0].tool_calls[1].has_error);
assert_eq!(turns[0].response.as_deref(), Some("Here's what I found"));
}
#[test]
fn test_build_turns_wrapped_format_without_narrative() {
let tc_json = serde_json::json!({
"calls": [{"name": "echo", "result_preview": "hello"}]
});
let messages = vec![
make_msg("user", "Say hi", 0),
make_msg("tool_calls", &tc_json.to_string(), 500),
make_msg("assistant", "Done", 1000),
];
let turns = build_turns_from_db_messages(&messages);
assert_eq!(turns.len(), 1);
assert!(turns[0].narrative.is_none());
assert_eq!(turns[0].tool_calls.len(), 1);
}
} }
+2
View File
@@ -575,6 +575,7 @@ fn extract_response_content(response: &AnthropicResponse) -> (Option<String>, Ve
id: id.clone(), id: id.clone(),
name: name.clone(), name: name.clone(),
arguments: input.clone(), arguments: input.clone(),
reasoning: None,
}); });
} }
} }
@@ -623,6 +624,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}), arguments: serde_json::json!({"q": "test"}),
reasoning: None,
}]; }];
let messages = vec![ let messages = vec![
ChatMessage::user("Search for test"), ChatMessage::user("Search for test"),
+7
View File
@@ -522,6 +522,7 @@ fn extract_content_blocks(
id: tu.tool_use_id().to_string(), id: tu.tool_use_id().to_string(),
name: tu.name().to_string(), name: tu.name().to_string(),
arguments: document_to_json(tu.input()), arguments: document_to_json(tu.input()),
reasoning: None,
}); });
} }
// Ignore reasoning, citations, images, etc. // Ignore reasoning, citations, images, etc.
@@ -759,11 +760,13 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({"text": "hi"}), arguments: serde_json::json!({"text": "hi"}),
reasoning: None,
}; };
let tc2 = crate::llm::provider::ToolCall { let tc2 = crate::llm::provider::ToolCall {
id: "call_2".to_string(), id: "call_2".to_string(),
name: "time".to_string(), name: "time".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}; };
let messages = vec![ let messages = vec![
@@ -802,6 +805,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}), arguments: serde_json::json!({"query": "test"}),
reasoning: None,
}; };
let messages = vec![ let messages = vec![
@@ -825,6 +829,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}; };
let messages = vec![ let messages = vec![
@@ -989,11 +994,13 @@ mod tests {
id: "call_abc".to_string(), id: "call_abc".to_string(),
name: "get_weather".to_string(), name: "get_weather".to_string(),
arguments: serde_json::json!({"city": "NYC"}), arguments: serde_json::json!({"city": "NYC"}),
reasoning: None,
}; };
let tc2 = crate::llm::provider::ToolCall { let tc2 = crate::llm::provider::ToolCall {
id: "call_def".to_string(), id: "call_def".to_string(),
name: "get_time".to_string(), name: "get_time".to_string(),
arguments: serde_json::json!({"tz": "EST"}), arguments: serde_json::json!({"tz": "EST"}),
reasoning: None,
}; };
let messages = vec![ let messages = vec![
+2
View File
@@ -732,6 +732,7 @@ impl LlmProvider for CodexChatGptProvider {
id: tc.call_id, id: tc.call_id,
name: tc.name, name: tc.name,
arguments: args, arguments: args,
reasoning: None,
} }
}) })
.collect(); .collect();
@@ -825,6 +826,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: json!({"query": "rust"}), arguments: json!({"query": "rust"}),
reasoning: None,
}; };
let msg = ChatMessage::assistant_with_tool_calls(Some("thinking...".into()), vec![tc]); let msg = ChatMessage::assistant_with_tool_calls(Some("thinking...".into()), vec![tc]);
let items = CodexChatGptProvider::message_to_input_items(&msg); let items = CodexChatGptProvider::message_to_input_items(&msg);
+1
View File
@@ -1898,6 +1898,7 @@ impl GeminiOauthProvider {
id, id,
name, name,
arguments: args, arguments: args,
reasoning: None,
}); });
} }
} }
+2
View File
@@ -596,6 +596,7 @@ fn extract_choice_content(choice: &OpenAiChoice) -> (Option<String>, Vec<ToolCal
name: tc.function.name.clone(), name: tc.function.name.clone(),
arguments: serde_json::from_str(&tc.function.arguments) arguments: serde_json::from_str(&tc.function.arguments)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new())), .unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
reasoning: None,
}) })
.collect() .collect()
}) })
@@ -628,6 +629,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}), arguments: serde_json::json!({"q": "test"}),
reasoning: None,
}]; }];
let messages = vec![ let messages = vec![
ChatMessage::user("Search"), ChatMessage::user("Search"),
+7
View File
@@ -587,6 +587,7 @@ impl LlmProvider for NearAiChatProvider {
id: tc.id, id: tc.id,
name: tc.function.name, name: tc.function.name,
arguments, arguments,
reasoning: None,
} }
}) })
.collect(); .collect();
@@ -1180,11 +1181,13 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "list_issues".to_string(), name: "list_issues".to_string(),
arguments: serde_json::json!({"owner": "foo", "repo": "bar"}), arguments: serde_json::json!({"owner": "foo", "repo": "bar"}),
reasoning: None,
}, },
ToolCall { ToolCall {
id: "call_2".to_string(), id: "call_2".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}), arguments: serde_json::json!({"query": "test"}),
reasoning: None,
}, },
]; ];
@@ -1217,6 +1220,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "test".to_string(), name: "test".to_string(),
arguments: serde_json::json!({"key": "value"}), arguments: serde_json::json!({"key": "value"}),
reasoning: None,
}; };
let msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]); let msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]);
let chat_msg: ChatCompletionMessage = msg.into(); let chat_msg: ChatCompletionMessage = msg.into();
@@ -1460,6 +1464,7 @@ mod tests {
id: tc.id, id: tc.id,
name: tc.function.name, name: tc.function.name,
arguments, arguments,
reasoning: None,
} }
}) })
.collect(); .collect();
@@ -1509,6 +1514,7 @@ mod tests {
id: tc.id, id: tc.id,
name: tc.function.name, name: tc.function.name,
arguments, arguments,
reasoning: None,
} }
}) })
.collect(); .collect();
@@ -2131,6 +2137,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "test".to_string(), name: "test".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}], }],
); );
let chat_msg: ChatCompletionMessage = msg.into(); let chat_msg: ChatCompletionMessage = msg.into();
+5
View File
@@ -625,6 +625,7 @@ fn parse_sse_response(body: &str) -> Result<ParsedResponse, LlmError> {
id: state.call_id, id: state.call_id,
name: state.name, name: state.name,
arguments, arguments,
reasoning: None,
}); });
} else { } else {
// Fallback: extract directly from the item // Fallback: extract directly from the item
@@ -650,6 +651,7 @@ fn parse_sse_response(body: &str) -> Result<ParsedResponse, LlmError> {
id: call_id, id: call_id,
name, name,
arguments, arguments,
reasoning: None,
}); });
} }
} }
@@ -727,6 +729,7 @@ fn parse_sse_response(body: &str) -> Result<ParsedResponse, LlmError> {
id: state.call_id, id: state.call_id,
name: state.name, name: state.name,
arguments, arguments,
reasoning: None,
}); });
} }
} }
@@ -822,11 +825,13 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}), arguments: serde_json::json!({"query": "test"}),
reasoning: None,
}, },
ToolCall { ToolCall {
id: "call_2".to_string(), id: "call_2".to_string(),
name: "read".to_string(), name: "read".to_string(),
arguments: serde_json::json!({"path": "/tmp"}), arguments: serde_json::json!({"path": "/tmp"}),
reasoning: None,
}, },
]; ];
let msg = let msg =
+8
View File
@@ -231,6 +231,10 @@ pub struct ToolCall {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub arguments: serde_json::Value, pub arguments: serde_json::Value,
/// Optional reasoning for why this tool was chosen — supplied by the provider
/// or derived from the shared response content as a fallback.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
} }
/// Generate a tool-call ID that satisfies all providers. /// Generate a tool-call ID that satisfies all providers.
@@ -637,6 +641,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}; };
let mut messages = vec![ let mut messages = vec![
ChatMessage::user("hello"), ChatMessage::user("hello"),
@@ -680,6 +685,7 @@ mod tests {
id: "call_1".to_string(), id: "call_1".to_string(),
name: "echo".to_string(), name: "echo".to_string(),
arguments: serde_json::json!({}), arguments: serde_json::json!({}),
reasoning: None,
}; };
let mut messages = vec![ let mut messages = vec![
ChatMessage::user("test"), ChatMessage::user("test"),
@@ -705,11 +711,13 @@ mod tests {
id: "call_sel_1".to_string(), id: "call_sel_1".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"q": "test"}), arguments: serde_json::json!({"q": "test"}),
reasoning: None,
}; };
let tc2 = ToolCall { let tc2 = ToolCall {
id: "call_sel_2".to_string(), id: "call_sel_2".to_string(),
name: "http".to_string(), name: "http".to_string(),
arguments: serde_json::json!({"url": "https://example.com"}), arguments: serde_json::json!({"url": "https://example.com"}),
reasoning: None,
}; };
let mut messages = vec![ let mut messages = vec![
ChatMessage::system("You are a helpful assistant."), ChatMessage::system("You are a helpful assistant."),
+85 -12
View File
@@ -525,17 +525,35 @@ impl Reasoning {
let response = self.llm.complete_with_tools(request).await?; let response = self.llm.complete_with_tools(request).await?;
let reasoning = response.content.unwrap_or_default(); let shared_reasoning = response
.content
.map(|c| {
let pre_truncated = truncate_at_tool_tags(&c);
clean_response(&pre_truncated)
})
.unwrap_or_default();
let selections: Vec<ToolSelection> = response let selections: Vec<ToolSelection> = response
.tool_calls .tool_calls
.into_iter() .into_iter()
.map(|tool_call| ToolSelection { .map(|tool_call| {
tool_name: tool_call.name, // Prefer per-tool reasoning if the provider supplied it,
parameters: tool_call.arguments, // otherwise fall back to the shared response content.
reasoning: reasoning.clone(), let rationale = tool_call
alternatives: vec![], .reasoning
tool_call_id: tool_call.id, .map(|r| {
let pre_truncated = truncate_at_tool_tags(&r);
clean_response(&pre_truncated)
})
.filter(|r| !r.trim().is_empty())
.unwrap_or_else(|| shared_reasoning.clone());
ToolSelection {
tool_name: tool_call.name,
parameters: tool_call.arguments,
reasoning: rationale,
alternatives: vec![],
tool_call_id: tool_call.id,
}
}) })
.collect(); .collect();
@@ -664,13 +682,36 @@ Respond in JSON format:
// If there were tool calls, return them for execution // If there were tool calls, return them for execution
if !response.tool_calls.is_empty() { if !response.tool_calls.is_empty() {
let narrative = response.content.map(|c| {
let pre_truncated = truncate_at_tool_tags(&c);
clean_response(&pre_truncated)
});
// Populate per-tool reasoning from the shared narrative when the
// provider did not supply per-tool rationale.
let tool_calls: Vec<ToolCall> = response
.tool_calls
.into_iter()
.map(|mut tc| {
if tc.reasoning.as_ref().is_none_or(|r| r.trim().is_empty()) {
tc.reasoning = narrative.as_ref().filter(|n| !n.is_empty()).cloned();
} else {
// Clean provider-supplied per-tool reasoning the same way
// we clean the shared narrative (strip thinking/tool tags).
tc.reasoning = tc
.reasoning
.map(|r| {
let pre_truncated = truncate_at_tool_tags(&r);
clean_response(&pre_truncated)
})
.filter(|r| !r.trim().is_empty());
}
tc
})
.collect();
return Ok(RespondOutput { return Ok(RespondOutput {
result: RespondResult::ToolCalls { result: RespondResult::ToolCalls {
tool_calls: response.tool_calls, tool_calls,
content: response.content.map(|c| { content: narrative,
let pre_truncated = truncate_at_tool_tags(&c);
clean_response(&pre_truncated)
}),
}, },
usage, usage,
}); });
@@ -1350,6 +1391,7 @@ fn recover_tool_calls_from_content(
), ),
name: name.to_string(), name: name.to_string(),
arguments, arguments,
reasoning: None,
}); });
continue; continue;
} }
@@ -1364,6 +1406,7 @@ fn recover_tool_calls_from_content(
), ),
name: name.to_string(), name: name.to_string(),
arguments: serde_json::Value::Object(Default::default()), arguments: serde_json::Value::Object(Default::default()),
reasoning: None,
}); });
} }
} }
@@ -1401,6 +1444,7 @@ fn recover_tool_calls_from_content(
), ),
name: name.to_string(), name: name.to_string(),
arguments, arguments,
reasoning: None,
}); });
remaining = &args_start[bracket_end + 1..]; remaining = &args_start[bracket_end + 1..];
continue; continue;
@@ -1412,6 +1456,7 @@ fn recover_tool_calls_from_content(
id: super::provider::generate_tool_call_id(calls.len(), RECOVERED_TOOL_CALL_SEED), id: super::provider::generate_tool_call_id(calls.len(), RECOVERED_TOOL_CALL_SEED),
name: name.to_string(), name: name.to_string(),
arguments: serde_json::Value::Object(Default::default()), arguments: serde_json::Value::Object(Default::default()),
reasoning: None,
}); });
remaining = after_name; remaining = after_name;
} }
@@ -3145,4 +3190,32 @@ That's my plan."#;
"Text <function_call>{}</function_call> middle " "Text <function_call>{}</function_call> middle "
); );
} }
/// Verify that reasoning normalization strips thinking tags and tool tags
/// from per-tool reasoning, matching the cleaning applied to shared reasoning.
#[test]
fn test_reasoning_normalization_strips_thinking_tags() {
let raw = "<thinking>Let me consider...</thinking>Search memory for prior context";
let pre_truncated = truncate_at_tool_tags(raw);
let cleaned = clean_response(&pre_truncated);
assert!(!cleaned.contains("<thinking>"));
assert!(cleaned.contains("Search memory"));
}
#[test]
fn test_reasoning_normalization_strips_tool_tags() {
let raw = "Calling search <tool_call>{\"name\": \"search\"}";
let pre_truncated = truncate_at_tool_tags(raw);
let cleaned = clean_response(&pre_truncated);
assert!(!cleaned.contains("<tool_call>"));
assert!(cleaned.contains("Calling search"));
}
#[test]
fn test_reasoning_normalization_empty_after_cleaning() {
let raw = "<thinking>internal only</thinking>";
let pre_truncated = truncate_at_tool_tags(raw);
let cleaned = clean_response(&pre_truncated);
assert!(cleaned.trim().is_empty());
}
} }
+7
View File
@@ -490,6 +490,7 @@ fn extract_response(
id: tc.id.clone(), id: tc.id.clone(),
name: tc.function.name.clone(), name: tc.function.name.clone(),
arguments: tc.function.arguments.clone(), arguments: tc.function.arguments.clone(),
reasoning: None,
}); });
} }
// Reasoning and Image variants are not mapped to IronClaw types // Reasoning and Image variants are not mapped to IronClaw types
@@ -880,6 +881,7 @@ mod tests {
id: "Xt7mK9pQ2".to_string(), id: "Xt7mK9pQ2".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}), arguments: serde_json::json!({"query": "test"}),
reasoning: None,
}; };
let msg = ChatMessage::assistant_with_tool_calls(Some("thinking".to_string()), vec![tc]); let msg = ChatMessage::assistant_with_tool_calls(Some("thinking".to_string()), vec![tc]);
let messages = vec![msg]; let messages = vec![msg];
@@ -997,6 +999,7 @@ mod tests {
id: "".to_string(), id: "".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}), arguments: serde_json::json!({"query": "test"}),
reasoning: None,
}; };
let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])]; let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])];
let (_preamble, history) = convert_messages(&messages); let (_preamble, history) = convert_messages(&messages);
@@ -1028,6 +1031,7 @@ mod tests {
id: " ".to_string(), id: " ".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}), arguments: serde_json::json!({"query": "test"}),
reasoning: None,
}; };
let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])]; let messages = vec![ChatMessage::assistant_with_tool_calls(None, vec![tc])];
let (_preamble, history) = convert_messages(&messages); let (_preamble, history) = convert_messages(&messages);
@@ -1061,6 +1065,7 @@ mod tests {
id: "".to_string(), id: "".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"query": "test"}), arguments: serde_json::json!({"query": "test"}),
reasoning: None,
}; };
let assistant_msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]); let assistant_msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]);
let tool_result_msg = ChatMessage { let tool_result_msg = ChatMessage {
@@ -1380,11 +1385,13 @@ mod tests {
id: "call_a".to_string(), id: "call_a".to_string(),
name: "search".to_string(), name: "search".to_string(),
arguments: serde_json::json!({"q": "rust"}), arguments: serde_json::json!({"q": "rust"}),
reasoning: None,
}; };
let tc2 = IronToolCall { let tc2 = IronToolCall {
id: "call_b".to_string(), id: "call_b".to_string(),
name: "fetch".to_string(), name: "fetch".to_string(),
arguments: serde_json::json!({"url": "https://example.com"}), arguments: serde_json::json!({"url": "https://example.com"}),
reasoning: None,
}; };
let assistant = ChatMessage::assistant_with_tool_calls(None, vec![tc1, tc2]); let assistant = ChatMessage::assistant_with_tool_calls(None, vec![tc1, tc2]);
let result_a = ChatMessage::tool_result("call_a", "search", "search results"); let result_a = ChatMessage::tool_result("call_a", "search", "search results");
+15
View File
@@ -14,6 +14,7 @@ use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, broadcast}; use tokio::sync::{Mutex, broadcast};
use uuid::Uuid; use uuid::Uuid;
use crate::channels::web::types::ToolDecisionDto;
use crate::db::Database; use crate::db::Database;
use crate::llm::{CompletionRequest, LlmProvider, ToolCompletionRequest}; use crate::llm::{CompletionRequest, LlmProvider, ToolCompletionRequest};
use crate::orchestrator::auth::{TokenStore, worker_auth_middleware}; use crate::orchestrator::auth::{TokenStore, worker_auth_middleware};
@@ -344,6 +345,20 @@ async fn job_event_handler(
// gain context/memory tracking capabilities. // gain context/memory tracking capabilities.
fallback_deliverable: payload.data.get("fallback_deliverable").cloned(), fallback_deliverable: payload.data.get("fallback_deliverable").cloned(),
}, },
"reasoning" => {
let narrative = payload
.data
.get("narrative")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let decisions = ToolDecisionDto::from_json_array(&payload.data["decisions"]);
AppEvent::JobReasoning {
job_id: job_id_str,
narrative,
decisions,
}
}
_ => AppEvent::JobStatus { _ => AppEvent::JobStatus {
job_id: job_id_str, job_id: job_id_str,
message: payload message: payload
+67 -1
View File
@@ -18,6 +18,7 @@ use crate::agent::agentic_loop::{
}; };
use crate::agent::scheduler::WorkerMessage; use crate::agent::scheduler::WorkerMessage;
use crate::agent::task::TaskOutput; use crate::agent::task::TaskOutput;
use crate::channels::web::types::ToolDecisionDto;
use crate::context::{ContextManager, JobState}; use crate::context::{ContextManager, JobState};
use crate::db::Database; use crate::db::Database;
use crate::error::Error; use crate::error::Error;
@@ -200,6 +201,19 @@ impl Worker {
.map(|s| s.to_string()), .map(|s| s.to_string()),
fallback_deliverable: data.get("fallback_deliverable").cloned(), fallback_deliverable: data.get("fallback_deliverable").cloned(),
}), }),
"reasoning" => {
let narrative = data
.get("narrative")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let decisions = ToolDecisionDto::from_json_array(&data["decisions"]);
Some(AppEvent::JobReasoning {
job_id: job_id_str,
narrative,
decisions,
})
}
_ => None, _ => None,
}; };
if let Some(event) = event { if let Some(event) = event {
@@ -897,6 +911,11 @@ Report when the job is complete or if you encounter issues you cannot resolve."#
id: selection.tool_call_id.clone(), id: selection.tool_call_id.clone(),
name: selection.tool_name.clone(), name: selection.tool_name.clone(),
arguments: selection.parameters.clone(), arguments: selection.parameters.clone(),
reasoning: if action.reasoning.is_empty() {
None
} else {
Some(action.reasoning.clone())
},
}], }],
)); ));
@@ -1357,6 +1376,48 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
); );
} }
// Emit reasoning event if any tool calls carry reasoning.
// Sanitize narrative and per-tool rationale through SafetyLayer
// (parity with ChatDelegate in dispatcher.rs).
let sanitized_narrative = content
.as_deref()
.filter(|c| !c.trim().is_empty())
.map(|c| {
self.worker
.deps
.safety
.sanitize_tool_output("job_narrative", c)
.content
})
.filter(|c| !c.trim().is_empty())
.unwrap_or_default();
let decisions: Vec<serde_json::Value> = tool_calls
.iter()
.filter_map(|tc| {
tc.reasoning.as_ref().map(|r| {
let sanitized = self
.worker
.deps
.safety
.sanitize_tool_output("tool_rationale", r)
.content;
serde_json::json!({
"tool_name": tc.name,
"rationale": sanitized,
})
})
})
.collect();
if !decisions.is_empty() {
self.worker.log_event(
"reasoning",
serde_json::json!({
"narrative": sanitized_narrative,
"decisions": decisions,
}),
);
}
// Add assistant message with tool_calls (OpenAI protocol) // Add assistant message with tool_calls (OpenAI protocol)
reason_ctx reason_ctx
.messages .messages
@@ -1371,7 +1432,7 @@ impl<'a> LoopDelegate for JobDelegate<'a> {
.map(|tc| ToolSelection { .map(|tc| ToolSelection {
tool_name: tc.name.clone(), tool_name: tc.name.clone(),
parameters: tc.arguments.clone(), parameters: tc.arguments.clone(),
reasoning: String::new(), reasoning: tc.reasoning.clone().unwrap_or_default(),
alternatives: vec![], alternatives: vec![],
tool_call_id: tc.id.clone(), tool_call_id: tc.id.clone(),
}) })
@@ -1424,6 +1485,11 @@ fn selections_to_tool_calls(selections: &[ToolSelection]) -> Vec<ToolCall> {
id: s.tool_call_id.clone(), id: s.tool_call_id.clone(),
name: s.tool_name.clone(), name: s.tool_name.clone(),
arguments: s.parameters.clone(), arguments: s.parameters.clone(),
reasoning: if s.reasoning.is_empty() {
None
} else {
Some(s.reasoning.clone())
},
}) })
.collect() .collect()
} }
+1
View File
@@ -94,6 +94,7 @@ impl LlmProvider for MockLlmProvider {
id: "call_mock_001".to_string(), id: "call_mock_001".to_string(),
name: tool.name.clone(), name: tool.name.clone(),
arguments: serde_json::json!({"test": true}), arguments: serde_json::json!({"test": true}),
reasoning: None,
}], }],
input_tokens: 15, input_tokens: 15,
output_tokens: 8, output_tokens: 8,
+1
View File
@@ -566,6 +566,7 @@ impl LlmProvider for TraceLlm {
id: tc.id, id: tc.id,
name: tc.name, name: tc.name,
arguments: tc.arguments, arguments: tc.arguments,
reasoning: None,
}) })
.collect(); .collect();
Ok(ToolCompletionResponse { Ok(ToolCompletionResponse {