Skip to content

Commit 67ffbd0

Browse files
authored
fix(aionrs): drop malformed tool-call events (#486)
## Summary - Ignore aionrs tool-call and tool-result stream events when the tool name is empty, so malformed calls do not reach the UI or persistence path. - Strip resumed aionrs session history of empty-name tool uses and their paired tool results before replaying provider history. - Add regression coverage for empty-name event filtering and session-history sanitization. ## Tests - cargo test -p aionui-ai-agent empty_name - cargo test -p aionui-ai-agent history_sanitize - cargo fmt --all --check - just push -u origin boii/fix/malformed-tool-call-sanitize - migration immutability check passed - cargo fix / clippy fix completed - cargo nextest run --workspace: 6315 passed, 18 skipped
1 parent 1a9297e commit 67ffbd0

11 files changed

Lines changed: 212 additions & 28 deletions

File tree

Cargo.lock

Lines changed: 21 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ aionui-cron = { path = "crates/aionui-cron" }
5353
aionui-assistant = { path = "crates/aionui-assistant" }
5454
aionui-app = { path = "crates/aionui-app" }
5555

56-
aion-agent = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
57-
aion-types = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
58-
aion-protocol = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
59-
aion-config = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
60-
aion-mcp = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
56+
aion-agent = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
57+
aion-types = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
58+
aion-protocol = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
59+
aion-config = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
60+
aion-mcp = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
6161

6262
# Core framework
6363
tokio = { version = "1", features = ["full"] }

crates/aionui-ai-agent/src/capability/backend_output_sink.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ impl OutputSink for BackendOutputSink {
4646
tracing::error!(tool = name, "Cannot emit tool_call with empty tool_use_id");
4747
return;
4848
};
49+
if name.trim().is_empty() {
50+
tracing::error!(tool_use_id = %tool_use_id, "Cannot emit tool_call with empty tool name");
51+
return;
52+
}
4953

5054
let parsed_input = serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_owned()));
5155

@@ -80,6 +84,10 @@ impl OutputSink for BackendOutputSink {
8084
tracing::error!(tool = name, "Cannot emit tool_result with empty tool_use_id");
8185
return;
8286
};
87+
if name.trim().is_empty() {
88+
tracing::error!(tool_use_id = %tool_use_id, "Cannot emit tool_result with empty tool name");
89+
return;
90+
}
8391

8492
let status = if is_error {
8593
ToolCallStatus::Error
@@ -191,6 +199,13 @@ mod tests {
191199
}
192200
}
193201

202+
#[test]
203+
fn emit_tool_call_with_empty_name_is_ignored() {
204+
let (sink, mut rx) = make_sink();
205+
sink.emit_tool_call("call_bad", " ", r#"{"path":"/tmp/a.txt"}"#);
206+
assert!(rx.try_recv().is_err());
207+
}
208+
194209
#[test]
195210
fn emit_tool_result_success_sends_completed() {
196211
let (sink, mut rx) = make_sink();
@@ -205,6 +220,13 @@ mod tests {
205220
}
206221
}
207222

223+
#[test]
224+
fn emit_tool_result_with_empty_name_is_ignored() {
225+
let (sink, mut rx) = make_sink();
226+
sink.emit_tool_result("call_bad", " ", false, "content");
227+
assert!(rx.try_recv().is_err());
228+
}
229+
208230
#[test]
209231
fn emit_tool_result_error_sends_error_status() {
210232
let (sink, mut rx) = make_sink();

crates/aionui-ai-agent/src/factory/aionrs.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ pub(super) async fn build(
189189
system_prompt: overrides.system_prompt,
190190
max_tokens: overrides.max_tokens,
191191
max_turns: overrides.max_turns,
192+
max_malformed_tool_call_turns: overrides.max_malformed_tool_call_turns,
192193
compat_overrides,
193194
session_directory,
194195
session_mode: overrides.session_mode,

crates/aionui-ai-agent/src/manager/aionrs/agent.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ impl AionrsAgentManager {
7373
model: Some(config_extra.model.clone()),
7474
max_tokens: Some(config_extra.max_tokens),
7575
max_turns: config_extra.max_turns,
76+
max_malformed_tool_call_turns: config_extra.max_malformed_tool_call_turns,
7677
system_prompt: config_extra.system_prompt.clone(),
7778
profile: None,
7879
auto_approve: config_extra.session_mode.as_deref() == Some("yolo"),
@@ -396,7 +397,11 @@ fn parse_session_mode(s: &str) -> SessionMode {
396397

397398
fn aionrs_engine_error_to_send_error(error_msg: String) -> AgentSendError {
398399
let lower = error_msg.to_ascii_lowercase();
399-
if lower.contains("provider error") || lower.contains("provider:") || lower.contains("api error:") {
400+
if lower.contains("provider error")
401+
|| lower.contains("provider:")
402+
|| lower.contains("api error:")
403+
|| lower.contains("repeatedly returned malformed tool calls")
404+
{
400405
return AgentSendError::from_agent_error(AgentError::bad_gateway(error_msg));
401406
}
402407
AgentSendError::from_agent_error(AgentError::internal(error_msg))
@@ -428,6 +433,7 @@ mod tests {
428433
system_prompt: None,
429434
max_tokens: 4096,
430435
max_turns: None,
436+
max_malformed_tool_call_turns: None,
431437
compat_overrides: Default::default(),
432438
session_directory: std::env::temp_dir().join("aionrs-test-sessions"),
433439
session_mode: None,
@@ -646,4 +652,22 @@ mod tests {
646652
);
647653
assert_eq!(send_error.stream_error().retryable, Some(true));
648654
}
655+
656+
#[test]
657+
fn aionrs_repeated_malformed_tool_call_is_user_llm_provider_error() {
658+
let send_error = aionrs_engine_error_to_send_error(
659+
"Aionrs agent error: provider repeatedly returned malformed tool calls (3/3); stopped to avoid wasting tokens"
660+
.to_owned(),
661+
);
662+
663+
assert_eq!(
664+
send_error.code(),
665+
Some(aionui_api_types::AgentErrorCode::UserLlmProviderInvalidRequest)
666+
);
667+
assert_eq!(
668+
send_error.ownership(),
669+
Some(aionui_api_types::AgentErrorOwnership::UserLlmProvider)
670+
);
671+
assert_eq!(send_error.stream_error().retryable, Some(false));
672+
}
649673
}

crates/aionui-ai-agent/src/manager/aionrs/history_sanitize.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
//! 3. have NO subsequent `ToolResult` block (in any later message) that
1919
//! references one of those tool-use ids.
2020
//!
21+
//! Also strip malformed tool calls whose `name` is empty, plus their matching
22+
//! results. Those are not valid protocol tool calls and strict providers reject
23+
//! them even when a matching result is present.
24+
//!
2125
//! A complete `assistant(tool_use) → user(tool_result)` pair is left intact —
2226
//! that shape is valid and required by every provider.
2327
//!
@@ -39,6 +43,8 @@ pub fn sanitize_session_messages(messages: &mut Vec<Message>) -> usize {
3943
return 0;
4044
}
4145

46+
let mut removed = strip_malformed_tool_calls(messages);
47+
4248
// Collect every tool_use_id that has a matching tool_result anywhere
4349
// in the entire history. We do this in one pass so that the lookup
4450
// for each candidate assistant message is O(1).
@@ -53,6 +59,38 @@ pub fn sanitize_session_messages(messages: &mut Vec<Message>) -> usize {
5359

5460
let original_len = messages.len();
5561
messages.retain(|msg| !is_orphaned_assistant_tool_call(msg, &answered_tool_use_ids));
62+
removed += original_len - messages.len();
63+
removed
64+
}
65+
66+
fn strip_malformed_tool_calls(messages: &mut Vec<Message>) -> usize {
67+
let malformed_tool_use_ids: HashSet<String> = messages
68+
.iter()
69+
.flat_map(|msg| msg.content.iter())
70+
.filter_map(|block| {
71+
if let ContentBlock::ToolUse { id, name, .. } = block
72+
&& name.trim().is_empty()
73+
{
74+
return Some(id.clone());
75+
}
76+
None
77+
})
78+
.collect();
79+
80+
if malformed_tool_use_ids.is_empty() {
81+
return 0;
82+
}
83+
84+
for msg in messages.iter_mut() {
85+
msg.content.retain(|block| match block {
86+
ContentBlock::ToolUse { name, .. } => !name.trim().is_empty(),
87+
ContentBlock::ToolResult { tool_use_id, .. } => !malformed_tool_use_ids.contains(tool_use_id),
88+
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => true,
89+
});
90+
}
91+
92+
let original_len = messages.len();
93+
messages.retain(|msg| !msg.content.is_empty());
5694
original_len - messages.len()
5795
}
5896

@@ -110,6 +148,18 @@ mod tests {
110148
Message::new(Role::Assistant, blocks)
111149
}
112150

151+
fn assistant_tool_call_with_name(id: &str, name: &str) -> Message {
152+
Message::new(
153+
Role::Assistant,
154+
vec![ContentBlock::ToolUse {
155+
id: id.to_owned(),
156+
name: name.to_owned(),
157+
input: json!({"path": "src/main.rs"}),
158+
extra: None,
159+
}],
160+
)
161+
}
162+
113163
fn assistant_text_plus_tool_call(text: &str, id: &str) -> Message {
114164
Message::new(
115165
Role::Assistant,
@@ -260,4 +310,55 @@ mod tests {
260310
assert_eq!(removed, 1);
261311
assert_eq!(messages.len(), 1);
262312
}
313+
314+
#[test]
315+
fn drops_empty_name_tool_call_even_when_it_has_a_matching_result() {
316+
let mut messages = vec![
317+
user_text("read it"),
318+
assistant_tool_call_with_name("call_bad", ""),
319+
user_tool_result("call_bad"),
320+
assistant_text("done"),
321+
];
322+
323+
let removed = sanitize_session_messages(&mut messages);
324+
325+
assert_eq!(removed, 2);
326+
assert_eq!(messages.len(), 2);
327+
assert!(messages.iter().all(|message| {
328+
message.content.iter().all(|block| {
329+
!matches!(block, ContentBlock::ToolUse { name, .. } if name.trim().is_empty())
330+
&& !matches!(block, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call_bad")
331+
})
332+
}));
333+
}
334+
335+
#[test]
336+
fn strips_empty_name_tool_call_from_assistant_text_message() {
337+
let mut messages = vec![
338+
user_text("read it"),
339+
Message::new(
340+
Role::Assistant,
341+
vec![
342+
ContentBlock::Text {
343+
text: "I will inspect it.".to_owned(),
344+
},
345+
ContentBlock::ToolUse {
346+
id: "call_bad".to_owned(),
347+
name: " ".to_owned(),
348+
input: json!({"path": "src/main.rs"}),
349+
extra: None,
350+
},
351+
],
352+
),
353+
user_tool_result("call_bad"),
354+
];
355+
356+
let removed = sanitize_session_messages(&mut messages);
357+
358+
assert_eq!(removed, 1);
359+
assert_eq!(messages.len(), 2);
360+
assert!(
361+
matches!(messages[1].content.as_slice(), [ContentBlock::Text { text }] if text == "I will inspect it.")
362+
);
363+
}
263364
}

0 commit comments

Comments
 (0)