Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ aionui-cron = { path = "crates/aionui-cron" }
aionui-assistant = { path = "crates/aionui-assistant" }
aionui-app = { path = "crates/aionui-app" }

aion-agent = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
aion-types = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
aion-protocol = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
aion-config = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
aion-mcp = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.29" }
aion-agent = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-types = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-protocol = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-config = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }
aion-mcp = { git = "https://github.com/iOfficeAI/aionrs.git", tag = "v0.1.30" }

# Core framework
tokio = { version = "1", features = ["full"] }
Expand Down
22 changes: 22 additions & 0 deletions crates/aionui-ai-agent/src/capability/backend_output_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ impl OutputSink for BackendOutputSink {
tracing::error!(tool = name, "Cannot emit tool_call with empty tool_use_id");
return;
};
if name.trim().is_empty() {
tracing::error!(tool_use_id = %tool_use_id, "Cannot emit tool_call with empty tool name");
return;
}

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

Expand Down Expand Up @@ -80,6 +84,10 @@ impl OutputSink for BackendOutputSink {
tracing::error!(tool = name, "Cannot emit tool_result with empty tool_use_id");
return;
};
if name.trim().is_empty() {
tracing::error!(tool_use_id = %tool_use_id, "Cannot emit tool_result with empty tool name");
return;
}

let status = if is_error {
ToolCallStatus::Error
Expand Down Expand Up @@ -191,6 +199,13 @@ mod tests {
}
}

#[test]
fn emit_tool_call_with_empty_name_is_ignored() {
let (sink, mut rx) = make_sink();
sink.emit_tool_call("call_bad", " ", r#"{"path":"/tmp/a.txt"}"#);
assert!(rx.try_recv().is_err());
}

#[test]
fn emit_tool_result_success_sends_completed() {
let (sink, mut rx) = make_sink();
Expand All @@ -205,6 +220,13 @@ mod tests {
}
}

#[test]
fn emit_tool_result_with_empty_name_is_ignored() {
let (sink, mut rx) = make_sink();
sink.emit_tool_result("call_bad", " ", false, "content");
assert!(rx.try_recv().is_err());
}

#[test]
fn emit_tool_result_error_sends_error_status() {
let (sink, mut rx) = make_sink();
Expand Down
1 change: 1 addition & 0 deletions crates/aionui-ai-agent/src/factory/aionrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ pub(super) async fn build(
system_prompt: overrides.system_prompt,
max_tokens: overrides.max_tokens,
max_turns: overrides.max_turns,
max_malformed_tool_call_turns: overrides.max_malformed_tool_call_turns,
compat_overrides,
session_directory,
session_mode: overrides.session_mode,
Expand Down
26 changes: 25 additions & 1 deletion crates/aionui-ai-agent/src/manager/aionrs/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ impl AionrsAgentManager {
model: Some(config_extra.model.clone()),
max_tokens: Some(config_extra.max_tokens),
max_turns: config_extra.max_turns,
max_malformed_tool_call_turns: config_extra.max_malformed_tool_call_turns,
system_prompt: config_extra.system_prompt.clone(),
profile: None,
auto_approve: config_extra.session_mode.as_deref() == Some("yolo"),
Expand Down Expand Up @@ -396,7 +397,11 @@ fn parse_session_mode(s: &str) -> SessionMode {

fn aionrs_engine_error_to_send_error(error_msg: String) -> AgentSendError {
let lower = error_msg.to_ascii_lowercase();
if lower.contains("provider error") || lower.contains("provider:") || lower.contains("api error:") {
if lower.contains("provider error")
|| lower.contains("provider:")
|| lower.contains("api error:")
|| lower.contains("repeatedly returned malformed tool calls")
{
return AgentSendError::from_agent_error(AgentError::bad_gateway(error_msg));
}
AgentSendError::from_agent_error(AgentError::internal(error_msg))
Expand Down Expand Up @@ -428,6 +433,7 @@ mod tests {
system_prompt: None,
max_tokens: 4096,
max_turns: None,
max_malformed_tool_call_turns: None,
compat_overrides: Default::default(),
session_directory: std::env::temp_dir().join("aionrs-test-sessions"),
session_mode: None,
Expand Down Expand Up @@ -646,4 +652,22 @@ mod tests {
);
assert_eq!(send_error.stream_error().retryable, Some(true));
}

#[test]
fn aionrs_repeated_malformed_tool_call_is_user_llm_provider_error() {
let send_error = aionrs_engine_error_to_send_error(
"Aionrs agent error: provider repeatedly returned malformed tool calls (3/3); stopped to avoid wasting tokens"
.to_owned(),
);

assert_eq!(
send_error.code(),
Some(aionui_api_types::AgentErrorCode::UserLlmProviderInvalidRequest)
);
assert_eq!(
send_error.ownership(),
Some(aionui_api_types::AgentErrorOwnership::UserLlmProvider)
);
assert_eq!(send_error.stream_error().retryable, Some(false));
}
}
101 changes: 101 additions & 0 deletions crates/aionui-ai-agent/src/manager/aionrs/history_sanitize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
//! 3. have NO subsequent `ToolResult` block (in any later message) that
//! references one of those tool-use ids.
//!
//! Also strip malformed tool calls whose `name` is empty, plus their matching
//! results. Those are not valid protocol tool calls and strict providers reject
//! them even when a matching result is present.
//!
//! A complete `assistant(tool_use) → user(tool_result)` pair is left intact —
//! that shape is valid and required by every provider.
//!
Expand All @@ -39,6 +43,8 @@ pub fn sanitize_session_messages(messages: &mut Vec<Message>) -> usize {
return 0;
}

let mut removed = strip_malformed_tool_calls(messages);

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

let original_len = messages.len();
messages.retain(|msg| !is_orphaned_assistant_tool_call(msg, &answered_tool_use_ids));
removed += original_len - messages.len();
removed
}

fn strip_malformed_tool_calls(messages: &mut Vec<Message>) -> usize {
let malformed_tool_use_ids: HashSet<String> = messages
.iter()
.flat_map(|msg| msg.content.iter())
.filter_map(|block| {
if let ContentBlock::ToolUse { id, name, .. } = block
&& name.trim().is_empty()
{
return Some(id.clone());
}
None
})
.collect();

if malformed_tool_use_ids.is_empty() {
return 0;
}

for msg in messages.iter_mut() {
msg.content.retain(|block| match block {
ContentBlock::ToolUse { name, .. } => !name.trim().is_empty(),
ContentBlock::ToolResult { tool_use_id, .. } => !malformed_tool_use_ids.contains(tool_use_id),
ContentBlock::Text { .. } | ContentBlock::Thinking { .. } => true,
});
}

let original_len = messages.len();
messages.retain(|msg| !msg.content.is_empty());
original_len - messages.len()
}

Expand Down Expand Up @@ -110,6 +148,18 @@ mod tests {
Message::new(Role::Assistant, blocks)
}

fn assistant_tool_call_with_name(id: &str, name: &str) -> Message {
Message::new(
Role::Assistant,
vec![ContentBlock::ToolUse {
id: id.to_owned(),
name: name.to_owned(),
input: json!({"path": "src/main.rs"}),
extra: None,
}],
)
}

fn assistant_text_plus_tool_call(text: &str, id: &str) -> Message {
Message::new(
Role::Assistant,
Expand Down Expand Up @@ -260,4 +310,55 @@ mod tests {
assert_eq!(removed, 1);
assert_eq!(messages.len(), 1);
}

#[test]
fn drops_empty_name_tool_call_even_when_it_has_a_matching_result() {
let mut messages = vec![
user_text("read it"),
assistant_tool_call_with_name("call_bad", ""),
user_tool_result("call_bad"),
assistant_text("done"),
];

let removed = sanitize_session_messages(&mut messages);

assert_eq!(removed, 2);
assert_eq!(messages.len(), 2);
assert!(messages.iter().all(|message| {
message.content.iter().all(|block| {
!matches!(block, ContentBlock::ToolUse { name, .. } if name.trim().is_empty())
&& !matches!(block, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call_bad")
})
}));
}

#[test]
fn strips_empty_name_tool_call_from_assistant_text_message() {
let mut messages = vec![
user_text("read it"),
Message::new(
Role::Assistant,
vec![
ContentBlock::Text {
text: "I will inspect it.".to_owned(),
},
ContentBlock::ToolUse {
id: "call_bad".to_owned(),
name: " ".to_owned(),
input: json!({"path": "src/main.rs"}),
extra: None,
},
],
),
user_tool_result("call_bad"),
];

let removed = sanitize_session_messages(&mut messages);

assert_eq!(removed, 1);
assert_eq!(messages.len(), 2);
assert!(
matches!(messages[1].content.as_slice(), [ContentBlock::Text { text }] if text == "I will inspect it.")
);
}
}
Loading
Loading