fix(agent): normalize tool arguments and recover invalid calls

Accept null and empty arguments as an empty object, and validate argument shape once before dispatch. Malformed calls consume their budget and return recoverable tool errors.

Normalize provider replay while preserving rejected input in audit storage. Include runtime and protocol regression coverage and the tool argument contract.

Validation: pnpm run check.
This commit is contained in:
Darkatse 2026-09-13 06:56:02 -04:00
parent 79cbcfe7d2
commit 94601423e8
43 changed files with 546 additions and 370 deletions

View file

@ -27,6 +27,14 @@ Invocation 使用的工具快照包含描述、参数、名称映射及预算上
聊天工具读取 Run 输入对应的历史范围;文件读取使用 1-based 行号,聊天消息索引使用 0-based。较长文本可以分段读取。完整工具集合会按 Profile 收窄,return-mode 子 Agent 使用 `task.return` 作为结束工具。
## 参数
一次调用携带一组具名参数。线上要么是 JSON object,要么是它的 JSON 字符串编码,「没有参数」在各家实现里有多种等价写法(字段缺失、`null`、`""`、`"null"`、`"{}"`)。`ToolArguments` 是唯一的换算位置:先剥掉字符串编码,再用同一条规则判断,因此两种编码得到同一个参数集合,全可选参数的工具可以直接无参调用。
既不是 object、也不属于上述空集合写法的内容(截断的 JSON、数组、标量)原样保留。runtime 在预算计入之后、分派之前拒绝这类调用,返回可恢复的 `tool.invalid_arguments` 并回显开头一段原文;工具本身只接收已经确定为 object 的参数。
各渠道回放统一使用对象参数:合法对象保持原值,非法参数回放为 `{}`,拒绝原因由配对的 tool error 承载。持久化仍保留非法参数原文。
## 结果如何进入下一轮
`AgentToolResult` 包含调用 ID、工具 ID、文本、结构化结果、错误信息和资源引用。runtime 把它作为 tool message 追加到模型上下文,并保存调用记录。
@ -47,5 +55,6 @@ Skill 提供按需读取的工作方法、材料和脚本。它沿用相同的
- [registry.rs](../../src-tauri/crates/tt-application/src/services/agent_tools/registry.rs):内置目录。
- [policy.rs](../../src-tauri/crates/tt-application/src/services/agent_tools/policy.rs):Invocation 工具快照。
- [tool.rs](../../src-tauri/crates/tt-domain/src/models/tool.rs):`ToolArguments` 的线上编码换算规则。
- [tool_request_gate.rs](../../src-tauri/crates/tt-application/src/services/tool_request_gate.rs):调用检查与预算。
- [tool_execution.rs](../../src-tauri/crates/tt-application/src/services/agent_runtime_service/tool_execution.rs):分派和记录。

View file

@ -1,4 +1,5 @@
use super::*;
use tt_domain::models::tool::ToolId;
use tt_domain::models::upstream_failure::{UPSTREAM_NETWORK_TIMEOUT, UpstreamFailure};
#[tokio::test]
@ -354,6 +355,98 @@ async fn agent_runtime_returns_missing_chat_reads_to_the_agent() {
let _ = fs::remove_dir_all(root).await;
}
#[tokio::test]
async fn agent_runtime_normalizes_empty_arguments_and_recovers_from_invalid_arguments() {
let root = temp_root("agent-tool-arguments");
let mut malformed = model_tool_call("call_invalid", "workspace_list_files", json!({}));
malformed["function"]["arguments"] = json!(r#"{"path":"#);
let fixture = agent_runtime_fixture_with_responses(
&root,
vec![
model_tool_response(vec![
// The helper encodes this as the wire string "null".
model_tool_call("call_empty", "workspace_list_files", Value::Null),
malformed,
]),
model_tool_response(vec![
model_tool_call("call_over_budget", "workspace_list_files", json!({})),
model_tool_call(
"call_write",
"workspace_write_file",
json!({ "path": "output/main.md", "content": "continued after invalid arguments" }),
),
model_tool_call("call_finish", "workspace_finish", json!({})),
]),
],
);
let mut profile = resolve_contract_profile(&fixture).await;
profile.tools.max_rounds = 2;
profile
.tools
.max_calls_per_tool
.insert(ToolId::builtin("workspace.list_files").unwrap(), 2);
let run = contract_run("run_arguments", AgentRunPresentation::Background, &profile);
fixture.agent_repository.create_run(&run).await.unwrap();
let request = chat_request("list files and write an output");
let prompt_snapshot = json!({ "chatCompletionPayload": request.payload.clone() });
let (_cancel_sender, mut cancel_receiver) = watch::channel(false);
fixture
.service
.execute_agent_loop_run_inner(
&run.id,
prompt_snapshot,
request,
profile,
&mut cancel_receiver,
)
.await
.expect("argument errors remain recoverable");
let requests = fixture.model_gateway.requests().await;
let results = requests[1]
.messages
.iter()
.flat_map(|message| &message.parts)
.filter_map(|part| match part {
AgentModelContentPart::ToolResult { result } => Some(result),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(results.len(), 2);
assert_eq!(results[0].call_id, "call_empty");
assert!(!results[0].is_error);
assert!(results[0].structured["entries"].is_array());
assert_eq!(
results[1].error_code.as_deref(),
Some("tool.invalid_arguments")
);
assert!(results[1].content.contains(r#"{"path":"#));
let events = read_agent_events(&fixture.agent_repository, &run.id).await;
assert!(events.iter().any(|event| {
event.event_type == "tool_call_failed"
&& event.payload["callId"] == "call_over_budget"
&& event.payload["errorCode"] == "agent.tool_budget_exhausted"
}));
let artifact = fixture
.agent_repository
.read_text(&run.id, &WorkspacePath::parse("output/main.md").unwrap())
.await
.unwrap();
assert_eq!(artifact.text, "continued after invalid arguments");
assert_eq!(
fixture
.agent_repository
.load_run(&run.id)
.await
.unwrap()
.status,
AgentRunStatus::Completed
);
fs::remove_dir_all(root).await.unwrap();
}
#[tokio::test]
async fn agent_runtime_duplicate_tool_call_id_preserves_first_audit_facts() {
let root = temp_root("agent-duplicate-tool-call-id");

View file

@ -7,7 +7,7 @@ use crate::services::chat_completion_service::exchange::{
use tt_domain::models::agent::{
AgentModelContentPart, AgentModelMessage, AgentModelResponse, AgentModelRole, AgentModelTool,
};
use tt_domain::models::tool::ToolInvocation;
use tt_domain::models::tool::{ToolArguments, ToolInvocation};
#[cfg(any(test, feature = "test-support"))]
pub fn decode_chat_completion_response(
@ -201,7 +201,7 @@ fn parse_tool_call(
))
})?;
let arguments =
parse_tool_call_arguments(function.get("arguments").or_else(|| function.get("args")));
ToolArguments::decode(function.get("arguments").or_else(|| function.get("args")));
Ok(ToolInvocation {
call_id: id.to_string(),
@ -222,16 +222,6 @@ pub(super) fn model_tool_for_alias<'a>(
tools.iter().find(|tool| tool.model_alias == alias)
}
fn parse_tool_call_arguments(value: Option<&Value>) -> Value {
match value {
Some(Value::String(raw)) => {
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}
Some(Value::Null) | None => Value::Object(Map::new()),
Some(value) => value.clone(),
}
}
fn extract_text_from_message(message: &Map<String, Value>) -> String {
text_from_value(message.get("content")).unwrap_or_default()
}

View file

@ -290,10 +290,6 @@ fn encode_openai_tool_call(
let model_alias = model_tool_for_id(&call.tool_id, tools)
.map(|tool| tool.model_alias.as_str())
.ok_or_else(|| tool_history_not_advertised(&call.tool_id))?;
let arguments = serde_json::to_string(&call.arguments).map_err(|error| {
ApplicationError::ValidationError(format!("agent.tool_call_serialize_failed: {error}"))
})?;
let mut object = Map::new();
object.insert("id".to_string(), Value::String(call.call_id.clone()));
object.insert("type".to_string(), Value::String("function".to_string()));
@ -301,7 +297,7 @@ fn encode_openai_tool_call(
"function".to_string(),
json!({
"name": model_alias,
"arguments": arguments,
"arguments": call.arguments.encode_for_replay(),
}),
);

View file

@ -15,7 +15,7 @@ use tt_domain::models::agent::{
AgentModelContentPart, AgentModelMessage, AgentModelRequest, AgentModelRole, AgentModelTool,
AgentToolResult,
};
use tt_domain::models::tool::{ToolChoice, ToolId, ToolInvocation, ToolProviderId};
use tt_domain::models::tool::{ToolArguments, ToolChoice, ToolId, ToolInvocation, ToolProviderId};
use tt_ports::repositories::chat_completion_repository::{
CHAT_COMPLETION_PROVIDER_STATE_FIELD, ChatCompletionNormalizationReport, ChatCompletionSource,
};
@ -509,7 +509,9 @@ fn openai_responses_continuation_sends_only_new_tool_results() {
call: ToolInvocation {
call_id: "call_1".to_string(),
tool_id: ToolId::builtin("workspace.write_file").unwrap(),
arguments: json!({"path":"output/main.md","content":"hi"}),
arguments: ToolArguments::decode(Some(
&json!({"path":"output/main.md","content":"hi"}),
)),
provider_metadata: Value::Null,
},
},

View file

@ -2,7 +2,7 @@ use std::collections::HashSet;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use super::rendering::{DelegatedResultContinuationHint, render_await_content};
use super::task_status::task_is_terminal;
@ -68,6 +68,7 @@ impl AgentRuntimeService {
&self,
prepared: &PreparedInvocation,
call: &ToolInvocation,
args: &Map<String, Value>,
committed_count: usize,
cancel: &mut AgentCancelReceiver,
) -> Result<AgentToolDispatchOutcome, ApplicationError> {
@ -76,7 +77,7 @@ impl AgentRuntimeService {
let profile = &prepared.profile;
let parent_tools = &prepared.request.tools;
let started = Instant::now();
let args = match serde_json::from_value::<AgentAwaitArgs>(call.arguments.clone()) {
let args = match serde_json::from_value::<AgentAwaitArgs>(Value::Object(args.clone())) {
Ok(args) => args,
Err(error) => {
return Ok(tool_error_outcome(

View file

@ -1,7 +1,7 @@
use std::time::Instant;
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use super::policy::validate_subagent_target;
use super::tool_error::tool_error_outcome;
@ -29,11 +29,12 @@ impl AgentRuntimeService {
run_id: &str,
invocation_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
profile: &ResolvedAgentProfile,
_cancel: &AgentCancelReceiver,
) -> Result<AgentToolDispatchOutcome, ApplicationError> {
let started = Instant::now();
let args = match serde_json::from_value::<AgentDelegateArgs>(call.arguments.clone()) {
let args = match serde_json::from_value::<AgentDelegateArgs>(Value::Object(args.clone())) {
Ok(args) => args,
Err(error) => {
return Ok(tool_error_outcome(

View file

@ -36,10 +36,11 @@ impl AgentRuntimeService {
run_id: &str,
invocation_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
profile: &ResolvedAgentProfile,
) -> Result<AgentToolDispatchOutcome, ApplicationError> {
let started = Instant::now();
let args = match serde_json::from_value::<AgentHandoffArgs>(call.arguments.clone()) {
let args = match serde_json::from_value::<AgentHandoffArgs>(Value::Object(args.clone())) {
Ok(args) => args,
Err(error) => {
return Ok(tool_error_outcome(

View file

@ -48,18 +48,10 @@ impl AgentRuntimeService {
pub(in crate::services::agent_runtime_service) async fn dispatch_agent_list_tool(
&self,
call: &ToolInvocation,
args: &Map<String, Value>,
profile: &ResolvedAgentProfile,
) -> Result<AgentToolDispatchOutcome, ApplicationError> {
let started = Instant::now();
let Some(args) = call.arguments.as_object() else {
return Ok(tool_error_outcome(
call,
"tool.invalid_arguments",
"arguments must be an object",
started.elapsed().as_millis(),
));
};
let purpose = match agent_list_purpose(args) {
Ok(purpose) => purpose,
Err(message) => {

View file

@ -23,6 +23,7 @@ impl AgentRuntimeService {
run_id: &str,
invocation_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
exit_policy: AgentInvocationExitPolicy,
profile: &ResolvedAgentProfile,
) -> Result<AgentToolDispatchOutcome, ApplicationError> {
@ -35,14 +36,6 @@ impl AgentRuntimeService {
started.elapsed().as_millis(),
));
}
let Some(args) = call.arguments.as_object() else {
return Ok(tool_error_outcome(
call,
"tool.invalid_arguments",
"arguments must be an object",
started.elapsed().as_millis(),
));
};
let summary = match required_trimmed_string(args, "summary") {
Ok(summary) => summary,
Err(message) => {
@ -83,7 +76,7 @@ impl AgentRuntimeService {
}
let result_ref = WorkspacePath::parse(format!("agent-results/{invocation_id}.json"))?;
let summary_ref = task_result_summary_path(&task.workspace_key)?;
let result_payload = match normalize_task_return_arguments(&call.arguments, profile) {
let result_payload = match normalize_task_return_arguments(args, profile) {
Ok(arguments) => arguments,
Err(error) => {
return Ok(tool_error_outcome(
@ -230,13 +223,10 @@ impl TaskReturnArgumentError {
}
fn normalize_task_return_arguments(
arguments: &Value,
arguments: &Map<String, Value>,
profile: &ResolvedAgentProfile,
) -> Result<Value, TaskReturnArgumentError> {
let Some(args) = arguments.as_object() else {
return Ok(arguments.clone());
};
let mut args = args.clone();
let mut args = arguments.clone();
let Some(artifacts_value) = args.get("artifacts") else {
return Ok(Value::Object(args));
};

View file

@ -281,7 +281,7 @@ fn string_field(value: &Value, key: &str) -> Option<String> {
mod tests {
use super::*;
use tt_domain::models::agent::{AgentModelMessage, AgentModelRole};
use tt_domain::models::tool::{ToolId, ToolInvocation, ToolProviderId};
use tt_domain::models::tool::{ToolArguments, ToolId, ToolInvocation, ToolProviderId};
#[test]
fn model_turn_projection_preserves_canonical_tool_identity() {
@ -295,7 +295,7 @@ mod tests {
vec![ToolInvocation {
call_id: "call_mcp".to_string(),
tool_id: tool_id.clone(),
arguments: Value::Null,
arguments: ToolArguments::empty(),
provider_metadata: Value::Null,
}],
);
@ -361,7 +361,7 @@ mod tests {
ToolInvocation {
call_id: "call_1".to_string(),
tool_id: ToolId::builtin("workspace.write_file").unwrap(),
arguments: json!({}),
arguments: ToolArguments::empty(),
provider_metadata: Value::Null,
}
}

View file

@ -1,6 +1,6 @@
use std::time::Instant;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
@ -129,6 +129,19 @@ impl AgentRuntimeService {
return Err(error);
}
// Charge the budget before rejecting arguments so malformed calls cannot retry for free.
let args = match tool_invocation.arguments.as_map() {
Ok(args) => args,
Err(message) => {
return Ok(recoverable_tool_error(
tool_invocation,
"tool.invalid_arguments",
&message,
started.elapsed().as_millis(),
));
}
};
let call = tool_invocation;
if exit_policy == AgentInvocationExitPolicy::RunFinishAllowed {
self.transition_status(run_id, AgentRunStatus::DispatchingTool)
@ -162,29 +175,43 @@ impl AgentRuntimeService {
started.elapsed().as_millis(),
))
} else if builtin_name == Some(AGENT_LIST) {
self.dispatch_agent_list_tool(call, profile).await
self.dispatch_agent_list_tool(call, args, profile).await
} else if builtin_name == Some(AGENT_DELEGATE) {
Box::pin(self.dispatch_agent_delegate_tool(
run_id,
invocation_id,
call,
args,
profile,
cancel,
))
.await
} else if builtin_name == Some(AGENT_AWAIT) {
self.dispatch_agent_await_tool(prepared, call, commit_ledger.explicit_count(), cancel)
.await
self.dispatch_agent_await_tool(
prepared,
call,
args,
commit_ledger.explicit_count(),
cancel,
)
.await
} else if builtin_name == Some(AGENT_HANDOFF) {
self.dispatch_agent_handoff_tool(run_id, invocation_id, call, profile)
self.dispatch_agent_handoff_tool(run_id, invocation_id, call, args, profile)
.await
} else if builtin_name == Some(TASK_RETURN) {
self.dispatch_task_return_tool(run_id, invocation_id, call, exit_policy, profile)
.await
self.dispatch_task_return_tool(
run_id,
invocation_id,
call,
args,
exit_policy,
profile,
)
.await
} else if !call.tool_id.is_builtin() {
// The MCP service reports sent-but-unconfirmed calls explicitly.
started_tool = false;
let outcome = self.call_mcp_tool(call, cancel).await?;
let outcome = self.call_mcp_tool(call, args, cancel).await?;
started_tool = true;
match outcome {
McpCallOutcome::KnownResponse(response) => Ok(AgentToolDispatchOutcome {
@ -226,6 +253,7 @@ impl AgentRuntimeService {
.dispatch_with_model_workspace_repository(
run_id,
call,
args,
session,
profile,
&workspace_repository,
@ -233,7 +261,7 @@ impl AgentRuntimeService {
.await
} else {
self.tool_dispatcher
.dispatch(run_id, call, session, profile)
.dispatch(run_id, call, args, session, profile)
.await
};
@ -373,6 +401,7 @@ impl AgentRuntimeService {
async fn call_mcp_tool(
&self,
call: &ToolInvocation,
args: &Map<String, Value>,
cancel: &mut super::AgentCancelReceiver,
) -> Result<McpCallOutcome, ApplicationError> {
let cancellation = CancellationToken::new();
@ -392,7 +421,7 @@ impl AgentRuntimeService {
};
let outcome = self
.mcp_service
.call_permitted_tool(&call.tool_id, call.arguments.clone(), cancellation)
.call_permitted_tool(&call.tool_id, Value::Object(args.clone()), cancellation)
.await;
if let Some(watcher) = watcher {
watcher.abort();
@ -793,7 +822,7 @@ mod tests {
use tt_domain::models::agent::AgentToolResult;
use tt_domain::models::agent::WorkspacePath;
use tt_domain::models::tool::{
InvocationToolSnapshot, ToolBinding, ToolDescriptor, ToolId, ToolInvocation,
InvocationToolSnapshot, ToolArguments, ToolBinding, ToolDescriptor, ToolId, ToolInvocation,
ToolProviderId, ToolSnapshotId,
};
@ -807,7 +836,7 @@ mod tests {
let invocation = ToolInvocation {
call_id: "call_1".to_string(),
tool_id: ToolId::builtin("workspace.finish").unwrap(),
arguments: Value::Null,
arguments: ToolArguments::empty(),
provider_metadata: Value::Null,
};
let result = AgentToolResult {

View file

@ -9,7 +9,7 @@ use super::{
chat_unavailable_message, role_as_str, visible_total_messages,
};
use crate::errors::ApplicationError;
use crate::services::agent_tools::common::{ensure_only_args, object_args, tool_error};
use crate::services::agent_tools::common::{ensure_only_args, tool_error};
use crate::services::agent_tools::dispatcher::AgentToolEffect;
use tt_domain::errors::DomainError;
use tt_domain::frozen_macros::{FrozenMacros, MAX_EXPANDED_TEXT_BYTES};
@ -68,18 +68,9 @@ pub(in crate::services::agent_tools) async fn read_messages(
group_chat_repository: &dyn GroupChatRepository,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
macros: &FrozenMacros,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
if let Err(message) = ensure_only_args(args, &["messages"]) {
return Ok((
tool_error(call, "tool.invalid_arguments", &message),

View file

@ -9,7 +9,7 @@ use super::{
};
use crate::errors::ApplicationError;
use crate::services::agent_tools::common::{
object_args, optional_usize_arg, required_trimmed_string_arg, tool_error,
optional_usize_arg, required_trimmed_string_arg, tool_error,
};
use crate::services::agent_tools::dispatcher::AgentToolEffect;
use tt_domain::errors::DomainError;
@ -51,18 +51,9 @@ pub(in crate::services::agent_tools) async fn search(
group_chat_repository: &dyn GroupChatRepository,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
macros: &Arc<FrozenMacros>,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
let query = match required_trimmed_string_arg(args, "query") {
Some(query) => query.to_string(),
None => {

View file

@ -5,10 +5,6 @@ use super::structured::{ToolErrorStructured, structured_value};
use tt_domain::models::agent::AgentToolResult;
use tt_domain::models::tool::ToolInvocation;
pub(super) fn object_args(call: &ToolInvocation) -> Option<&Map<String, Value>> {
call.arguments.as_object()
}
pub(super) fn required_trimmed_string_arg<'a>(
args: &'a Map<String, Value>,
key: &str,

View file

@ -1,9 +1,10 @@
use rand::RngExt;
use serde::Serialize;
use serde_json::{Map, Value};
use super::{MAX_ABS_MODIFIER, MAX_DICE, MAX_SIDES};
use crate::errors::ApplicationError;
use crate::services::agent_tools::common::{object_args, required_trimmed_string_arg, tool_error};
use crate::services::agent_tools::common::{required_trimmed_string_arg, tool_error};
use crate::services::agent_tools::dispatcher::AgentToolEffect;
use crate::services::agent_tools::structured::structured_value;
use tt_domain::models::agent::AgentToolResult;
@ -32,17 +33,8 @@ struct DiceRollStructured<'a> {
pub(in crate::services::agent_tools) async fn roll(
call: &ToolInvocation,
args: &Map<String, Value>,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
let Some(formula) = required_trimmed_string_arg(args, "formula") else {
return Ok((
tool_error(call, "tool.invalid_arguments", "formula is required"),
@ -224,7 +216,7 @@ mod tests {
use serde_json::{Value, json};
use super::{MAX_DICE, MAX_SIDES, parse_formula};
use tt_domain::models::tool::{ToolId, ToolInvocation};
use tt_domain::models::tool::{ToolArguments, ToolId, ToolInvocation};
#[test]
fn reject_invalid_or_unbounded_formulas() {
@ -251,11 +243,13 @@ mod tests {
let call = ToolInvocation {
call_id: "call_dice".to_string(),
tool_id: ToolId::builtin("dice.roll").unwrap(),
arguments: json!({ "formula": "1d1+2" }),
arguments: ToolArguments::decode(Some(&json!({ "formula": "1d1+2" }))),
provider_metadata: Value::Null,
};
let (result, effect) = super::roll(&call).await.expect("dice roll result");
let (result, effect) = super::roll(&call, call.arguments.as_map().unwrap())
.await
.expect("dice roll result");
assert!(matches!(
effect,

View file

@ -1,6 +1,8 @@
use std::sync::Arc;
use std::time::Instant;
use serde_json::{Map, Value};
use super::chat;
use super::dice;
use super::session::AgentToolSession;
@ -96,12 +98,14 @@ impl AgentToolDispatcher {
&self,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
session: &mut AgentToolSession,
profile: &ResolvedAgentProfile,
) -> Result<AgentToolDispatchOutcome, ApplicationError> {
self.dispatch_with_model_workspace_repository(
run_id,
call,
args,
session,
profile,
self.workspace_repository.as_ref(),
@ -113,6 +117,7 @@ impl AgentToolDispatcher {
&self,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
session: &mut AgentToolSession,
profile: &ResolvedAgentProfile,
model_workspace_repository: &dyn WorkspaceRepository,
@ -126,6 +131,7 @@ impl AgentToolDispatcher {
self.group_chat_repository.as_ref(),
run_id,
call,
args,
&session.frozen_macros,
)
.await?
@ -137,6 +143,7 @@ impl AgentToolDispatcher {
self.group_chat_repository.as_ref(),
run_id,
call,
args,
&session.frozen_macros,
)
.await?
@ -145,15 +152,15 @@ impl AgentToolDispatcher {
// WorldInfo activation is a hidden run input fact, not a model-visible
// workspace file; invocation workspace policy must not gate this read.
let prompt_snapshot = self.read_run_prompt_snapshot(run_id).await?;
world_info::read_activated(&prompt_snapshot, call)?
world_info::read_activated(&prompt_snapshot, call, args)?
}
dice::DICE_ROLL => dice::roll(call).await?,
dice::DICE_ROLL => dice::roll(call, args).await?,
skill::SKILL_LIST => skill::list(call, session, profile).await?,
skill::SKILL_SEARCH => {
skill::search(self.skill_service.as_ref(), call, session, profile).await?
skill::search(self.skill_service.as_ref(), call, args, session, profile).await?
}
skill::SKILL_READ => {
skill::read(self.skill_service.as_ref(), call, session, profile).await?
skill::read(self.skill_service.as_ref(), call, args, session, profile).await?
}
skill::SKILL_SCRIPT => {
let prompt_snapshot = self.read_run_prompt_snapshot(run_id).await?;
@ -166,30 +173,34 @@ impl AgentToolDispatcher {
prompt_snapshot,
},
call,
args,
session,
profile,
)
.await?
}
workspace::WORKSPACE_LIST_FILES => {
workspace::list_files(model_workspace_repository, run_id, call).await?
workspace::list_files(model_workspace_repository, run_id, call, args).await?
}
workspace::WORKSPACE_SEARCH_FILES => {
workspace::search_files(model_workspace_repository, run_id, call).await?
workspace::search_files(model_workspace_repository, run_id, call, args).await?
}
workspace::WORKSPACE_READ_FILE => {
workspace::read_file(model_workspace_repository, run_id, call, session).await?
workspace::read_file(model_workspace_repository, run_id, call, args, session)
.await?
}
workspace::WORKSPACE_WRITE_FILE => {
workspace::write_file(model_workspace_repository, run_id, call, session).await?
workspace::write_file(model_workspace_repository, run_id, call, args, session)
.await?
}
workspace::WORKSPACE_APPLY_PATCH => {
workspace::apply_patch(model_workspace_repository, run_id, call, session).await?
workspace::apply_patch(model_workspace_repository, run_id, call, args, session)
.await?
}
workspace::WORKSPACE_COMMIT => {
workspace::commit(model_workspace_repository, run_id, call, profile).await?
workspace::commit(model_workspace_repository, run_id, call, args, profile).await?
}
workspace::WORKSPACE_FINISH => workspace::finish(call)?,
workspace::WORKSPACE_FINISH => workspace::finish(call, args)?,
other => {
return Err(ApplicationError::InternalError(format!(
"tool.dispatch_handler_missing: admitted builtin tool `builtin:{other}` has no execution handler"

View file

@ -114,7 +114,7 @@ mod tests {
use super::{build_result, skill_is_visible};
use tt_domain::models::agent::profile::AgentSkillPolicy;
use tt_domain::models::skill::{SkillIndexEntry, SkillScope, SkillSourceRef};
use tt_domain::models::tool::{ToolId, ToolInvocation};
use tt_domain::models::tool::{ToolArguments, ToolId, ToolInvocation};
#[test]
fn wildcard_deny_hides_skills_even_when_visible_allows_all() {
@ -141,7 +141,7 @@ mod tests {
let call = ToolInvocation {
call_id: "call_skill_list".to_string(),
tool_id: ToolId::builtin("skill.list").unwrap(),
arguments: json!({}),
arguments: ToolArguments::empty(),
provider_metadata: Value::Null,
};

View file

@ -1,7 +1,8 @@
use serde::Serialize;
use serde_json::{Map, Value};
use super::super::common::{
ensure_only_args, object_args, optional_usize_arg, required_trimmed_string_arg, tool_error,
ensure_only_args, optional_usize_arg, required_trimmed_string_arg, tool_error,
};
use super::super::dispatcher::AgentToolEffect;
use super::super::session::AgentToolSession;
@ -31,19 +32,10 @@ struct SkillReadStructured<'a> {
pub(in crate::services::agent_tools) async fn read(
skill_service: &SkillService,
call: &ToolInvocation,
args: &Map<String, Value>,
session: &mut AgentToolSession,
profile: &ResolvedAgentProfile,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
if let Err(message) = ensure_only_args(args, &["name", "path", "start_line", "line_count"]) {
return Ok((
tool_error(call, "tool.invalid_arguments", &message),
@ -58,7 +50,7 @@ pub(in crate::services::agent_tools) async fn read(
};
let path = args
.get("path")
.and_then(serde_json::Value::as_str)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("SKILL.md");

View file

@ -2,9 +2,7 @@ use std::collections::HashMap;
use serde_json::{Map, Value, json};
use super::super::common::{
ensure_only_args, object_args, required_trimmed_string_arg, tool_error,
};
use super::super::common::{ensure_only_args, required_trimmed_string_arg, tool_error};
use super::super::dispatcher::AgentToolEffect;
use super::super::session::AgentToolSession;
use super::super::workspace::workspace_access_policy;
@ -44,6 +42,7 @@ pub(in crate::services::agent_tools) struct ScriptContext<'a> {
pub(in crate::services::agent_tools) async fn script(
context: ScriptContext<'_>,
call: &ToolInvocation,
args: &Map<String, Value>,
session: &AgentToolSession,
profile: &ResolvedAgentProfile,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
@ -54,16 +53,6 @@ pub(in crate::services::agent_tools) async fn script(
run_id,
prompt_snapshot,
} = context;
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
if let Err(message) = ensure_only_args(args, &["skill", "script", "args"]) {
return Ok((
tool_error(call, "tool.invalid_arguments", &message),
@ -83,7 +72,7 @@ pub(in crate::services::agent_tools) async fn script(
));
};
let script_args = match args.get("args") {
None => Value::Object(serde_json::Map::new()),
None => Value::Object(Map::new()),
Some(value) if value.is_object() => value.clone(),
Some(_) => {
return Ok((

View file

@ -106,6 +106,7 @@ async fn success_builds_result_and_passes_workspace_context() {
profile.workspace.visible_roots = vec!["profile-only".to_string()];
profile.workspace.writable_roots = vec!["profile-only".to_string()];
let tool_call = call(json!({ "skill": "demo", "script": "helper", "args": { "n": 7 } }));
let (result, effect) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -122,7 +123,8 @@ async fn success_builds_result_and_passes_workspace_context() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper", "args": { "n": 7 } })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)
@ -163,6 +165,7 @@ async fn module_snapshot_contains_only_script_modules() {
});
let session = session_with_skill("demo");
let profile = profile(true);
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, _) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -182,7 +185,8 @@ async fn module_snapshot_contains_only_script_modules() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)
@ -230,6 +234,7 @@ async fn frozen_host_context_is_passed_to_engine() {
}
});
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, _) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -246,7 +251,8 @@ async fn frozen_host_context_is_passed_to_engine() {
run_id: "run-1",
prompt_snapshot,
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)

View file

@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use tokio::sync::Mutex;
use super::*;
@ -26,7 +26,7 @@ use tt_domain::models::skill::{
SkillScope, SkillScopeFilter, SkillScopeRetargetRequest, SkillScopeRetargetResult,
SkillSearchRequest, SkillSearchResult, SkillWriteRequest,
};
use tt_domain::models::tool::ToolId;
use tt_domain::models::tool::{ToolArguments, ToolId};
use tt_ports::repositories::skill_repository::SkillRepository;
use tt_ports::repositories::workspace_repository::{
WorkspaceAppendResult, WorkspaceEntry, WorkspaceEntryKind, WorkspaceFile, WorkspaceFileList,
@ -506,11 +506,15 @@ fn call(arguments: Value) -> ToolInvocation {
ToolInvocation {
call_id: "call_skill_script".to_string(),
tool_id: ToolId::builtin("skill.run_script").unwrap(),
arguments,
arguments: ToolArguments::decode(Some(&arguments)),
provider_metadata: Value::Null,
}
}
fn call_args(call: &ToolInvocation) -> &Map<String, Value> {
call.arguments.as_map().expect("test arguments are objects")
}
fn empty_prompt_snapshot() -> Value {
json!({
"worldInfoActivation": { "entries": [] },
@ -529,6 +533,7 @@ async fn run_with_repo_and_outcome(
outcome,
requests: Mutex::new(Vec::new()),
});
let tool_call = call(arguments);
script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(repo)),
@ -543,7 +548,8 @@ async fn run_with_repo_and_outcome(
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(arguments),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)

View file

@ -23,6 +23,7 @@ async fn script_writes_produce_workspace_file_written_effect() {
let session = session_with_skill("demo");
let profile = profile(true);
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, effect) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -33,7 +34,8 @@ async fn script_writes_produce_workspace_file_written_effect() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)
@ -95,6 +97,7 @@ async fn multiple_files_written_produce_batch_effect() {
let session = session_with_skill("demo");
let profile = profile(true);
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, effect) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -105,7 +108,8 @@ async fn multiple_files_written_produce_batch_effect() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)
@ -165,6 +169,7 @@ async fn write_outside_writable_roots_is_rejected_before_any_disk_write() {
let session = session_with_skill("demo");
let profile = profile(true);
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, effect) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -175,7 +180,8 @@ async fn write_outside_writable_roots_is_rejected_before_any_disk_write() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)
@ -223,6 +229,7 @@ async fn existing_file_write_uses_snapshot_sha_guard() {
let session = session_with_skill("demo");
let profile = profile(true);
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, effect) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -233,7 +240,8 @@ async fn existing_file_write_uses_snapshot_sha_guard() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)
@ -280,6 +288,7 @@ async fn stale_conflict_fails_without_side_effects() {
let session = session_with_skill("demo");
let profile = profile(true);
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, effect) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -290,7 +299,8 @@ async fn stale_conflict_fails_without_side_effects() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)
@ -338,6 +348,7 @@ async fn mid_batch_failure_preserves_already_written_files_in_effect() {
let session = session_with_skill("demo");
let profile = profile(true);
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, effect) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -348,7 +359,8 @@ async fn mid_batch_failure_preserves_already_written_files_in_effect() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)
@ -391,6 +403,7 @@ async fn truncated_workspace_snapshot_returns_tool_error() {
let session = session_with_skill("demo");
let profile = profile(true);
let tool_call = call(json!({ "skill": "demo", "script": "helper" }));
let (result, effect) = script(
ScriptContext {
skill_service: &SkillService::new(Arc::new(FakeSkillRepo {
@ -407,7 +420,8 @@ async fn truncated_workspace_snapshot_returns_tool_error() {
run_id: "run-1",
prompt_snapshot: empty_prompt_snapshot(),
},
&call(json!({ "skill": "demo", "script": "helper" })),
&tool_call,
call_args(&tool_call),
&session,
&profile,
)

View file

@ -1,8 +1,7 @@
use serde::Serialize;
use serde_json::{Map, Value};
use super::super::common::{
object_args, optional_usize_arg, required_trimmed_string_arg, tool_error,
};
use super::super::common::{optional_usize_arg, required_trimmed_string_arg, tool_error};
use super::super::dispatcher::AgentToolEffect;
use super::super::session::AgentToolSession;
use super::list::skill_is_visible;
@ -51,19 +50,10 @@ struct SkillSearchHitStructured<'a> {
pub(in crate::services::agent_tools) async fn search(
skill_service: &SkillService,
call: &ToolInvocation,
args: &Map<String, Value>,
session: &mut AgentToolSession,
profile: &ResolvedAgentProfile,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
let Some(name) = required_trimmed_string_arg(args, "name") else {
return Ok((
tool_error(call, "tool.invalid_arguments", "name is required"),

View file

@ -1,7 +1,8 @@
use serde::Serialize;
use serde_json::{Map, Value};
use super::args::{
classify_workspace_io_error, ensure_writable_workspace_path, object_args, optional_bool_arg,
classify_workspace_io_error, ensure_writable_workspace_path, optional_bool_arg,
parse_workspace_path, required_raw_string_arg, required_trimmed_string_arg, tool_error,
};
use super::policy::workspace_access_policy;
@ -31,19 +32,10 @@ pub(in crate::services::agent_tools) async fn apply_patch(
workspace_repository: &dyn WorkspaceRepository,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
session: &mut AgentToolSession,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let policy = workspace_access_policy(workspace_repository, run_id).await?;
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
let Some(path) = required_trimmed_string_arg(args, "path") else {
return Ok((
tool_error(call, "tool.invalid_arguments", "path is required"),

View file

@ -6,8 +6,8 @@ use tt_domain::models::agent::{AgentToolResult, WorkspacePath};
use tt_domain::models::tool::ToolInvocation;
pub(super) use crate::services::agent_tools::common::{
WORKSPACE_PATH_IS_DIRECTORY_CODE, ensure_only_args, object_args, optional_bool_arg,
optional_usize_arg, required_raw_string_arg, required_trimmed_string_arg, tool_error,
WORKSPACE_PATH_IS_DIRECTORY_CODE, ensure_only_args, optional_bool_arg, optional_usize_arg,
required_raw_string_arg, required_trimmed_string_arg, tool_error,
workspace_path_is_directory_message,
};

View file

@ -1,8 +1,8 @@
use serde::Serialize;
use serde_json::{Map, Value};
use super::args::{
ensure_visible_workspace_path, object_args, parse_workspace_path, required_trimmed_string_arg,
tool_error,
ensure_visible_workspace_path, parse_workspace_path, required_trimmed_string_arg, tool_error,
};
use super::policy::workspace_access_policy;
use crate::errors::ApplicationError;
@ -26,12 +26,11 @@ pub(in crate::services::agent_tools) async fn commit(
workspace_repository: &dyn WorkspaceRepository,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
profile: &ResolvedAgentProfile,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let policy = workspace_access_policy(workspace_repository, run_id).await?;
let args = object_args(call);
let path = args
.and_then(|args| required_trimmed_string_arg(args, "path"))
let path = required_trimmed_string_arg(args, "path")
.unwrap_or(profile.output.message_body_path.as_str());
let path = match parse_workspace_path(path) {
Ok(path) => path,
@ -41,7 +40,7 @@ pub(in crate::services::agent_tools) async fn commit(
return Ok((error.into_tool_result(call), AgentToolEffect::None));
}
let mode = match args.and_then(|args| required_trimmed_string_arg(args, "mode")) {
let mode = match required_trimmed_string_arg(args, "mode") {
Some("append") => AgentChatCommitMode::Append,
Some("replace") | None => AgentChatCommitMode::Replace,
Some(other) => {
@ -55,9 +54,7 @@ pub(in crate::services::agent_tools) async fn commit(
));
}
};
let reason = args
.and_then(|args| required_trimmed_string_arg(args, "reason"))
.map(str::to_string);
let reason = required_trimmed_string_arg(args, "reason").map(str::to_string);
Ok((
AgentToolResult {

View file

@ -1,4 +1,5 @@
use serde::Serialize;
use serde_json::{Map, Value};
use crate::errors::ApplicationError;
use tt_domain::models::agent::AgentToolResult;
@ -15,16 +16,14 @@ struct WorkspaceFinishStructured<'a> {
pub(in crate::services::agent_tools) fn finish(
call: &ToolInvocation,
args: &Map<String, Value>,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let args = call.arguments.as_object();
let result = AgentToolResult {
call_id: call.call_id.clone(),
tool_id: call.tool_id.clone(),
content: "Finished the Agent run.".to_string(),
structured: structured_value(WorkspaceFinishStructured {
reason: args
.and_then(|args| args.get("reason"))
.and_then(serde_json::Value::as_str),
reason: args.get("reason").and_then(Value::as_str),
}),
is_error: false,
error_code: None,

View file

@ -1,8 +1,8 @@
use serde::Serialize;
use serde_json::{Map, Value};
use super::args::{
ensure_visible_workspace_path, object_args, optional_list_path_arg, optional_usize_arg,
tool_error,
ensure_visible_workspace_path, optional_list_path_arg, optional_usize_arg, tool_error,
};
use super::policy::workspace_access_policy;
use super::render::{filter_visible_entries, render_file_list};
@ -34,18 +34,9 @@ pub(in crate::services::agent_tools) async fn list_files(
workspace_repository: &dyn WorkspaceRepository,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let policy = workspace_access_policy(workspace_repository, run_id).await?;
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
let path = match optional_list_path_arg(args, "path") {
Ok(path) => path,
Err(message) => {

View file

@ -1,7 +1,8 @@
use serde::Serialize;
use serde_json::{Map, Value};
use super::args::{
classify_workspace_io_error, ensure_only_args, ensure_visible_workspace_path, object_args,
classify_workspace_io_error, ensure_only_args, ensure_visible_workspace_path,
optional_usize_arg, parse_workspace_path, required_trimmed_string_arg, tool_error,
};
use super::policy::workspace_access_policy;
@ -31,19 +32,10 @@ pub(in crate::services::agent_tools) async fn read_file(
workspace_repository: &dyn WorkspaceRepository,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
session: &mut AgentToolSession,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let policy = workspace_access_policy(workspace_repository, run_id).await?;
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
if let Err(message) = ensure_only_args(args, &["path", "start_line", "line_count"]) {
return Ok((
tool_error(call, "tool.invalid_arguments", &message),

View file

@ -1,7 +1,8 @@
use serde::Serialize;
use serde_json::{Map, Value};
use super::args::{
ensure_visible_workspace_path, object_args, optional_list_path_arg, optional_usize_arg,
ensure_visible_workspace_path, optional_list_path_arg, optional_usize_arg,
required_trimmed_string_arg, tool_error,
};
use super::policy::{WorkspaceAccessPolicy, workspace_access_policy};
@ -64,18 +65,9 @@ pub(in crate::services::agent_tools) async fn search_files(
workspace_repository: &dyn WorkspaceRepository,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let policy = workspace_access_policy(workspace_repository, run_id).await?;
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
let Some(query) = required_trimmed_string_arg(args, "query") else {
return Ok((
tool_error(call, "tool.invalid_arguments", "query is required"),

View file

@ -3,7 +3,7 @@ use std::sync::Mutex;
use async_trait::async_trait;
use chrono::Utc;
use serde_json::json;
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use super::args::classify_workspace_io_error;
@ -19,7 +19,7 @@ use tt_domain::models::agent::{
WorkspaceInputManifest, WorkspaceManifest, WorkspacePath, WorkspacePersistentChangeSet,
WorkspaceRootCommit, WorkspaceRootLifecycle, WorkspaceRootMount, WorkspaceRootScope,
};
use tt_domain::models::tool::{ToolId, ToolInvocation};
use tt_domain::models::tool::{ToolArguments, ToolId, ToolInvocation};
use tt_ports::repositories::workspace_repository::{
WorkspaceAppendResult, WorkspaceEntry, WorkspaceFile, WorkspaceFileList, WorkspaceRepository,
WorkspaceWriteGuard,
@ -69,7 +69,7 @@ fn make_test_tool_call(name: &str) -> ToolInvocation {
ToolInvocation {
call_id: "call_test".to_string(),
tool_id: ToolId::builtin(name).unwrap(),
arguments: json!({}),
arguments: ToolArguments::empty(),
provider_metadata: json!({}),
}
}
@ -92,7 +92,7 @@ async fn workspace_read_invalid_path_returns_canonical_tool_error() {
let mut session = AgentToolSession::default();
let call = workspace_call("workspace.read_file", json!({ "path": "../secrets.json" }));
let (result, effect) = read_file(&repository, "run", &call, &mut session)
let (result, effect) = read_file(&repository, "run", &call, call_args(&call), &mut session)
.await
.expect("invalid model path must remain recoverable");
@ -124,7 +124,7 @@ async fn workspace_read_hidden_path_returns_recoverable_tool_error() {
json!({ "path": "input/prompt_snapshot.json" }),
);
let (result, effect) = read_file(&repository, "run", &call, &mut session)
let (result, effect) = read_file(&repository, "run", &call, call_args(&call), &mut session)
.await
.expect("hidden model path must remain recoverable");
@ -150,7 +150,7 @@ async fn workspace_read_defaults_to_a_preview_for_oversized_files() {
let mut session = AgentToolSession::default();
let call = workspace_call("workspace.read_file", json!({ "path": "output/large.md" }));
let (result, _) = read_file(&repository, "run", &call, &mut session)
let (result, _) = read_file(&repository, "run", &call, call_args(&call), &mut session)
.await
.expect("large read should return a preview");
@ -169,7 +169,7 @@ async fn workspace_read_keeps_a_large_single_line_out_of_the_next_model_request(
let mut session = AgentToolSession::default();
let call = workspace_call("workspace.read_file", json!({ "path": "output/large.txt" }));
let (result, _) = read_file(&repository, "run", &call, &mut session)
let (result, _) = read_file(&repository, "run", &call, call_args(&call), &mut session)
.await
.expect("large read should return a preview");
@ -191,7 +191,7 @@ async fn workspace_write_root_returns_recoverable_tool_error() {
}),
);
let (result, effect) = write_file(&repository, "run", &call, &mut session)
let (result, effect) = write_file(&repository, "run", &call, call_args(&call), &mut session)
.await
.expect("non-writable model path must remain recoverable");
@ -212,16 +212,18 @@ async fn workspace_write_existing_file_requires_prior_read() {
let repository = TestWorkspaceRepository::with_file("output/main.md", "old text");
let mut session = AgentToolSession::default();
let write_call = workspace_call(
"workspace.write_file",
json!({
"path": "output/main.md",
"content": "new text",
}),
);
let (result, _) = write_file(
&repository,
"run",
&workspace_call(
"workspace.write_file",
json!({
"path": "output/main.md",
"content": "new text",
}),
),
&write_call,
call_args(&write_call),
&mut session,
)
.await
@ -242,19 +244,20 @@ async fn workspace_write_existing_file_requires_prior_read() {
);
let read_call = workspace_call("workspace.read_file", json!({ "path": "output/main.md" }));
read_file(&repository, "run", &read_call, &mut session)
.await
.expect("read file");
read_file(
&repository,
"run",
&read_call,
call_args(&read_call),
&mut session,
)
.await
.expect("read file");
let (result, effect) = write_file(
&repository,
"run",
&workspace_call(
"workspace.write_file",
json!({
"path": "output/main.md",
"content": "new text",
}),
),
&write_call,
call_args(&write_call),
&mut session,
)
.await
@ -275,17 +278,37 @@ async fn workspace_patch_partial_failure_requires_full_read_before_retry() {
let repository = TestWorkspaceRepository::with_file("output/main.md", "alpha beta\ngamma");
let mut session = AgentToolSession::default();
let partial_read = workspace_call(
"workspace.read_file",
json!({
"path": "output/main.md",
"start_line": 1,
"line_count": 1
}),
);
let full_read = workspace_call("workspace.read_file", json!({ "path": "output/main.md" }));
let missing_patch = workspace_call(
"workspace.apply_patch",
json!({
"path": "output/main.md",
"old_string": "delta",
"new_string": "omega"
}),
);
let patch = workspace_call(
"workspace.apply_patch",
json!({
"path": "output/main.md",
"old_string": "alpha",
"new_string": "omega"
}),
);
read_file(
&repository,
"run",
&workspace_call(
"workspace.read_file",
json!({
"path": "output/main.md",
"start_line": 1,
"line_count": 1
}),
),
&partial_read,
call_args(&partial_read),
&mut session,
)
.await
@ -294,14 +317,8 @@ async fn workspace_patch_partial_failure_requires_full_read_before_retry() {
let (result, _) = apply_patch(
&repository,
"run",
&workspace_call(
"workspace.apply_patch",
json!({
"path": "output/main.md",
"old_string": "delta",
"new_string": "omega"
}),
),
&missing_patch,
call_args(&missing_patch),
&mut session,
)
.await
@ -311,21 +328,9 @@ async fn workspace_patch_partial_failure_requires_full_read_before_retry() {
Some("workspace.patch_requires_full_read")
);
let (result, _) = apply_patch(
&repository,
"run",
&workspace_call(
"workspace.apply_patch",
json!({
"path": "output/main.md",
"old_string": "alpha",
"new_string": "omega"
}),
),
&mut session,
)
.await
.expect("patch blocked after partial failure");
let (result, _) = apply_patch(&repository, "run", &patch, call_args(&patch), &mut session)
.await
.expect("patch blocked after partial failure");
assert_eq!(
result.error_code.as_deref(),
Some("workspace.patch_requires_full_read")
@ -334,26 +339,15 @@ async fn workspace_patch_partial_failure_requires_full_read_before_retry() {
read_file(
&repository,
"run",
&workspace_call("workspace.read_file", json!({ "path": "output/main.md" })),
&full_read,
call_args(&full_read),
&mut session,
)
.await
.expect("full read");
let (result, _) = apply_patch(
&repository,
"run",
&workspace_call(
"workspace.apply_patch",
json!({
"path": "output/main.md",
"old_string": "alpha",
"new_string": "omega"
}),
),
&mut session,
)
.await
.expect("patch after full read");
let (result, _) = apply_patch(&repository, "run", &patch, call_args(&patch), &mut session)
.await
.expect("patch after full read");
assert!(!result.is_error);
assert_eq!(
@ -370,11 +364,15 @@ fn workspace_call(name: &str, arguments: serde_json::Value) -> ToolInvocation {
ToolInvocation {
call_id: format!("call_{}", name.replace('.', "_")),
tool_id: ToolId::builtin(name).unwrap(),
arguments,
arguments: ToolArguments::decode(Some(&arguments)),
provider_metadata: serde_json::Value::Null,
}
}
fn call_args(call: &ToolInvocation) -> &Map<String, Value> {
call.arguments.as_map().expect("test arguments are objects")
}
struct TestWorkspaceRepository {
files: Mutex<HashMap<String, String>>,
}

View file

@ -2,7 +2,7 @@ use serde::Serialize;
use serde_json::{Map, Value};
use super::args::{
classify_workspace_io_error, ensure_writable_workspace_path, object_args, parse_workspace_path,
classify_workspace_io_error, ensure_writable_workspace_path, parse_workspace_path,
required_raw_string_arg, required_trimmed_string_arg, tool_error,
};
use super::policy::workspace_access_policy;
@ -34,19 +34,10 @@ pub(in crate::services::agent_tools) async fn write_file(
workspace_repository: &dyn WorkspaceRepository,
run_id: &str,
call: &ToolInvocation,
args: &Map<String, Value>,
session: &mut AgentToolSession,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let policy = workspace_access_policy(workspace_repository, run_id).await?;
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
let Some(path) = required_trimmed_string_arg(args, "path") else {
return Ok((
tool_error(call, "tool.invalid_arguments", "path is required"),

View file

@ -6,7 +6,7 @@ use super::{
MAX_WORLDINFO_TOTAL_READ_CHARS,
};
use crate::errors::ApplicationError;
use crate::services::agent_tools::common::{object_args, tool_error};
use crate::services::agent_tools::common::tool_error;
use crate::services::agent_tools::dispatcher::AgentToolEffect;
use tt_domain::models::agent::AgentToolResult;
use tt_domain::models::tool::ToolInvocation;
@ -104,17 +104,8 @@ struct RenderedEntry {
pub(in crate::services::agent_tools) fn read_activated(
prompt_snapshot: &Value,
call: &ToolInvocation,
args: &Map<String, Value>,
) -> Result<(AgentToolResult, AgentToolEffect), ApplicationError> {
let Some(args) = object_args(call) else {
return Ok((
tool_error(
call,
"tool.invalid_arguments",
"arguments must be an object",
),
AgentToolEffect::None,
));
};
let request = match parse_request(args) {
Ok(request) => request,
Err(message) => {

View file

@ -133,11 +133,9 @@ pub(super) fn convert_messages(
if use_tools {
blocks.extend(convert_openai_tool_calls_to_claude_blocks(&tool_calls));
} else {
blocks.extend(
tool_calls.iter().map(|call| {
normalize_claude_text_block(&call.arguments.to_string())
}),
);
blocks.extend(tool_calls.iter().map(|call| {
normalize_claude_text_block(&call.arguments.encode_for_replay())
}));
}
blocks
} else {

View file

@ -15,7 +15,7 @@ pub(super) fn convert_openai_tool_calls_to_claude_blocks(
"type": "tool_use",
"id": tool_call.id,
"name": tool_call.name,
"input": tool_call.arguments,
"input": tool_call.arguments.to_replay_object(),
})
})
.collect()

View file

@ -414,7 +414,7 @@ fn build_function_call_step(tool_call: &OpenAiToolCall) -> Value {
"type": "function_call",
"id": tool_call.id.clone(),
"name": tool_call.name.clone(),
"arguments": tool_call.arguments.clone(),
"arguments": tool_call.arguments.to_replay_object(),
});
if let Some(signature) = tool_call.signature.as_deref()

View file

@ -723,7 +723,7 @@ fn convert_openai_tool_calls_to_parts(
.map(|tool_call| {
let mut function_call = json!({
"name": tool_call.name,
"args": tool_call.arguments,
"args": tool_call.arguments.to_replay_object(),
});
if supports_function_call_ids {
function_call["id"] = Value::String(tool_call.id.clone());

View file

@ -107,6 +107,66 @@ mod tests {
use super::build_payload;
use tt_ports::repositories::chat_completion_repository::ChatCompletionSource;
#[test]
fn converted_tool_call_history_replays_unusable_arguments_as_an_empty_object() {
let error = "## Tool error\n\nRequest rejected";
for arguments in ["null", "[1,2]", r#"{"path":"#] {
for (format, arguments_path, error_path) in [
("openai_responses", "/input/1/arguments", "/input/2/output"),
(
"claude_messages",
"/messages/1/content/0/input",
"/messages/2/content/0/content",
),
(
"gemini_generate_content",
"/contents/1/parts/0/functionCall/args",
"/contents/2/parts/0/functionResponse/response/content",
),
(
"gemini_interactions",
"/input/1/arguments",
"/input/2/result/0/text",
),
] {
let payload = json!({
"chat_completion_source": "custom",
"custom_api_format": format,
"model": "test-model",
"tools": [{ "type": "function", "function": {
"name": "read_file", "parameters": { "type": "object" }
} }],
"messages": [
{ "role": "user", "content": "Read a file" },
{ "role": "assistant", "tool_calls": [{
"id": "call_1", "type": "function",
"function": { "name": "read_file", "arguments": arguments }
}] },
{ "role": "tool", "tool_call_id": "call_1", "content": error }
]
});
let (_, upstream) = build_payload(
ChatCompletionSource::Custom,
payload.as_object().unwrap().clone(),
)
.unwrap();
let replay = upstream
.pointer(arguments_path)
.unwrap_or_else(|| panic!("{format} missing {arguments_path}: {upstream}"));
let replay = match replay.as_str() {
Some(encoded) => serde_json::from_str::<Value>(encoded).unwrap(),
None => replay.clone(),
};
assert_eq!(replay, json!({}), "{format}: {arguments}");
assert_eq!(
upstream.pointer(error_path),
Some(&json!(error)),
"{format}"
);
}
}
}
#[test]
fn claude_leaves_additional_body_overrides_to_service_layer() {
let payload = json!({

View file

@ -3,6 +3,7 @@ use std::collections::HashSet;
use serde_json::{Map, Number, Value, json};
use crate::errors::ApplicationError;
use tt_domain::models::tool::ToolArguments;
use tt_ports::repositories::chat_completion_repository::CHAT_COMPLETION_PROVIDER_STATE_FIELD;
use super::content_parts::{InputPart, MediaPart, MediaSource, parse_openai_chat_content};
@ -483,7 +484,7 @@ fn assistant_function_calls(
"Assistant tool_call function is missing name".to_string(),
)
})?;
let arguments = function_call_arguments(function.get("arguments"))?;
let arguments = ToolArguments::decode(function.get("arguments")).encode_for_replay();
Ok(ResponsesFunctionCall {
call_id,
@ -494,18 +495,6 @@ fn assistant_function_calls(
.collect()
}
fn function_call_arguments(value: Option<&Value>) -> Result<String, ApplicationError> {
match value {
Some(Value::String(arguments)) => Ok(arguments.clone()),
Some(Value::Null) | None => Ok("{}".to_string()),
Some(value) => serde_json::to_string(value).map_err(|error| {
ApplicationError::ValidationError(format!(
"Assistant tool_call arguments are not serializable: {error}"
))
}),
}
}
fn message_native_openai_responses_output(
message: &Map<String, Value>,
) -> Result<Option<Vec<Value>>, ApplicationError> {

View file

@ -1,6 +1,7 @@
use std::collections::HashSet;
use serde_json::{Map, Value, json};
use tt_domain::models::tool::ToolArguments;
use crate::errors::ApplicationError;
@ -12,7 +13,7 @@ const DEFAULT_TOOL_NAME: &str = "tool";
pub(super) struct OpenAiToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
pub arguments: ToolArguments,
pub signature: Option<String>,
}
@ -37,9 +38,8 @@ pub(super) fn extract_openai_tool_calls(value: Option<&Value>) -> Vec<OpenAiTool
let id =
non_empty_string(object.get("id")).unwrap_or_else(|| format!("tool_call_{index}"));
let arguments = parse_tool_call_arguments(
function.get("arguments").or_else(|| function.get("args")),
);
let arguments =
ToolArguments::decode(function.get("arguments").or_else(|| function.get("args")));
let signature = non_empty_string(object.get("signature"));
Some(OpenAiToolCall {
@ -156,20 +156,6 @@ pub(super) fn validate_openai_chat_tool_transcript(
Ok(())
}
fn parse_tool_call_arguments(value: Option<&Value>) -> Value {
let Some(value) = value else {
return Value::Object(Map::new());
};
match value {
Value::String(raw) => {
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
}
Value::Null => Value::Object(Map::new()),
other => other.clone(),
}
}
fn strict_tool_call_ids(
value: Option<&Value>,
message_index: usize,
@ -249,7 +235,7 @@ mod tests {
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "weather");
assert_eq!(calls[0].signature.as_deref(), Some("sig_1"));
assert_eq!(calls[0].arguments["city"], "Paris");
assert_eq!(calls[0].arguments.as_map().unwrap()["city"], "Paris");
}
#[test]

View file

@ -118,8 +118,8 @@ pub(crate) enum ToolRequestGateError {
mod tests {
use serde_json::{Value, json};
use tt_domain::models::tool::{
InvocationToolSnapshot, ToolBinding, ToolChoice, ToolDescriptor, ToolId, ToolInvocation,
ToolProviderId, ToolSnapshotId, ToolTurnContract,
InvocationToolSnapshot, ToolArguments, ToolBinding, ToolChoice, ToolDescriptor, ToolId,
ToolInvocation, ToolProviderId, ToolSnapshotId, ToolTurnContract,
};
use super::{ToolRequestGate, ToolRequestGateError};
@ -145,7 +145,7 @@ mod tests {
ToolInvocation {
call_id: call_id.to_string(),
tool_id,
arguments: json!({}),
arguments: ToolArguments::empty(),
provider_metadata: Value::Null,
}
}

View file

@ -4,7 +4,7 @@ use std::{
};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use serde_json::Value;
use serde_json::{Map, Value};
use crate::errors::DomainError;
@ -485,12 +485,93 @@ pub enum ToolChoice {
Specific(ToolId),
}
const INVALID_ARGUMENTS_QUOTE_CHARS: usize = 200;
/// Invalid arguments retain their raw text in storage but replay as `{}`.
/// The paired tool error carries the rejection back to the model.
#[derive(Debug, Clone, PartialEq)]
pub enum ToolArguments {
Object(Map<String, Value>),
Invalid(String),
}
impl ToolArguments {
pub fn empty() -> Self {
Self::Object(Map::new())
}
pub fn decode(raw: Option<&Value>) -> Self {
match raw {
None | Some(Value::Null) => Self::empty(),
Some(Value::String(text)) if text.trim().is_empty() => Self::empty(),
Some(Value::String(text)) => match serde_json::from_str::<Value>(text) {
Ok(Value::Object(map)) => Self::Object(map),
Ok(Value::Null) => Self::empty(),
_ => Self::Invalid(text.clone()),
},
Some(Value::Object(map)) => Self::Object(map.clone()),
Some(value) => Self::Invalid(value.to_string()),
}
}
pub fn as_map(&self) -> Result<&Map<String, Value>, String> {
match self {
Self::Object(map) => Ok(map),
Self::Invalid(raw) => Err(format!(
"arguments must be a JSON object; send `{{}}` when the call takes no arguments. Received: `{}`",
quote_invalid_arguments(raw)
)),
}
}
pub fn encode_for_replay(&self) -> String {
match self {
Self::Object(map) => {
serde_json::to_string(map).expect("JSON argument objects are serializable")
}
Self::Invalid(_) => "{}".to_string(),
}
}
pub fn to_replay_object(&self) -> Value {
match self {
Self::Object(map) => Value::Object(map.clone()),
Self::Invalid(_) => Value::Object(Map::new()),
}
}
}
impl Serialize for ToolArguments {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Object(map) => map.serialize(serializer),
Self::Invalid(raw) => serializer.serialize_str(raw),
}
}
}
impl<'de> Deserialize<'de> for ToolArguments {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(match Value::deserialize(deserializer)? {
Value::Object(map) => Self::Object(map),
value => Self::decode(Some(&value)),
})
}
}
fn quote_invalid_arguments(raw: &str) -> String {
match raw.char_indices().nth(INVALID_ARGUMENTS_QUOTE_CHARS) {
Some((index, _)) => format!("{}…", &raw[..index]),
None => raw.to_string(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ToolInvocation {
pub call_id: String,
pub tool_id: ToolId,
pub arguments: Value,
pub arguments: ToolArguments,
#[serde(default)]
pub provider_metadata: Value,
}
@ -586,8 +667,9 @@ mod tests {
use serde_json::json;
use super::{
InvocationToolSnapshot, ToolBinding, ToolCatalog, ToolChoice, ToolDescriptionOverride,
ToolDescriptor, ToolId, ToolProviderId, ToolSnapshotId, ToolTurnContract,
InvocationToolSnapshot, ToolArguments, ToolBinding, ToolCatalog, ToolChoice,
ToolDescriptionOverride, ToolDescriptor, ToolId, ToolProviderId, ToolSnapshotId,
ToolTurnContract,
};
use crate::errors::DomainError;
@ -779,6 +861,40 @@ mod tests {
assert!(ToolTurnContract::all(&empty, ToolChoice::Required).is_err());
}
#[test]
fn both_wire_encodings_of_a_value_decode_to_the_same_arguments() {
let object = ToolArguments::decode(Some(&json!({ "depth": 2 })));
for (raw, expected) in [
(None, ToolArguments::empty()),
(Some(json!(null)), ToolArguments::empty()),
(Some(json!("null")), ToolArguments::empty()),
(Some(json!(" ")), ToolArguments::empty()),
(Some(json!("{}")), ToolArguments::empty()),
(Some(json!(r#"{"depth":2}"#)), object.clone()),
(Some(json!([1, 2])), ToolArguments::Invalid("[1,2]".into())),
(Some(json!("[1,2]")), ToolArguments::Invalid("[1,2]".into())),
(
Some(json!(r#"{"path":"#)),
ToolArguments::Invalid(r#"{"path":"#.into()),
),
] {
assert_eq!(ToolArguments::decode(raw.as_ref()), expected, "{raw:?}");
}
}
#[test]
fn invalid_arguments_are_stored_verbatim_but_replay_as_an_empty_object() {
let invalid = ToolArguments::decode(Some(&json!("x".repeat(500))));
assert_eq!(
serde_json::to_value(&invalid).unwrap(),
json!("x".repeat(500))
);
assert_eq!(invalid.encode_for_replay(), "{}");
let message = invalid.as_map().unwrap_err();
assert!(message.contains('…') && message.len() < 400, "{message}");
}
#[test]
fn tool_catalog_rejects_duplicate_ids() {
let id = ToolId::builtin("workspace.finish").unwrap();