diff --git a/Cargo.lock b/Cargo.lock index c3b98b238..0fdae6914 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,8 +117,8 @@ dependencies = [ [[package]] name = "aion-agent" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "aion-compact", "aion-config", @@ -147,8 +147,8 @@ dependencies = [ [[package]] name = "aion-compact" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "regex", "serde", @@ -158,8 +158,8 @@ dependencies = [ [[package]] name = "aion-config" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "aion-compact", "aion-types", @@ -182,8 +182,8 @@ dependencies = [ [[package]] name = "aion-mcp" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "aion-config", "aion-protocol", @@ -203,8 +203,8 @@ dependencies = [ [[package]] name = "aion-memory" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "aion-config", "chrono", @@ -217,8 +217,8 @@ dependencies = [ [[package]] name = "aion-protocol" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "aion-types", "serde", @@ -230,8 +230,8 @@ dependencies = [ [[package]] name = "aion-providers" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "aion-config", "aion-types", @@ -258,8 +258,8 @@ dependencies = [ [[package]] name = "aion-skills" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "aion-config", "aion-mcp", @@ -284,8 +284,8 @@ dependencies = [ [[package]] name = "aion-tools" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "aion-config", "aion-protocol", @@ -304,8 +304,8 @@ dependencies = [ [[package]] name = "aion-types" -version = "0.1.29" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +version = "0.1.30" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" dependencies = [ "async-trait", "chrono", @@ -6472,7 +6472,7 @@ dependencies = [ [[package]] name = "workspace-hack" version = "0.1.0" -source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.29#b2495c7076ae02dfdc7b487a9c85127bc7e9978d" +source = "git+https://github.com/iOfficeAI/aionrs.git?tag=v0.1.30#f048034140aa7974b82fd38e5e492d5851329c00" [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index 390bd97c9..4c9584d3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/aionui-ai-agent/src/capability/backend_output_sink.rs b/crates/aionui-ai-agent/src/capability/backend_output_sink.rs index 496940170..087295aff 100644 --- a/crates/aionui-ai-agent/src/capability/backend_output_sink.rs +++ b/crates/aionui-ai-agent/src/capability/backend_output_sink.rs @@ -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())); @@ -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 @@ -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(); @@ -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(); diff --git a/crates/aionui-ai-agent/src/factory/aionrs.rs b/crates/aionui-ai-agent/src/factory/aionrs.rs index b72f94184..4e632dd72 100644 --- a/crates/aionui-ai-agent/src/factory/aionrs.rs +++ b/crates/aionui-ai-agent/src/factory/aionrs.rs @@ -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, diff --git a/crates/aionui-ai-agent/src/manager/aionrs/agent.rs b/crates/aionui-ai-agent/src/manager/aionrs/agent.rs index 5227b4a8e..0419c3891 100644 --- a/crates/aionui-ai-agent/src/manager/aionrs/agent.rs +++ b/crates/aionui-ai-agent/src/manager/aionrs/agent.rs @@ -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"), @@ -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)) @@ -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, @@ -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)); + } } diff --git a/crates/aionui-ai-agent/src/manager/aionrs/history_sanitize.rs b/crates/aionui-ai-agent/src/manager/aionrs/history_sanitize.rs index e6ee9db2a..b4ab02ace 100644 --- a/crates/aionui-ai-agent/src/manager/aionrs/history_sanitize.rs +++ b/crates/aionui-ai-agent/src/manager/aionrs/history_sanitize.rs @@ -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. //! @@ -39,6 +43,8 @@ pub fn sanitize_session_messages(messages: &mut Vec) -> 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). @@ -53,6 +59,38 @@ pub fn sanitize_session_messages(messages: &mut Vec) -> 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) -> usize { + let malformed_tool_use_ids: HashSet = 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() } @@ -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, @@ -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.") + ); + } } diff --git a/crates/aionui-ai-agent/src/protocol/send_error.rs b/crates/aionui-ai-agent/src/protocol/send_error.rs index 5c4a69a98..28693be4d 100644 --- a/crates/aionui-ai-agent/src/protocol/send_error.rs +++ b/crates/aionui-ai-agent/src/protocol/send_error.rs @@ -650,6 +650,22 @@ fn classify_provider_text(lower: &str) -> Option { None, )); } + if contains_any( + lower, + &[ + "repeatedly returned malformed tool calls", + "malformed tool call loop", + "malformed tool calls", + ], + ) { + return Some(provider_error( + "The model provider repeatedly returned malformed tool calls", + AgentErrorCode::UserLlmProviderInvalidRequest, + false, + AgentErrorResolutionKind::ChangeModel, + Some(AgentErrorResolutionTarget::ProviderSettings), + )); + } if contains_any( lower, &[ @@ -1429,6 +1445,16 @@ mod tests { AgentErrorOwnership::UserLlmProvider, AgentErrorResolutionKind::ChangeModel, ); + assert_classification( + "Aionrs agent error: provider repeatedly returned malformed tool calls (3/3); stopped to avoid wasting tokens", + AgentErrorCode::UserLlmProviderInvalidRequest, + AgentErrorOwnership::UserLlmProvider, + AgentErrorResolutionKind::ChangeModel, + ); + assert_resolution_target( + "Aionrs agent error: provider repeatedly returned malformed tool calls (3/3); stopped to avoid wasting tokens", + AgentErrorResolutionTarget::ProviderSettings, + ); assert_classification( "API error 400: invalid params, context window exceeds limit", AgentErrorCode::UserLlmProviderContextTooLarge, diff --git a/crates/aionui-ai-agent/src/services/provider_health.rs b/crates/aionui-ai-agent/src/services/provider_health.rs index 8bd2dc936..585591a2f 100644 --- a/crates/aionui-ai-agent/src/services/provider_health.rs +++ b/crates/aionui-ai-agent/src/services/provider_health.rs @@ -82,6 +82,7 @@ impl ProviderHealthCheckService { system_prompt: Some("You are a provider health probe. Reply with exactly OK and do not use tools.".into()), max_tokens: 16, max_turns: Some(1), + max_malformed_tool_call_turns: Some(1), compat_overrides, session_directory: self.data_dir.join("aionrs-health-check-sessions"), session_mode: None, @@ -194,6 +195,7 @@ async fn build_probe_engine(config_extra: AionrsResolvedConfig) -> Result, + /// Max repeated malformed tool-call turns before stopping. + pub max_malformed_tool_call_turns: Option, /// Provider-specific compat overrides. pub compat_overrides: AionrsCompatOverrides, /// Directory for aionrs session persistence files. @@ -193,6 +195,7 @@ mod tests { assert!(extra.preset_rules.is_none()); assert_eq!(extra.max_tokens, 8192); assert!(extra.max_turns.is_none()); + assert!(extra.max_malformed_tool_call_turns.is_none()); } #[test] @@ -200,12 +203,14 @@ mod tests { let json = json!({ "system_prompt": "You are a helpful assistant.", "max_tokens": 4096, - "max_turns": 10 + "max_turns": 10, + "max_malformed_tool_call_turns": 2 }); let extra: AionrsBuildExtra = serde_json::from_value(json).unwrap(); assert_eq!(extra.system_prompt.unwrap(), "You are a helpful assistant."); assert_eq!(extra.max_tokens, 4096); assert_eq!(extra.max_turns.unwrap(), 10); + assert_eq!(extra.max_malformed_tool_call_turns.unwrap(), 2); } #[test] diff --git a/crates/aionui-ai-agent/tests/agent_types_integration.rs b/crates/aionui-ai-agent/tests/agent_types_integration.rs index 034803c77..976351c06 100644 --- a/crates/aionui-ai-agent/tests/agent_types_integration.rs +++ b/crates/aionui-ai-agent/tests/agent_types_integration.rs @@ -97,6 +97,7 @@ fn make_aionrs_config() -> AionrsResolvedConfig { 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, diff --git a/crates/aionui-api-types/src/agent_build_extra.rs b/crates/aionui-api-types/src/agent_build_extra.rs index 9025f6c10..24af08e21 100644 --- a/crates/aionui-api-types/src/agent_build_extra.rs +++ b/crates/aionui-api-types/src/agent_build_extra.rs @@ -89,6 +89,8 @@ pub struct AionrsBuildExtra { #[serde(default)] pub max_turns: Option, #[serde(default)] + pub max_malformed_tool_call_turns: Option, + #[serde(default)] pub session_mode: Option, #[serde(default)] pub team_mcp_stdio_config: Option,