diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/core/src/agentic/execution/stream_processor.rs index b8bb9498..782f40ac 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/core/src/agentic/execution/stream_processor.rs @@ -8,13 +8,15 @@ use crate::agentic::events::{ ToolEventData, }; use crate::agentic::tools::SubagentParentInfo; +use crate::infrastructure::ai::tool_call_accumulator::{ + FinalizedToolCall, PendingToolCall, ToolCallBoundary, +}; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; -use crate::util::JsonChecker; use ai_stream_handlers::UnifiedResponse; use futures::StreamExt; use log::{debug, error, trace}; -use serde_json::{json, Value}; +use serde_json::Value; use std::sync::Arc; use tokio::sync::mpsc; @@ -107,48 +109,6 @@ impl SseLogCollector { } } -#[derive(Debug)] -struct ToolCallBuffer { - tool_id: String, - tool_name: String, - json_checker: JsonChecker, -} - -impl ToolCallBuffer { - fn new() -> Self { - Self { - tool_id: String::new(), - tool_name: String::new(), - json_checker: JsonChecker::new(), - } - } - - fn reset(&mut self) { - self.tool_id.clear(); - self.tool_name.clear(); - self.json_checker.reset(); - } - - fn append(&mut self, s: &str) { - self.json_checker.append(s); - } - - fn is_valid(&self) -> bool { - self.json_checker.is_valid() - } - - fn to_tool_call(&self) -> ToolCall { - let arguments = serde_json::from_str(&self.json_checker.get_buffer()); - let is_error = arguments.is_err(); - ToolCall { - tool_id: self.tool_id.clone(), - tool_name: self.tool_name.clone(), - arguments: arguments.unwrap_or(json!({})), - is_error, - } - } -} - /// Stream processing result #[derive(Debug, Clone)] pub struct StreamResult { @@ -199,7 +159,7 @@ struct StreamContext { provider_metadata: Option, // Current tool call state - tool_call_buffer: ToolCallBuffer, + pending_tool_call: PendingToolCall, // Counters and flags text_chunks_count: usize, @@ -228,7 +188,7 @@ impl StreamContext { tool_calls: Vec::new(), usage: None, provider_metadata: None, - tool_call_buffer: ToolCallBuffer::new(), + pending_tool_call: PendingToolCall::default(), text_chunks_count: 0, thinking_chunks_count: 0, thinking_completed_sent: false, @@ -252,18 +212,34 @@ impl StreamContext { self.has_effective_output && !self.full_text.is_empty() && self.tool_calls.is_empty() - && self.tool_call_buffer.tool_id.is_empty() + && !self.pending_tool_call.has_pending() } - /// Force finish tool_call_buffer, used to handle cases where toolcall parameters are not fully closed - /// E.g., when new toolcall arrives and before returning results - fn force_finish_tool_call_buffer(&mut self) { - if !self.tool_call_buffer.tool_id.is_empty() { - error!("force finish tool_call_buffer: {:?}", self.tool_call_buffer); - // Add to results even if parameters are incomplete, to avoid dialog turn interruption due to no tool calls - // Caller can detect is_error=true to mark tool execution error - self.tool_calls.push(self.tool_call_buffer.to_tool_call()); - self.tool_call_buffer.reset(); + fn finalize_pending_tool_call( + &mut self, + boundary: ToolCallBoundary, + ) -> Option { + let finalized = self.pending_tool_call.finalize(boundary)?; + self.tool_calls.push(ToolCall { + tool_id: finalized.tool_id.clone(), + tool_name: finalized.tool_name.clone(), + arguments: finalized.arguments.clone(), + is_error: finalized.is_error, + }); + Some(finalized) + } + + /// Force finish pending_tool_call, used when the stream is shutting down before a natural tool boundary. + fn force_finish_pending_tool_call(&mut self) { + if let Some(finalized) = self.finalize_pending_tool_call(ToolCallBoundary::GracefulShutdown) + { + error!( + "force finish pending tool call: tool_id={}, tool_name={}, raw_len={}, is_error={}", + finalized.tool_id, + finalized.tool_name, + finalized.raw_arguments.len(), + finalized.is_error + ); } } } @@ -341,7 +317,7 @@ impl StreamProcessor { /// Execute graceful shutdown from context async fn graceful_shutdown_from_ctx(&self, ctx: &mut StreamContext, reason: String) { - ctx.force_finish_tool_call_buffer(); + ctx.force_finish_pending_tool_call(); self.graceful_shutdown( ctx.session_id.clone(), ctx.dialog_turn_id.clone(), @@ -461,22 +437,26 @@ impl StreamProcessor { ctx: &mut StreamContext, tool_call: ai_stream_handlers::UnifiedToolCall, ) { + let ai_stream_handlers::UnifiedToolCall { + id, + name, + arguments, + } = tool_call; + // Handle tool ID and name - if let Some(tool_id) = tool_call.id { + if let Some(tool_id) = id { if !tool_id.is_empty() { ctx.has_effective_output = true; // Some providers repeat the tool id on every delta; only treat a new id as a new tool call. - let is_new_tool = ctx.tool_call_buffer.tool_id != tool_id; + let is_new_tool = ctx.pending_tool_call.tool_id() != tool_id; if is_new_tool { - // Clear previous tool_call state - ctx.force_finish_tool_call_buffer(); + let _ = ctx.finalize_pending_tool_call(ToolCallBoundary::NewTool); // Normally tool_name should not be empty - let tool_name = tool_call.name.unwrap_or_default(); + let tool_name = name.clone().unwrap_or_default(); debug!("Tool detected: {}", tool_name); - ctx.tool_call_buffer.tool_id = tool_id.clone(); - ctx.tool_call_buffer.tool_name = tool_name.clone(); - ctx.tool_call_buffer.json_checker.reset(); + ctx.pending_tool_call + .start_new(tool_id.clone(), name.clone()); // Send early detection event let _ = self @@ -494,19 +474,21 @@ impl StreamProcessor { None, ) .await; - } else if ctx.tool_call_buffer.tool_name.is_empty() { + } else if ctx.pending_tool_call.tool_name().is_empty() { // Best-effort: keep name if provider repeats it. - ctx.tool_call_buffer.tool_name = tool_call.name.unwrap_or_default(); + ctx.pending_tool_call + .update_tool_name_if_missing(name.clone()); } } } // Handle tool parameters - if let Some(tool_call_arguments) = tool_call.arguments { - // Empty tool_id indicates abnormal premature closure, stop processing subsequent data for this tool_call - if !ctx.tool_call_buffer.tool_id.is_empty() { + if let Some(tool_call_arguments) = arguments { + // Providers often omit tool_id on follow-up argument deltas. Append as long as we already + // have a pending tool call; otherwise treat this as an orphaned delta and ignore it. + if ctx.pending_tool_call.has_pending() { ctx.has_effective_output = true; - ctx.tool_call_buffer.append(&tool_call_arguments); + ctx.pending_tool_call.append_arguments(&tool_call_arguments); // Send partial parameters event let _ = self @@ -516,8 +498,8 @@ impl StreamProcessor { session_id: ctx.session_id.clone(), turn_id: ctx.dialog_turn_id.clone(), tool_event: ToolEventData::ParamsPartial { - tool_id: ctx.tool_call_buffer.tool_id.clone(), - tool_name: ctx.tool_call_buffer.tool_name.clone(), + tool_id: ctx.pending_tool_call.tool_id().to_string(), + tool_name: ctx.pending_tool_call.tool_name().to_string(), params: tool_call_arguments, }, subagent_parent_info: ctx.event_subagent_parent_info.clone(), @@ -527,17 +509,6 @@ impl StreamProcessor { .await; } } - - // Check if JSON is complete - if ctx.tool_call_buffer.is_valid() { - let tool_call = ctx.tool_call_buffer.to_tool_call(); - ctx.tool_calls.push(tool_call); - - // Clear buffer - // Normally there should be no delta data after parameters are complete, but this has been triggered in practice, possibly due to network issues or model output anomalies - // reset clears the id, subsequent data for this tool_call will not be processed - ctx.tool_call_buffer.reset(); - } } /// Handle text chunk @@ -743,20 +714,18 @@ impl StreamProcessor { } }; - // Handle usage - if let Some(ref response_usage) = response.usage { - self.handle_usage(&mut ctx, response_usage); - } - - if let Some(provider_metadata) = response.provider_metadata { - match ctx.provider_metadata.as_mut() { - Some(existing) => Self::merge_json_value(existing, provider_metadata), - None => ctx.provider_metadata = Some(provider_metadata), - } - } + let UnifiedResponse { + text, + reasoning_content, + thinking_signature, + tool_call, + usage, + finish_reason, + provider_metadata, + } = response; // Handle thinking_signature - if let Some(signature) = response.thinking_signature { + if let Some(signature) = thinking_signature { if !signature.is_empty() { ctx.thinking_signature = Some(signature); trace!("Received thinking_signature"); @@ -766,8 +735,8 @@ impl StreamProcessor { // Handle different types of response content // Normalize empty strings to None // (some models send empty text alongside reasoning content) - let text = response.text.filter(|t| !t.is_empty()); - let reasoning_content = response.reasoning_content.filter(|t| !t.is_empty()); + let text = text.filter(|t| !t.is_empty()); + let reasoning_content = reasoning_content.filter(|t| !t.is_empty()); if let Some(thinking_content) = reasoning_content { self.handle_thinking_chunk(&mut ctx, thinking_content).await; @@ -784,13 +753,28 @@ impl StreamProcessor { } } - if let Some(tool_call) = response.tool_call { + if let Some(tool_call) = tool_call { self.send_thinking_end_if_needed(&mut ctx).await; self.handle_tool_call_chunk(&mut ctx, tool_call).await; if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing tool call").await { return err; } } + + if let Some(ref response_usage) = usage { + self.handle_usage(&mut ctx, response_usage); + } + + if let Some(provider_metadata) = provider_metadata { + match ctx.provider_metadata.as_mut() { + Some(existing) => Self::merge_json_value(existing, provider_metadata), + None => ctx.provider_metadata = Some(provider_metadata), + } + } + + if finish_reason.is_some() { + let _ = ctx.finalize_pending_tool_call(ToolCallBoundary::FinishReason); + } } } } @@ -798,16 +782,154 @@ impl StreamProcessor { // Ensure thinking end marker is sent self.send_thinking_end_if_needed(&mut ctx).await; - // Check if tool parameters are complete, flush SSE logs if incomplete - // Incomplete parameters that still occur under normal network conditions need detailed logging for problem diagnosis - let has_incomplete_tool = ctx.tool_calls.iter().any(|tc| !tc.is_valid()); - if has_incomplete_tool { - flush_sse_on_error(&sse_collector, "Has incomplete tool calls").await; + let _ = ctx.finalize_pending_tool_call(ToolCallBoundary::StreamEnd); + + // Invalid tool payloads that survive to finalization still need detailed SSE logs for diagnosis. + if ctx.tool_calls.iter().any(|tc| !tc.is_valid()) { + flush_sse_on_error(&sse_collector, "Has invalid tool calls").await; } - ctx.force_finish_tool_call_buffer(); self.log_stream_result(&ctx); Ok(ctx.into_result()) } } + +#[cfg(test)] +mod tests { + use super::StreamProcessor; + use crate::agentic::events::{EventQueue, EventQueueConfig}; + use ai_stream_handlers::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; + use futures::StreamExt; + use serde_json::json; + use std::sync::Arc; + use tokio_stream::iter; + use tokio_util::sync::CancellationToken; + + fn build_processor() -> StreamProcessor { + StreamProcessor::new(Arc::new(EventQueue::new(EventQueueConfig::default()))) + } + + fn sample_usage(total_tokens: u32) -> UnifiedTokenUsage { + UnifiedTokenUsage { + prompt_token_count: 1, + candidates_token_count: total_tokens.saturating_sub(1), + total_token_count: total_tokens, + reasoning_token_count: None, + cached_content_token_count: None, + } + } + + #[tokio::test] + async fn keeps_collecting_tool_args_across_usage_chunks() { + let processor = build_processor(); + let stream = iter(vec![ + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + id: Some("call_1".to_string()), + name: Some("tool_a".to_string()), + arguments: Some("{\"a\":".to_string()), + }), + usage: Some(sample_usage(5)), + ..Default::default() + }), + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + id: None, + name: None, + arguments: Some("1}".to_string()), + }), + usage: Some(sample_usage(7)), + ..Default::default() + }), + ]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].tool_id, "call_1"); + assert_eq!(result.tool_calls[0].tool_name, "tool_a"); + assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); + assert!(!result.tool_calls[0].is_error); + assert_eq!(result.usage.as_ref().map(|u| u.total_token_count), Some(7)); + } + + #[tokio::test] + async fn finalizes_tool_after_same_chunk_finish_reason() { + let processor = build_processor(); + let stream = iter(vec![Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + id: Some("call_1".to_string()), + name: Some("tool_a".to_string()), + arguments: Some("{\"a\":1}".to_string()), + }), + usage: Some(sample_usage(9)), + finish_reason: Some("tool_calls".to_string()), + ..Default::default() + })]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); + assert_eq!(result.usage.as_ref().map(|u| u.total_token_count), Some(9)); + } + + #[tokio::test] + async fn repairs_tool_args_with_one_extra_trailing_right_brace() { + let processor = build_processor(); + let stream = iter(vec![Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + id: Some("call_1".to_string()), + name: Some("tool_a".to_string()), + arguments: Some("{\"a\":1}}".to_string()), + }), + finish_reason: Some("tool_calls".to_string()), + ..Default::default() + })]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].tool_id, "call_1"); + assert_eq!(result.tool_calls[0].tool_name, "tool_a"); + assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); + assert!(!result.tool_calls[0].is_error); + } +} diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 70cab66c..f1408357 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -7,10 +7,10 @@ use super::state_manager::ToolStateManager; use super::types::*; use crate::agentic::core::{ToolCall, ToolExecutionState, ToolResult as ModelToolResult}; use crate::agentic::events::types::ToolEventData; +use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::{ ToolOptions, ToolResult as FrameworkToolResult, ToolUseContext, }; -use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::image_context::ImageContextProviderRef; use crate::agentic::tools::registry::ToolRegistry; use crate::util::errors::{BitFunError, BitFunResult}; @@ -400,11 +400,13 @@ impl ToolPipeline { ); if tool_name.is_empty() || tool_is_error { - let error_msg = format!( - "Missing tool name or tool arguments are invalid. \ - This may be caused by network errors (packet loss, connection issues) or model output anomalies. \ - Please regenerate the tool call with valid tool name and arguments." - ); + let error_msg = if tool_name.is_empty() && tool_is_error { + "Missing valid tool name and arguments are invalid JSON.".to_string() + } else if tool_name.is_empty() { + "Missing valid tool name.".to_string() + } else { + "Arguments are invalid JSON.".to_string() + }; self.state_manager .update_state( &tool_id, diff --git a/src/crates/core/src/infrastructure/ai/client.rs b/src/crates/core/src/infrastructure/ai/client.rs index 90547fa9..dd571899 100644 --- a/src/crates/core/src/infrastructure/ai/client.rs +++ b/src/crates/core/src/infrastructure/ai/client.rs @@ -5,9 +5,9 @@ use crate::infrastructure::ai::providers::anthropic::AnthropicMessageConverter; use crate::infrastructure::ai::providers::gemini::GeminiMessageConverter; use crate::infrastructure::ai::providers::openai::OpenAIMessageConverter; +use crate::infrastructure::ai::tool_call_accumulator::{PendingToolCall, ToolCallBoundary}; use crate::service::config::ProxyConfig; use crate::util::types::*; -use crate::util::JsonChecker; use ai_stream_handlers::{ handle_anthropic_stream, handle_gemini_stream, handle_openai_stream, handle_responses_stream, UnifiedResponse, @@ -17,7 +17,6 @@ use futures::StreamExt; use log::{debug, error, info, warn}; use reqwest::{Client, Proxy}; use serde::Deserialize; -use std::collections::HashMap; use tokio::sync::mpsc; /// Streamed response result with the parsed stream and optional raw SSE receiver @@ -616,10 +615,7 @@ impl AIClient { builder = builder .header("Content-Type", "application/json") .header("x-goog-api-key", &self.config.api_key) - .header( - "Authorization", - format!("Bearer {}", self.config.api_key), - ); + .header("Authorization", format!("Bearer {}", self.config.api_key)); if self.config.base_url.contains("openbitfun.com") { builder = builder.header("X-Verification-Code", "from_bitfun"); @@ -1809,83 +1805,111 @@ impl AIClient { let mut provider_metadata: Option = None; let mut tool_calls: Vec = Vec::new(); - let mut cur_tool_call_id = String::new(); - let mut cur_tool_call_name = String::new(); - let mut json_checker = JsonChecker::new(); + let mut pending_tool_call = PendingToolCall::default(); while let Some(chunk_result) = stream.next().await { match chunk_result { Ok(chunk) => { - if let Some(text) = chunk.text { + let UnifiedResponse { + text, + reasoning_content, + thinking_signature: _, + tool_call, + usage: chunk_usage, + finish_reason: chunk_finish_reason, + provider_metadata: chunk_provider_metadata, + } = chunk; + + if let Some(text) = text { full_text.push_str(&text); } - if let Some(reasoning_content) = chunk.reasoning_content { + if let Some(reasoning_content) = reasoning_content { full_reasoning.push_str(&reasoning_content); } - if let Some(finish_reason_) = chunk.finish_reason { - finish_reason = Some(finish_reason_); - } - - if let Some(chunk_usage) = chunk.usage { - usage = Some(Self::unified_usage_to_gemini_usage(chunk_usage)); - } - - if let Some(chunk_provider_metadata) = chunk.provider_metadata { - match provider_metadata.as_mut() { - Some(existing) => { - Self::merge_json_value(existing, chunk_provider_metadata); - } - None => provider_metadata = Some(chunk_provider_metadata), - } - } + if let Some(tool_call) = tool_call { + let ai_stream_handlers::UnifiedToolCall { + id, + name, + arguments, + } = tool_call; - if let Some(tool_call) = chunk.tool_call { - if let Some(tool_call_id) = tool_call.id { + if let Some(tool_call_id) = id { if !tool_call_id.is_empty() { - // Some providers repeat the tool id on every delta. Only reset when the id changes. - let is_new_tool = cur_tool_call_id != tool_call_id; + // Some providers repeat the tool id on every delta. Only switch when the id changes. + let is_new_tool = pending_tool_call.tool_id() != tool_call_id; if is_new_tool { - cur_tool_call_id = tool_call_id; - cur_tool_call_name = tool_call.name.unwrap_or_default(); - json_checker.reset(); + if let Some(finalized) = + pending_tool_call.finalize(ToolCallBoundary::NewTool) + { + if finalized.is_error { + warn!( + "[send_message] Dropping invalid tool call at boundary=new_tool: tool_id={}, tool_name={}, raw_len={}", + finalized.tool_id, + finalized.tool_name, + finalized.raw_arguments.len() + ); + } else { + let arguments = finalized.arguments_as_object_map(); + tool_calls.push(ToolCall { + id: finalized.tool_id, + name: finalized.tool_name, + arguments, + }); + } + } + pending_tool_call.start_new(tool_call_id, name.clone()); debug!( "[send_message] Detected tool call: {}", - cur_tool_call_name + pending_tool_call.tool_name() ); - } else if cur_tool_call_name.is_empty() { - // Best-effort: keep name if provider repeats it. - cur_tool_call_name = tool_call.name.unwrap_or_default(); + } else { + pending_tool_call.update_tool_name_if_missing(name.clone()); } } } - if let Some(ref tool_call_arguments) = tool_call.arguments { - json_checker.append(tool_call_arguments); + if let Some(tool_call_arguments) = arguments { + if pending_tool_call.has_pending() { + pending_tool_call.append_arguments(&tool_call_arguments); + } } + } - if json_checker.is_valid() { - let arguments_string = json_checker.get_buffer(); - let arguments: HashMap = - serde_json::from_str(&arguments_string).unwrap_or_else(|e| { - error!( - "[send_message] Failed to parse tool arguments: {}, arguments: {}", - e, - arguments_string - ); - HashMap::new() + if let Some(finish_reason_) = chunk_finish_reason { + if let Some(finalized) = + pending_tool_call.finalize(ToolCallBoundary::FinishReason) + { + if finalized.is_error { + warn!( + "[send_message] Dropping invalid tool call at boundary=finish_reason: tool_id={}, tool_name={}, raw_len={}", + finalized.tool_id, + finalized.tool_name, + finalized.raw_arguments.len() + ); + } else { + let arguments = finalized.arguments_as_object_map(); + tool_calls.push(ToolCall { + id: finalized.tool_id, + name: finalized.tool_name, + arguments, }); - tool_calls.push(ToolCall { - id: cur_tool_call_id.clone(), - name: cur_tool_call_name.clone(), - arguments, - }); - debug!( - "[send_message] Tool call arguments complete: {}", - cur_tool_call_name - ); - json_checker.reset(); + } + } + finish_reason = Some(finish_reason_); + } + + if let Some(chunk_usage) = chunk_usage { + usage = Some(Self::unified_usage_to_gemini_usage(chunk_usage)); + } + + if let Some(chunk_provider_metadata) = chunk_provider_metadata { + match provider_metadata.as_mut() { + Some(existing) => { + Self::merge_json_value(existing, chunk_provider_metadata); + } + None => provider_metadata = Some(chunk_provider_metadata), } } } @@ -1893,6 +1917,24 @@ impl AIClient { } } + if let Some(finalized) = pending_tool_call.finalize(ToolCallBoundary::EndOfAggregation) { + if finalized.is_error { + warn!( + "[send_message] Dropping invalid tool call at boundary=end_of_aggregation: tool_id={}, tool_name={}, raw_len={}", + finalized.tool_id, + finalized.tool_name, + finalized.raw_arguments.len() + ); + } else { + let arguments = finalized.arguments_as_object_map(); + tool_calls.push(ToolCall { + id: finalized.tool_id, + name: finalized.tool_name, + arguments, + }); + } + } + let reasoning_content = if full_reasoning.is_empty() { None } else { diff --git a/src/crates/core/src/infrastructure/ai/mod.rs b/src/crates/core/src/infrastructure/ai/mod.rs index ae9e7015..14456248 100644 --- a/src/crates/core/src/infrastructure/ai/mod.rs +++ b/src/crates/core/src/infrastructure/ai/mod.rs @@ -5,6 +5,7 @@ pub mod client; pub mod client_factory; pub mod providers; +pub mod tool_call_accumulator; pub use ai_stream_handlers; diff --git a/src/crates/core/src/infrastructure/ai/tool_call_accumulator.rs b/src/crates/core/src/infrastructure/ai/tool_call_accumulator.rs new file mode 100644 index 00000000..c35b4fb5 --- /dev/null +++ b/src/crates/core/src/infrastructure/ai/tool_call_accumulator.rs @@ -0,0 +1,216 @@ +use log::{error, warn}; +use serde_json::{json, Value}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCallBoundary { + NewTool, + FinishReason, + StreamEnd, + GracefulShutdown, + EndOfAggregation, +} + +impl ToolCallBoundary { + fn as_str(self) -> &'static str { + match self { + Self::NewTool => "new_tool", + Self::FinishReason => "finish_reason", + Self::StreamEnd => "stream_end", + Self::GracefulShutdown => "graceful_shutdown", + Self::EndOfAggregation => "end_of_aggregation", + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct PendingToolCall { + tool_id: String, + tool_name: String, + raw_arguments: String, +} + +#[derive(Debug, Clone)] +pub struct FinalizedToolCall { + pub tool_id: String, + pub tool_name: String, + pub raw_arguments: String, + pub arguments: Value, + pub is_error: bool, +} + +impl FinalizedToolCall { + pub fn arguments_as_object_map(&self) -> HashMap { + match &self.arguments { + Value::Object(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + _ => HashMap::new(), + } + } +} + +impl PendingToolCall { + fn remove_trailing_right_brace_once(raw_arguments: &str) -> Option { + let last_non_whitespace_idx = raw_arguments + .char_indices() + .rev() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(idx, _)| idx)?; + + if raw_arguments[last_non_whitespace_idx..].chars().next() != Some('}') { + return None; + } + + let mut repaired = raw_arguments.to_string(); + repaired.remove(last_non_whitespace_idx); + Some(repaired) + } + + fn parse_arguments(raw_arguments: &str) -> Result { + match serde_json::from_str::(raw_arguments) { + Ok(arguments) => Ok(arguments), + Err(primary_error) => { + if let Some(repaired_arguments) = + Self::remove_trailing_right_brace_once(raw_arguments) + { + match serde_json::from_str::(&repaired_arguments) { + Ok(arguments) => { + warn!( + "Tool call arguments repaired by removing one trailing right brace" + ); + Ok(arguments) + } + Err(_) => Err(primary_error.to_string()), + } + } else { + Err(primary_error.to_string()) + } + } + } + } + + pub fn has_pending(&self) -> bool { + !self.tool_id.is_empty() + } + + pub fn tool_id(&self) -> &str { + &self.tool_id + } + + pub fn tool_name(&self) -> &str { + &self.tool_name + } + + pub fn start_new(&mut self, tool_id: String, tool_name: Option) { + self.tool_id = tool_id; + self.tool_name = tool_name.unwrap_or_default(); + self.raw_arguments.clear(); + } + + pub fn update_tool_name_if_missing(&mut self, tool_name: Option) { + if self.tool_name.is_empty() { + self.tool_name = tool_name.unwrap_or_default(); + } + } + + pub fn append_arguments(&mut self, arguments_chunk: &str) { + self.raw_arguments.push_str(arguments_chunk); + } + + pub fn finalize(&mut self, boundary: ToolCallBoundary) -> Option { + if !self.has_pending() { + return None; + } + + let tool_id = std::mem::take(&mut self.tool_id); + let tool_name = std::mem::take(&mut self.tool_name); + let raw_arguments = std::mem::take(&mut self.raw_arguments); + let parsed_arguments = Self::parse_arguments(&raw_arguments); + let is_error = parsed_arguments.is_err(); + + if let Err(error) = &parsed_arguments { + error!( + "Tool call arguments parsing failed at boundary={}: tool_id={}, tool_name={}, error={}, raw_arguments={}", + boundary.as_str(), + tool_id, + tool_name, + error, + raw_arguments + ); + } + + Some(FinalizedToolCall { + tool_id, + tool_name, + raw_arguments, + arguments: parsed_arguments.unwrap_or_else(|_| json!({})), + is_error, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{PendingToolCall, ToolCallBoundary}; + use serde_json::json; + + #[test] + fn finalizes_complete_json_only_at_boundary() { + let mut pending = PendingToolCall::default(); + pending.start_new("call_1".to_string(), Some("tool_a".to_string())); + pending.append_arguments("{\"a\":1}"); + + let finalized = pending + .finalize(ToolCallBoundary::FinishReason) + .expect("finalized tool"); + + assert_eq!(finalized.tool_id, "call_1"); + assert_eq!(finalized.tool_name, "tool_a"); + assert_eq!(finalized.arguments, json!({"a": 1})); + assert!(!finalized.is_error); + assert!(!pending.has_pending()); + } + + #[test] + fn invalid_json_becomes_error_with_empty_object() { + let mut pending = PendingToolCall::default(); + pending.start_new("call_1".to_string(), Some("tool_a".to_string())); + pending.append_arguments("{\"a\":"); + + let finalized = pending + .finalize(ToolCallBoundary::StreamEnd) + .expect("finalized tool"); + + assert_eq!(finalized.arguments, json!({})); + assert!(finalized.is_error); + } + + #[test] + fn repairs_json_with_one_extra_trailing_right_brace() { + let mut pending = PendingToolCall::default(); + pending.start_new("call_1".to_string(), Some("tool_a".to_string())); + pending.append_arguments("{\"a\":1}}"); + + let finalized = pending + .finalize(ToolCallBoundary::FinishReason) + .expect("finalized tool"); + + assert_eq!(finalized.raw_arguments, "{\"a\":1}}"); + assert_eq!(finalized.arguments, json!({"a": 1})); + assert!(!finalized.is_error); + } + + #[test] + fn arguments_as_object_map_returns_hash_map_for_objects() { + let mut pending = PendingToolCall::default(); + pending.start_new("call_1".to_string(), Some("tool_a".to_string())); + pending.append_arguments("{\"a\":1,\"b\":\"x\"}"); + + let finalized = pending + .finalize(ToolCallBoundary::EndOfAggregation) + .expect("finalized tool"); + let map = finalized.arguments_as_object_map(); + + assert_eq!(map.get("a"), Some(&json!(1))); + assert_eq!(map.get("b"), Some(&json!("x"))); + } +} diff --git a/src/crates/core/src/util/json_checker.rs b/src/crates/core/src/util/json_checker.rs deleted file mode 100644 index e014afb7..00000000 --- a/src/crates/core/src/util/json_checker.rs +++ /dev/null @@ -1,620 +0,0 @@ -/// JSON integrity checker - detect whether streamed JSON is complete -/// -/// Primarily used to check whether tool-parameter JSON in AI streaming responses has been fully received. -/// Tolerates leading non-JSON content (e.g. spaces sent by some models) by discarding -/// everything before the first '{'. -#[derive(Debug)] -pub struct JsonChecker { - buffer: String, - stack: Vec, - in_string: bool, - escape_next: bool, - seen_left_brace: bool, -} - -impl JsonChecker { - pub fn new() -> Self { - Self { - buffer: String::new(), - stack: Vec::new(), - in_string: false, - escape_next: false, - seen_left_brace: false, - } - } - - pub fn append(&mut self, s: &str) { - let mut chars = s.chars(); - - while let Some(ch) = chars.next() { - // Discard everything before the first '{' - if !self.seen_left_brace { - if ch == '{' { - self.seen_left_brace = true; - self.stack.push('{'); - self.buffer.push(ch); - } - continue; - } - - self.buffer.push(ch); - - if self.escape_next { - self.escape_next = false; - continue; - } - - match ch { - '\\' if self.in_string => { - self.escape_next = true; - } - '"' => { - self.in_string = !self.in_string; - } - '{' if !self.in_string => { - self.stack.push('{'); - } - '}' if !self.in_string => { - if !self.stack.is_empty() { - self.stack.pop(); - } - } - _ => {} - } - } - } - - pub fn get_buffer(&self) -> String { - self.buffer.clone() - } - - pub fn is_valid(&self) -> bool { - self.stack.is_empty() && self.seen_left_brace - } - - pub fn reset(&mut self) { - self.buffer.clear(); - self.stack.clear(); - self.in_string = false; - self.escape_next = false; - self.seen_left_brace = false; - } -} - -impl Default for JsonChecker { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Helper: feed string as single chunk ── - - fn check_one_shot(input: &str) -> (bool, String) { - let mut c = JsonChecker::new(); - c.append(input); - (c.is_valid(), c.get_buffer()) - } - - // ── Helper: feed string char-by-char (worst-case chunking) ── - - fn check_char_by_char(input: &str) -> (bool, String) { - let mut c = JsonChecker::new(); - for ch in input.chars() { - c.append(&ch.to_string()); - } - (c.is_valid(), c.get_buffer()) - } - - // ── Basic validity ── - - #[test] - fn empty_input_is_invalid() { - let (valid, _) = check_one_shot(""); - assert!(!valid); - } - - #[test] - fn simple_empty_object() { - let (valid, buf) = check_one_shot("{}"); - assert!(valid); - assert_eq!(buf, "{}"); - } - - #[test] - fn simple_object_with_string_value() { - let input = r#"{"city": "Beijing"}"#; - let (valid, buf) = check_one_shot(input); - assert!(valid); - assert_eq!(buf, input); - } - - #[test] - fn nested_object() { - let input = r#"{"a": {"b": {"c": 1}}}"#; - let (valid, _) = check_one_shot(input); - assert!(valid); - } - - #[test] - fn incomplete_object_missing_closing_brace() { - let (valid, _) = check_one_shot(r#"{"key": "value""#); - assert!(!valid); - } - - #[test] - fn incomplete_object_open_string() { - let (valid, _) = check_one_shot(r#"{"key": "val"#); - assert!(!valid); - } - - // ── Leading garbage / whitespace (ByteDance model issue) ── - - #[test] - fn leading_space_before_brace() { - let (valid, buf) = check_one_shot(r#" {"city": "Beijing"}"#); - assert!(valid); - assert_eq!(buf, r#"{"city": "Beijing"}"#); - } - - #[test] - fn leading_multiple_spaces_and_newlines() { - let (valid, buf) = check_one_shot(" \n\t {\"a\": 1}"); - assert!(valid); - assert_eq!(buf, "{\"a\": 1}"); - } - - #[test] - fn leading_random_text_before_brace() { - let (valid, buf) = check_one_shot("some garbage {\"ok\": true}"); - assert!(valid); - assert_eq!(buf, "{\"ok\": true}"); - } - - #[test] - fn only_spaces_no_brace() { - let (valid, _) = check_one_shot(" "); - assert!(!valid); - } - - // ── Escape handling ── - - #[test] - fn escaped_quote_in_string() { - // JSON: {"msg": "say \"hello\""} - let input = r#"{"msg": "say \"hello\""}"#; - let (valid, _) = check_one_shot(input); - assert!(valid); - } - - #[test] - fn escaped_backslash_before_quote() { - // JSON: {"path": "C:\\"} — value is C:\, the \\ is an escaped backslash - let input = r#"{"path": "C:\\"}"#; - let (valid, _) = check_one_shot(input); - assert!(valid); - } - - #[test] - fn escaped_backslash_followed_by_quote_char_by_char() { - // Ensure escape state survives across single-char chunks - let input = r#"{"path": "C:\\"}"#; - let (valid, buf) = check_char_by_char(input); - assert!(valid); - assert_eq!(buf, input); - } - - #[test] - fn braces_inside_string_are_ignored() { - let input = r#"{"code": "fn main() { println!(\"hi\"); }"}"#; - let (valid, _) = check_one_shot(input); - assert!(valid); - } - - #[test] - fn braces_inside_string_char_by_char() { - let input = r#"{"code": "fn main() { println!(\"hi\"); }"}"#; - let (valid, _) = check_char_by_char(input); - assert!(valid); - } - - // ── Cross-chunk escape: the exact ByteDance bug scenario ── - - #[test] - fn escape_split_across_chunks() { - // Simulates: {"new_string": "fn main() {\n println!(\"Hello, World!\");\n}"} - // The backslash and the quote land in different chunks - let mut c = JsonChecker::new(); - c.append(r#"{"new_string": "fn main() {\n println!(\"Hello, World!"#); - assert!(!c.is_valid()); - - // chunk ends with backslash - c.append("\\"); - assert!(!c.is_valid()); - - // next chunk starts with escaped quote — must NOT end the string - c.append("\""); - assert!(!c.is_valid()); - - c.append(r#");\n}"}"#); - assert!(c.is_valid()); - } - - #[test] - fn escape_at_chunk_boundary_does_not_leak() { - // After the escaped char is consumed, escape_next should be false - let mut c = JsonChecker::new(); - c.append(r#"{"a": "x\"#); // ends with backslash inside string - assert!(!c.is_valid()); - - c.append("n"); // \n escape sequence complete - assert!(!c.is_valid()); - - c.append(r#""}"#); // close string and object - assert!(c.is_valid()); - } - - // ── Realistic streaming simulation ── - - #[test] - fn bytedance_doubao_streaming_simulation() { - // Reproduces the exact chunking pattern from the bug report - let mut c = JsonChecker::new(); - c.append(""); // empty first arguments chunk - c.append(" {\""); // leading space + opening brace - assert!(!c.is_valid()); - - c.append("city"); - c.append("\":"); - c.append(" \""); - c.append("Beijing"); - c.append("\"}"); - assert!(c.is_valid()); - assert_eq!(c.get_buffer(), r#"{"city": "Beijing"}"#); - } - - #[test] - fn edit_tool_streaming_simulation() { - // Reproduces the Edit tool call from the second bug report - let mut c = JsonChecker::new(); - c.append("{\"file_path\": \"E:/Projects/ForTest/basic-rust/src/main.rs\", \"new_string\": \"fn main() {\\n println!(\\\"Hello,"); - c.append(" World"); - c.append("!\\"); // backslash at chunk end - c.append("\");"); // escaped quote at chunk start — must stay in string - assert!(!c.is_valid()); - - c.append("\\"); // another backslash at chunk end - c.append("n"); // \n escape - c.append("}\","); // closing brace inside string, then close string, comma - assert!(!c.is_valid()); // object not yet closed - - c.append(" \"old_string\": \"\""); - c.append("}"); - assert!(c.is_valid()); - } - - // ── Reset ── - - #[test] - fn reset_clears_all_state() { - let mut c = JsonChecker::new(); - c.append(r#" {"key": "val"#); // leading space, incomplete - assert!(!c.is_valid()); - - c.reset(); - assert!(!c.is_valid()); - assert_eq!(c.get_buffer(), ""); - - // Should work fresh after reset - c.append(r#"{"ok": true}"#); - assert!(c.is_valid()); - } - - #[test] - fn reset_clears_escape_state() { - let mut c = JsonChecker::new(); - c.append(r#"{"a": "\"#); // ends mid-escape - c.reset(); - - // The stale escape_next must not affect the new input - c.append(r#"{"b": "x"}"#); - assert!(c.is_valid()); - } - - // ── Edge cases ── - - #[test] - fn multiple_top_level_objects_first_wins() { - // After the first object completes, is_valid becomes true; - // subsequent data keeps appending but re-opens the stack - let mut c = JsonChecker::new(); - c.append("{}"); - assert!(c.is_valid()); - - c.append("{}"); - // stack opens and closes again, still valid - assert!(c.is_valid()); - } - - #[test] - fn deeply_nested_objects() { - let input = r#"{"a":{"b":{"c":{"d":{"e":{}}}}}}"#; - let (valid, _) = check_one_shot(input); - assert!(valid); - } - - #[test] - fn string_with_unicode_escapes() { - let input = r#"{"emoji": "\u0048\u0065\u006C\u006C\u006F"}"#; - let (valid, _) = check_one_shot(input); - assert!(valid); - } - - #[test] - fn string_with_newlines_and_tabs() { - let input = r#"{"text": "line1\nline2\ttab"}"#; - let (valid, _) = check_one_shot(input); - assert!(valid); - } - - #[test] - fn consecutive_escaped_backslashes() { - // JSON value: a\\b — two backslashes, meaning literal backslash in value - let input = r#"{"p": "a\\\\b"}"#; - let (valid, _) = check_one_shot(input); - assert!(valid); - } - - #[test] - fn consecutive_escaped_backslashes_char_by_char() { - let input = r#"{"p": "a\\\\b"}"#; - let (valid, _) = check_char_by_char(input); - assert!(valid); - } - - #[test] - fn default_trait_works() { - let c = JsonChecker::default(); - assert!(!c.is_valid()); - assert_eq!(c.get_buffer(), ""); - } - - // ── Streaming: no premature is_valid() ── - - #[test] - fn never_valid_during_progressive_append() { - // Feed a complete JSON object token-by-token, assert is_valid() is false - // at every step except after the final '}' - let chunks = vec![ - "{", "\"", "k", "e", "y", "\"", ":", " ", "\"", "v", "a", "l", "\"", "}", - ]; - let mut c = JsonChecker::new(); - for (i, chunk) in chunks.iter().enumerate() { - c.append(chunk); - if i < chunks.len() - 1 { - assert!( - !c.is_valid(), - "premature valid at chunk index {}: {:?}", - i, - c.get_buffer() - ); - } - } - assert!(c.is_valid()); - assert_eq!(c.get_buffer(), r#"{"key": "val"}"#); - } - - #[test] - fn never_valid_during_nested_object_streaming() { - // {"a": {"b": 1}} streamed in realistic chunks - let chunks = vec!["{\"a\"", ": ", "{\"b\"", ": 1", "}", "}"]; - let mut c = JsonChecker::new(); - for (i, chunk) in chunks.iter().enumerate() { - c.append(chunk); - if i < chunks.len() - 1 { - assert!( - !c.is_valid(), - "premature valid at chunk index {}: {:?}", - i, - c.get_buffer() - ); - } - } - assert!(c.is_valid()); - } - - #[test] - fn string_with_braces_never_premature_valid() { - // {"code": "{ } { }"} — braces inside string must not close the object - let chunks = vec!["{\"code\": \"", "{ ", "} ", "{ ", "}", "\"", "}"]; - let mut c = JsonChecker::new(); - for (i, chunk) in chunks.iter().enumerate() { - c.append(chunk); - if i < chunks.len() - 1 { - assert!( - !c.is_valid(), - "premature valid at chunk index {}: {:?}", - i, - c.get_buffer() - ); - } - } - assert!(c.is_valid()); - } - - // ── Streaming: empty chunks interspersed ── - - #[test] - fn empty_chunks_between_data() { - let mut c = JsonChecker::new(); - c.append(""); - assert!(!c.is_valid()); - c.append("{"); - assert!(!c.is_valid()); - c.append(""); - assert!(!c.is_valid()); - c.append("\"a\""); - c.append(""); - c.append(": 1"); - c.append(""); - c.append(""); - c.append("}"); - assert!(c.is_valid()); - assert_eq!(c.get_buffer(), r#"{"a": 1}"#); - } - - #[test] - fn empty_chunks_before_first_brace() { - let mut c = JsonChecker::new(); - c.append(""); - c.append(""); - c.append(""); - assert!(!c.is_valid()); - c.append(" "); - assert!(!c.is_valid()); - c.append("{}"); - assert!(c.is_valid()); - } - - // ── Streaming: \\\" sequence split at different positions ── - - #[test] - fn escaped_backslash_then_escaped_quote_split_1() { - // JSON: {"a": "x\\\"y"} — value is x\"y (backslash, quote, y) - // Split: `{"a": "x\` | `\` | `\` | `"` | `y"}` - // Char-by-char through the \\\" sequence - let mut c = JsonChecker::new(); - c.append(r#"{"a": "x"#); - assert!(!c.is_valid()); - c.append("\\"); // first \ of \\, sets escape_next - assert!(!c.is_valid()); - c.append("\\"); // consumed by escape (it's the escaped backslash), then done - assert!(!c.is_valid()); - c.append("\\"); // first \ of \", sets escape_next - assert!(!c.is_valid()); - c.append("\""); // consumed by escape (it's the escaped quote) - assert!(!c.is_valid()); // still inside string! - c.append("y"); - assert!(!c.is_valid()); - c.append("\"}"); - assert!(c.is_valid()); - } - - #[test] - fn escaped_backslash_then_escaped_quote_split_2() { - // Same JSON: {"a": "x\\\"y"} but split as: `...x\\` | `\"y"}` - let mut c = JsonChecker::new(); - c.append(r#"{"a": "x\\"#); // \\ = escaped backslash, escape_next consumed - assert!(!c.is_valid()); - c.append(r#"\"y"}"#); // \" = escaped quote, y, close string, close object - assert!(c.is_valid()); - } - - #[test] - fn escaped_backslash_then_escaped_quote_split_3() { - // Same JSON but split as: `...x\` | `\\` | `"y"}` - let mut c = JsonChecker::new(); - c.append(r#"{"a": "x\"#); // \ sets escape_next - assert!(!c.is_valid()); - c.append("\\\\"); // first \ consumed by escape, second \ sets escape_next - assert!(!c.is_valid()); - c.append("\"y\"}"); // " consumed by escape, y normal, " closes string, } closes object - assert!(c.is_valid()); - } - - // ── Streaming: escaped backslash + closing quote ── - - #[test] - fn escaped_backslash_then_closing_quote_split_at_boundary() { - // JSON: {"a": "x\\"} — value is x\ (escaped backslash), then " closes string - // Split as: `{"a": "x\` | `\"}` — \ crosses chunk boundary - let mut c = JsonChecker::new(); - c.append(r#"{"a": "x\"#); // \ sets escape_next - assert!(!c.is_valid()); - c.append("\\\"}"); // \ consumed by escape, " closes string, } closes object - assert!(c.is_valid()); - assert_eq!(c.get_buffer(), r#"{"a": "x\\"}"#); - } - - #[test] - fn escaped_backslash_then_closing_quote_split_after_pair() { - // Same JSON: {"a": "x\\"} — split as: `{"a": "x\\` | `"}` - let mut c = JsonChecker::new(); - c.append(r#"{"a": "x\\"#); // \\ pair complete, escape_next = false - assert!(!c.is_valid()); - c.append("\"}"); // " closes string, } closes object - assert!(c.is_valid()); - } - - // ── Streaming: multiple tool calls with reset (full lifecycle) ── - - #[test] - fn lifecycle_multiple_tool_calls_with_reset() { - let mut c = JsonChecker::new(); - - // --- Tool call 1: simple --- - c.append(" "); // leading space (ByteDance) - c.append("{\""); - c.append("city\": \"Beijing\"}"); - assert!(c.is_valid()); - assert_eq!(c.get_buffer(), r#"{"city": "Beijing"}"#); - - // --- Reset for tool call 2 --- - c.reset(); - assert!(!c.is_valid()); - assert_eq!(c.get_buffer(), ""); - - // --- Tool call 2: with escapes --- - c.append("{\"code\": \""); - assert!(!c.is_valid()); - c.append("fn main() {\\n"); - assert!(!c.is_valid()); - c.append(" println!(\\\"hi\\\");"); - assert!(!c.is_valid()); - c.append("\\n}\"}"); - assert!(c.is_valid()); - - // --- Reset for tool call 3 --- - c.reset(); - assert!(!c.is_valid()); - - // --- Tool call 3: empty object --- - c.append("{}"); - assert!(c.is_valid()); - } - - #[test] - fn lifecycle_reset_mid_escape_then_new_tool_call() { - let mut c = JsonChecker::new(); - - // Tool call 1: interrupted mid-escape - c.append("{\"a\": \"x\\"); // ends with pending escape - assert!(!c.is_valid()); - - // Reset before completion (e.g. stream error) - c.reset(); - - // Tool call 2: must work cleanly with no stale escape state - c.append("{\"b\": \"y\"}"); - assert!(c.is_valid()); - assert_eq!(c.get_buffer(), r#"{"b": "y"}"#); - } - - #[test] - fn lifecycle_reset_mid_string_then_new_tool_call() { - let mut c = JsonChecker::new(); - - // Tool call 1: interrupted inside string - c.append("{\"a\": \"some text"); - assert!(!c.is_valid()); - - c.reset(); - - // Tool call 2: must not think it's still in a string - c.append("{\"b\": \"{}\"}"); // braces inside string value - assert!(c.is_valid()); - } -} diff --git a/src/crates/core/src/util/mod.rs b/src/crates/core/src/util/mod.rs index 47595a42..a55acad2 100644 --- a/src/crates/core/src/util/mod.rs +++ b/src/crates/core/src/util/mod.rs @@ -2,7 +2,6 @@ pub mod errors; pub mod front_matter_markdown; -pub mod json_checker; pub mod json_extract; pub mod plain_output; pub mod process_manager; @@ -11,7 +10,6 @@ pub mod types; pub use errors::*; pub use front_matter_markdown::FrontMatterMarkdown; -pub use json_checker::JsonChecker; pub use json_extract::extract_json_from_ai_response; pub use plain_output::sanitize_plain_model_output; pub use process_manager::*;