feat: stream agent turns through session lifecycle
This commit is contained in:
parent
588449d373
commit
3ada5b9421
@ -1,11 +1,15 @@
|
|||||||
use crate::agent::context_compressor::estimate_tokens;
|
use crate::agent::context_compressor::estimate_tokens;
|
||||||
use crate::agent::media_handler::MediaHandlerRegistry;
|
use crate::agent::media_handler::MediaHandlerRegistry;
|
||||||
use crate::agent::system_prompt::build_system_prompt;
|
use crate::agent::system_prompt::build_system_prompt;
|
||||||
|
use crate::agent::turn_event::{AgentTurnContext, TurnEvent};
|
||||||
use crate::bus::message::ContentBlock;
|
use crate::bus::message::ContentBlock;
|
||||||
use crate::bus::{ChatMessage, MediaRef};
|
use crate::bus::{ChatMessage, MediaRef};
|
||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
use crate::observability::{Observer, ObserverEvent, ToolExecutionOutcome, truncate_args};
|
use crate::observability::{Observer, ObserverEvent, ToolExecutionOutcome, truncate_args};
|
||||||
use crate::providers::{ChatCompletionRequest, LLMProvider, Message, ToolCall, create_provider};
|
use crate::providers::{
|
||||||
|
ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, ProviderChunk,
|
||||||
|
ProviderResponseAccumulator, ToolCall, create_provider,
|
||||||
|
};
|
||||||
use crate::tools::ToolRegistry;
|
use crate::tools::ToolRegistry;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
@ -13,11 +17,14 @@ use std::path::PathBuf;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
/// Maximum characters in a tool result before truncation.
|
/// Maximum characters in a tool result before truncation.
|
||||||
/// Prevents context overflow from large tool outputs.
|
/// Prevents context overflow from large tool outputs.
|
||||||
const MAX_TOOL_RESULT_CHARS: usize = 16_000;
|
const MAX_TOOL_RESULT_CHARS: usize = 16_000;
|
||||||
/// Minimum characters to keep when truncating
|
/// Minimum characters to keep when truncating
|
||||||
const TRUNCATION_SUFFIX_LEN: usize = 200;
|
const TRUNCATION_SUFFIX_LEN: usize = 200;
|
||||||
|
const TOOL_PREVIEW_CHARS: usize = 1_000;
|
||||||
|
|
||||||
enum MediaOrigin<'a> {
|
enum MediaOrigin<'a> {
|
||||||
User,
|
User,
|
||||||
@ -164,6 +171,17 @@ fn truncate_tool_result(output: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_result_preview(output: &str) -> String {
|
||||||
|
if output.len() <= TOOL_PREVIEW_CHARS {
|
||||||
|
output.to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}…",
|
||||||
|
&output[..output.floor_char_boundary(TOOL_PREVIEW_CHARS)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Loop detection result.
|
/// Loop detection result.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
enum LoopDetectionResult {
|
enum LoopDetectionResult {
|
||||||
@ -296,6 +314,32 @@ pub struct AgentProcessResult {
|
|||||||
pub final_response: ChatMessage,
|
pub final_response: ChatMessage,
|
||||||
pub emitted_messages: Vec<ChatMessage>,
|
pub emitted_messages: Vec<ChatMessage>,
|
||||||
pub total_tokens: Option<u32>,
|
pub total_tokens: Option<u32>,
|
||||||
|
pub usage: Option<crate::providers::Usage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_usage(total: &mut crate::providers::Usage, next: &crate::providers::Usage) {
|
||||||
|
total.prompt_tokens = total.prompt_tokens.saturating_add(next.prompt_tokens);
|
||||||
|
total.completion_tokens = total
|
||||||
|
.completion_tokens
|
||||||
|
.saturating_add(next.completion_tokens);
|
||||||
|
total.total_tokens = total.total_tokens.saturating_add(next.total_tokens);
|
||||||
|
total.cached_tokens = sum_optional_tokens(total.cached_tokens, next.cached_tokens);
|
||||||
|
total.cache_read_input_tokens =
|
||||||
|
sum_optional_tokens(total.cache_read_input_tokens, next.cache_read_input_tokens);
|
||||||
|
total.cache_creation_input_tokens = sum_optional_tokens(
|
||||||
|
total.cache_creation_input_tokens,
|
||||||
|
next.cache_creation_input_tokens,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sum_optional_tokens(left: Option<u32>, right: Option<u32>) -> Option<u32> {
|
||||||
|
match (left, right) {
|
||||||
|
(None, None) => None,
|
||||||
|
(left, right) => Some(
|
||||||
|
left.unwrap_or_default()
|
||||||
|
.saturating_add(right.unwrap_or_default()),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentLoop {
|
impl AgentLoop {
|
||||||
@ -451,6 +495,60 @@ impl AgentLoop {
|
|||||||
&self.tools
|
&self.tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn stream_completion(
|
||||||
|
&self,
|
||||||
|
request: ChatCompletionRequest,
|
||||||
|
iteration: u32,
|
||||||
|
turn: Option<&AgentTurnContext>,
|
||||||
|
) -> Result<ChatCompletionResponse, AgentError> {
|
||||||
|
let mut provider_stream = self.provider.stream(request).await.map_err(|error| {
|
||||||
|
tracing::error!(error = %error, "LLM request failed");
|
||||||
|
AgentError::LlmError(error.to_string())
|
||||||
|
})?;
|
||||||
|
let mut accumulator = ProviderResponseAccumulator::default();
|
||||||
|
while let Some(chunk) = provider_stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|error| {
|
||||||
|
tracing::error!(error = %error, "LLM stream failed");
|
||||||
|
AgentError::LlmError(error.to_string())
|
||||||
|
})?;
|
||||||
|
if let Some(turn) = turn {
|
||||||
|
let event = match &chunk {
|
||||||
|
ProviderChunk::Reasoning(delta) => Some(TurnEvent::ReasoningDelta {
|
||||||
|
iteration,
|
||||||
|
delta: delta.clone(),
|
||||||
|
}),
|
||||||
|
ProviderChunk::Text(delta) => Some(TurnEvent::TextDelta {
|
||||||
|
iteration,
|
||||||
|
delta: delta.clone(),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(event) = event {
|
||||||
|
turn.emitter.emit(event).map_err(|error| {
|
||||||
|
AgentError::Other(format!("turn event rejected: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accumulator.push(chunk);
|
||||||
|
}
|
||||||
|
Ok(accumulator.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn annotate_message(
|
||||||
|
message: &mut ChatMessage,
|
||||||
|
turn: Option<&AgentTurnContext>,
|
||||||
|
iteration: u32,
|
||||||
|
final_response: bool,
|
||||||
|
) {
|
||||||
|
message.iteration = Some(iteration);
|
||||||
|
if let Some(turn) = turn {
|
||||||
|
message.turn_id = Some(turn.turn_id.clone());
|
||||||
|
if final_response {
|
||||||
|
message.id = turn.message_id.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn chat_message_to_llm_message(&self, m: &ChatMessage, include_media: bool) -> Message {
|
fn chat_message_to_llm_message(&self, m: &ChatMessage, include_media: bool) -> Message {
|
||||||
let content = if m.media_refs.is_empty() || !include_media {
|
let content = if m.media_refs.is_empty() || !include_media {
|
||||||
vec![ContentBlock::text(&m.content)]
|
vec![ContentBlock::text(&m.content)]
|
||||||
@ -498,8 +596,24 @@ impl AgentLoop {
|
|||||||
/// - The LLM returns no more tool calls (final response)
|
/// - The LLM returns no more tool calls (final response)
|
||||||
/// - Maximum iterations are reached
|
/// - Maximum iterations are reached
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
|
&self,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
) -> Result<AgentProcessResult, AgentError> {
|
||||||
|
self.process_inner(messages, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_streaming(
|
||||||
|
&self,
|
||||||
|
messages: Vec<ChatMessage>,
|
||||||
|
turn: AgentTurnContext,
|
||||||
|
) -> Result<AgentProcessResult, AgentError> {
|
||||||
|
self.process_inner(messages, Some(turn)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_inner(
|
||||||
&self,
|
&self,
|
||||||
mut messages: Vec<ChatMessage>,
|
mut messages: Vec<ChatMessage>,
|
||||||
|
turn: Option<AgentTurnContext>,
|
||||||
) -> Result<AgentProcessResult, AgentError> {
|
) -> Result<AgentProcessResult, AgentError> {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@ -522,6 +636,7 @@ impl AgentLoop {
|
|||||||
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
|
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
|
||||||
let mut emitted_messages = Vec::new();
|
let mut emitted_messages = Vec::new();
|
||||||
let mut accumulated_tokens: u32 = 0;
|
let mut accumulated_tokens: u32 = 0;
|
||||||
|
let mut accumulated_usage = crate::providers::Usage::default();
|
||||||
|
|
||||||
for iteration in 0..self.max_iterations {
|
for iteration in 0..self.max_iterations {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@ -564,12 +679,14 @@ impl AgentLoop {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Call LLM
|
// Call LLM
|
||||||
let response = (*self.provider).chat(request).await.map_err(|e| {
|
let iteration = u32::try_from(iteration)
|
||||||
tracing::error!(error = %e, "LLM request failed");
|
.map_err(|_| AgentError::Other("tool iteration exceeds u32".to_string()))?;
|
||||||
AgentError::LlmError(e.to_string())
|
let response = self
|
||||||
})?;
|
.stream_completion(request, iteration, turn.as_ref())
|
||||||
|
.await?;
|
||||||
|
|
||||||
accumulated_tokens += response.usage.total_tokens;
|
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
||||||
|
merge_usage(&mut accumulated_usage, &response.usage);
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
@ -583,14 +700,23 @@ impl AgentLoop {
|
|||||||
if response.tool_calls.is_empty() {
|
if response.tool_calls.is_empty() {
|
||||||
let mut assistant_message = ChatMessage::assistant(response.content);
|
let mut assistant_message = ChatMessage::assistant(response.content);
|
||||||
assistant_message.reasoning_content = response.reasoning_content;
|
assistant_message.reasoning_content = response.reasoning_content;
|
||||||
|
assistant_message.provider_state = response.provider_state;
|
||||||
|
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, true);
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
return Ok(AgentProcessResult {
|
return Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
total_tokens: Some(accumulated_tokens),
|
total_tokens: Some(accumulated_tokens),
|
||||||
|
usage: Some(accumulated_usage),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(turn) = turn.as_ref() {
|
||||||
|
turn.emitter
|
||||||
|
.emit(TurnEvent::TextSegmentFinished { iteration })
|
||||||
|
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
// Execute tool calls — log and notify immediately
|
// Execute tool calls — log and notify immediately
|
||||||
{
|
{
|
||||||
let tools_info: Vec<String> = response
|
let tools_info: Vec<String> = response
|
||||||
@ -614,11 +740,15 @@ impl AgentLoop {
|
|||||||
response.tool_calls.clone(),
|
response.tool_calls.clone(),
|
||||||
);
|
);
|
||||||
assistant_message.reasoning_content = response.reasoning_content;
|
assistant_message.reasoning_content = response.reasoning_content;
|
||||||
|
assistant_message.provider_state = response.provider_state;
|
||||||
|
Self::annotate_message(&mut assistant_message, turn.as_ref(), iteration, false);
|
||||||
messages.push(assistant_message.clone());
|
messages.push(assistant_message.clone());
|
||||||
emitted_messages.push(assistant_message);
|
emitted_messages.push(assistant_message);
|
||||||
|
|
||||||
// Execute tools and add results to messages
|
// Execute tools and add results to messages
|
||||||
let tool_results = self.execute_tools(&response.tool_calls).await;
|
let tool_results = self
|
||||||
|
.execute_tools(&response.tool_calls, iteration, turn.as_ref())
|
||||||
|
.await?;
|
||||||
|
|
||||||
for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) {
|
for (tool_call, result) in response.tool_calls.iter().zip(tool_results.iter()) {
|
||||||
// Log function call with name and arguments
|
// Log function call with name and arguments
|
||||||
@ -644,22 +774,24 @@ impl AgentLoop {
|
|||||||
"Loop warning: {}",
|
"Loop warning: {}",
|
||||||
msg
|
msg
|
||||||
);
|
);
|
||||||
let tool_message = ChatMessage::tool_with_media(
|
let mut tool_message = ChatMessage::tool_with_media(
|
||||||
tool_call.id.clone(),
|
tool_call.id.clone(),
|
||||||
tool_call.name.clone(),
|
tool_call.name.clone(),
|
||||||
format!("{}\n\n[上一条结果]\n{}", msg, truncated_output),
|
format!("{}\n\n[上一条结果]\n{}", msg, truncated_output),
|
||||||
result.media_refs.clone(),
|
result.media_refs.clone(),
|
||||||
);
|
);
|
||||||
|
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
|
||||||
messages.push(tool_message.clone());
|
messages.push(tool_message.clone());
|
||||||
emitted_messages.push(tool_message);
|
emitted_messages.push(tool_message);
|
||||||
}
|
}
|
||||||
LoopDetectionResult::Ok => {
|
LoopDetectionResult::Ok => {
|
||||||
let tool_message = ChatMessage::tool_with_media(
|
let mut tool_message = ChatMessage::tool_with_media(
|
||||||
tool_call.id.clone(),
|
tool_call.id.clone(),
|
||||||
tool_call.name.clone(),
|
tool_call.name.clone(),
|
||||||
truncated_output,
|
truncated_output,
|
||||||
result.media_refs.clone(),
|
result.media_refs.clone(),
|
||||||
);
|
);
|
||||||
|
Self::annotate_message(&mut tool_message, turn.as_ref(), iteration, false);
|
||||||
messages.push(tool_message.clone());
|
messages.push(tool_message.clone());
|
||||||
emitted_messages.push(tool_message);
|
emitted_messages.push(tool_message);
|
||||||
}
|
}
|
||||||
@ -695,25 +827,56 @@ impl AgentLoop {
|
|||||||
tools: None, // No tools in final summary call
|
tools: None, // No tools in final summary call
|
||||||
};
|
};
|
||||||
|
|
||||||
match (*self.provider).chat(request).await {
|
let summary_iteration = u32::try_from(self.max_iterations)
|
||||||
|
.map_err(|_| AgentError::Other("tool iteration exceeds u32".to_string()))?;
|
||||||
|
match self
|
||||||
|
.stream_completion(request, summary_iteration, turn.as_ref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
accumulated_tokens += response.usage.total_tokens;
|
accumulated_tokens = accumulated_tokens.saturating_add(response.usage.total_tokens);
|
||||||
|
merge_usage(&mut accumulated_usage, &response.usage);
|
||||||
let mut assistant_message = ChatMessage::assistant(response.content);
|
let mut assistant_message = ChatMessage::assistant(response.content);
|
||||||
assistant_message.reasoning_content = response.reasoning_content;
|
assistant_message.reasoning_content = response.reasoning_content;
|
||||||
|
assistant_message.provider_state = response.provider_state;
|
||||||
|
Self::annotate_message(
|
||||||
|
&mut assistant_message,
|
||||||
|
turn.as_ref(),
|
||||||
|
summary_iteration,
|
||||||
|
true,
|
||||||
|
);
|
||||||
emitted_messages.push(assistant_message.clone());
|
emitted_messages.push(assistant_message.clone());
|
||||||
Ok(AgentProcessResult {
|
Ok(AgentProcessResult {
|
||||||
final_response: assistant_message,
|
final_response: assistant_message,
|
||||||
emitted_messages,
|
emitted_messages,
|
||||||
total_tokens: Some(accumulated_tokens),
|
total_tokens: Some(accumulated_tokens),
|
||||||
|
usage: Some(accumulated_usage),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Fallback if summary call fails
|
// Fallback if summary call fails
|
||||||
tracing::error!(error = %e, "Failed to get summary from LLM");
|
tracing::error!(error = %e, "Failed to get summary from LLM");
|
||||||
let final_message = ChatMessage::assistant(format!(
|
let fallback = format!(
|
||||||
"I reached the maximum number of tool call iterations ({}) without completing the task. The work done so far has been lost due to an error. Please try breaking the task into smaller steps.",
|
"I reached the maximum number of tool call iterations ({}) without completing the task. The work done so far has been lost due to an error. Please try breaking the task into smaller steps.",
|
||||||
self.max_iterations
|
self.max_iterations
|
||||||
));
|
);
|
||||||
|
if let Some(turn) = turn.as_ref() {
|
||||||
|
turn.emitter
|
||||||
|
.emit(TurnEvent::TextSegmentFinished {
|
||||||
|
iteration: summary_iteration,
|
||||||
|
})
|
||||||
|
.and_then(|()| {
|
||||||
|
turn.emitter.emit(TurnEvent::TextDelta {
|
||||||
|
iteration: summary_iteration,
|
||||||
|
delta: fallback.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
AgentError::Other(format!("turn event rejected: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let mut final_message = ChatMessage::assistant(fallback);
|
||||||
|
Self::annotate_message(&mut final_message, turn.as_ref(), summary_iteration, true);
|
||||||
emitted_messages.push(final_message.clone());
|
emitted_messages.push(final_message.clone());
|
||||||
Ok(AgentProcessResult {
|
Ok(AgentProcessResult {
|
||||||
final_response: final_message,
|
final_response: final_message,
|
||||||
@ -723,6 +886,7 @@ impl AgentLoop {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
usage: (accumulated_usage.total_tokens > 0).then_some(accumulated_usage),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -753,42 +917,76 @@ impl AgentLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Execute multiple tool calls, choosing parallel or sequential based on conditions.
|
/// Execute multiple tool calls, choosing parallel or sequential based on conditions.
|
||||||
async fn execute_tools(&self, tool_calls: &[ToolCall]) -> Vec<ToolExecutionOutcome> {
|
async fn execute_tools(
|
||||||
|
&self,
|
||||||
|
tool_calls: &[ToolCall],
|
||||||
|
iteration: u32,
|
||||||
|
turn: Option<&AgentTurnContext>,
|
||||||
|
) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
|
||||||
if self.should_execute_in_parallel(tool_calls) {
|
if self.should_execute_in_parallel(tool_calls) {
|
||||||
tracing::debug!("Executing {} tools in parallel", tool_calls.len());
|
tracing::debug!("Executing {} tools in parallel", tool_calls.len());
|
||||||
self.execute_tools_parallel(tool_calls).await
|
self.execute_tools_parallel(tool_calls, iteration, turn)
|
||||||
|
.await
|
||||||
} else {
|
} else {
|
||||||
tracing::debug!("Executing {} tools sequentially", tool_calls.len());
|
tracing::debug!("Executing {} tools sequentially", tool_calls.len());
|
||||||
self.execute_tools_sequential(tool_calls).await
|
self.execute_tools_sequential(tool_calls, iteration, turn)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute tools in parallel using join_all.
|
/// Execute tools in parallel using join_all.
|
||||||
async fn execute_tools_parallel(&self, tool_calls: &[ToolCall]) -> Vec<ToolExecutionOutcome> {
|
async fn execute_tools_parallel(
|
||||||
|
&self,
|
||||||
|
tool_calls: &[ToolCall],
|
||||||
|
iteration: u32,
|
||||||
|
turn: Option<&AgentTurnContext>,
|
||||||
|
) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
|
||||||
let futures: Vec<_> = tool_calls
|
let futures: Vec<_> = tool_calls
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tc| self.execute_one_tool(tc))
|
.map(|tool_call| self.execute_one_tool(tool_call, iteration, turn))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
futures_util::future::join_all(futures).await
|
futures_util::future::join_all(futures)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute tools sequentially.
|
/// Execute tools sequentially.
|
||||||
async fn execute_tools_sequential(&self, tool_calls: &[ToolCall]) -> Vec<ToolExecutionOutcome> {
|
async fn execute_tools_sequential(
|
||||||
|
&self,
|
||||||
|
tool_calls: &[ToolCall],
|
||||||
|
iteration: u32,
|
||||||
|
turn: Option<&AgentTurnContext>,
|
||||||
|
) -> Result<Vec<ToolExecutionOutcome>, AgentError> {
|
||||||
let mut outcomes = Vec::with_capacity(tool_calls.len());
|
let mut outcomes = Vec::with_capacity(tool_calls.len());
|
||||||
|
|
||||||
for tool_call in tool_calls {
|
for tool_call in tool_calls {
|
||||||
outcomes.push(self.execute_one_tool(tool_call).await);
|
outcomes.push(self.execute_one_tool(tool_call, iteration, turn).await?);
|
||||||
}
|
}
|
||||||
|
|
||||||
outcomes
|
Ok(outcomes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a single tool and return the outcome with event tracking.
|
/// Execute a single tool and return the outcome with event tracking.
|
||||||
async fn execute_one_tool(&self, tool_call: &ToolCall) -> ToolExecutionOutcome {
|
async fn execute_one_tool(
|
||||||
|
&self,
|
||||||
|
tool_call: &ToolCall,
|
||||||
|
iteration: u32,
|
||||||
|
turn: Option<&AgentTurnContext>,
|
||||||
|
) -> Result<ToolExecutionOutcome, AgentError> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let tool_name = tool_call.name.clone();
|
let tool_name = tool_call.name.clone();
|
||||||
|
|
||||||
|
if let Some(turn) = turn {
|
||||||
|
turn.emitter
|
||||||
|
.emit(TurnEvent::ToolStarted {
|
||||||
|
iteration,
|
||||||
|
call: tool_call.clone(),
|
||||||
|
})
|
||||||
|
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
// Record ToolCallStart event
|
// Record ToolCallStart event
|
||||||
if let Some(ref observer) = self.observer {
|
if let Some(ref observer) = self.observer {
|
||||||
observer.record_event(&ObserverEvent::ToolCallStart {
|
observer.record_event(&ObserverEvent::ToolCallStart {
|
||||||
@ -800,6 +998,17 @@ impl AgentLoop {
|
|||||||
let result = self.execute_tool_internal(tool_call).await;
|
let result = self.execute_tool_internal(tool_call).await;
|
||||||
let duration = start.elapsed();
|
let duration = start.elapsed();
|
||||||
|
|
||||||
|
if let Some(turn) = turn {
|
||||||
|
turn.emitter
|
||||||
|
.emit(TurnEvent::ToolFinished {
|
||||||
|
iteration,
|
||||||
|
call_id: tool_call.id.clone(),
|
||||||
|
success: result.success,
|
||||||
|
preview: Some(tool_result_preview(&truncate_tool_result(&result.output))),
|
||||||
|
})
|
||||||
|
.map_err(|error| AgentError::Other(format!("turn event rejected: {error}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
// Record ToolCall event
|
// Record ToolCall event
|
||||||
if let Some(ref observer) = self.observer {
|
if let Some(ref observer) = self.observer {
|
||||||
observer.record_event(&ObserverEvent::ToolCall {
|
observer.record_event(&ObserverEvent::ToolCall {
|
||||||
@ -810,7 +1019,7 @@ impl AgentLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply duration
|
// Apply duration
|
||||||
ToolExecutionOutcome { duration, ..result }
|
Ok(ToolExecutionOutcome { duration, ..result })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal tool execution without event tracking.
|
/// Internal tool execution without event tracking.
|
||||||
@ -851,13 +1060,115 @@ impl AgentLoop {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::observability::{MultiObserver, Observer};
|
use crate::observability::{MultiObserver, Observer};
|
||||||
use crate::providers::{ChatCompletionResponse, Usage};
|
use crate::providers::{
|
||||||
|
ChatCompletionResponse, FinishReason, ProviderChunk, ProviderStream, Usage,
|
||||||
|
};
|
||||||
|
use crate::session::{TurnBlock, TurnController};
|
||||||
use crate::tools::FileReadTool;
|
use crate::tools::FileReadTool;
|
||||||
|
|
||||||
struct TestObserver {
|
struct TestObserver {
|
||||||
events: std::sync::Mutex<Vec<ObserverEvent>>,
|
events: std::sync::Mutex<Vec<ObserverEvent>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct StreamingTextProvider;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl LLMProvider for StreamingTextProvider {
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
_request: ChatCompletionRequest,
|
||||||
|
) -> Result<ProviderStream, crate::providers::DynProviderError> {
|
||||||
|
let chunks = vec![
|
||||||
|
ProviderChunk::Metadata {
|
||||||
|
id: "response".into(),
|
||||||
|
model: "streaming-test".into(),
|
||||||
|
},
|
||||||
|
ProviderChunk::Reasoning("because ".into()),
|
||||||
|
ProviderChunk::Reasoning("facts".into()),
|
||||||
|
ProviderChunk::Text("hello ".into()),
|
||||||
|
ProviderChunk::Text("world".into()),
|
||||||
|
ProviderChunk::ProviderState(crate::bus::ProviderReasoningState {
|
||||||
|
provider: "test".into(),
|
||||||
|
payload: serde_json::json!({"opaque":"state"}),
|
||||||
|
}),
|
||||||
|
ProviderChunk::Usage(Usage {
|
||||||
|
prompt_tokens: 2,
|
||||||
|
completion_tokens: 3,
|
||||||
|
total_tokens: 5,
|
||||||
|
..Usage::default()
|
||||||
|
}),
|
||||||
|
ProviderChunk::Done(FinishReason::Stop),
|
||||||
|
];
|
||||||
|
Ok(Box::pin(futures_util::stream::iter(
|
||||||
|
chunks.into_iter().map(Ok),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ptype(&self) -> &str {
|
||||||
|
"test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"streaming-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_id(&self) -> &str {
|
||||||
|
"streaming-test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn process_streaming_emits_turn_blocks_and_stamps_durable_message() {
|
||||||
|
let agent = AgentLoop::with_provider(
|
||||||
|
Arc::new(StreamingTextProvider),
|
||||||
|
1,
|
||||||
|
"streaming-test".into(),
|
||||||
|
PathBuf::from("."),
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
|
||||||
|
let initial = controller.snapshot();
|
||||||
|
let context =
|
||||||
|
AgentTurnContext::new(initial.id.0.clone(), initial.message_id.clone(), emitter);
|
||||||
|
|
||||||
|
let result = agent
|
||||||
|
.process_streaming(vec![ChatMessage::user("hi")], context)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.final_response.id, "assistant-id");
|
||||||
|
assert_eq!(result.final_response.content, "hello world");
|
||||||
|
assert_eq!(
|
||||||
|
result.final_response.reasoning_content.as_deref(),
|
||||||
|
Some("because facts")
|
||||||
|
);
|
||||||
|
assert_eq!(result.final_response.turn_id, Some(initial.id.0.clone()));
|
||||||
|
assert_eq!(result.final_response.iteration, Some(0));
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.final_response
|
||||||
|
.provider_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|state| state.provider.as_str()),
|
||||||
|
Some("test")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.usage.as_ref().map(|usage| usage.total_tokens),
|
||||||
|
Some(5)
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = controller.snapshot();
|
||||||
|
assert_eq!(snapshot.blocks.len(), 2);
|
||||||
|
assert!(matches!(
|
||||||
|
&snapshot.blocks[0],
|
||||||
|
TurnBlock::Reasoning { text, .. } if text == "because facts"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
&snapshot.blocks[1],
|
||||||
|
TurnBlock::Assistant { text, .. } if text == "hello world"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
impl TestObserver {
|
impl TestObserver {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@ -973,8 +1284,13 @@ mod tests {
|
|||||||
vec!["text".to_string(), "image".to_string()],
|
vec!["text".to_string(), "image".to_string()],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let (controller, emitter, _) = TurnController::start("session", "assistant-id");
|
||||||
|
let turn = controller.snapshot();
|
||||||
let result = agent
|
let result = agent
|
||||||
.process(vec![ChatMessage::user("inspect the image")])
|
.process_streaming(
|
||||||
|
vec![ChatMessage::user("inspect the image")],
|
||||||
|
AgentTurnContext::new(turn.id.0.clone(), turn.message_id.clone(), emitter),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@ -999,6 +1315,14 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|media| media.media_type == "image")
|
.any(|media| media.media_type == "image")
|
||||||
}));
|
}));
|
||||||
|
assert!(controller.snapshot().blocks.iter().any(|block| matches!(
|
||||||
|
block,
|
||||||
|
TurnBlock::Tool {
|
||||||
|
id,
|
||||||
|
status: crate::session::ToolStatus::Completed,
|
||||||
|
..
|
||||||
|
} if id == "call-image"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -15,4 +15,4 @@ pub use system_prompt::{
|
|||||||
PromptContext, PromptSection, SystemPromptBuilder, build_sub_agent_system_prompt,
|
PromptContext, PromptSection, SystemPromptBuilder, build_sub_agent_system_prompt,
|
||||||
build_system_prompt,
|
build_system_prompt,
|
||||||
};
|
};
|
||||||
pub use turn_event::{TurnEmitError, TurnEmitter, TurnEvent};
|
pub use turn_event::{AgentTurnContext, TurnEmitError, TurnEmitter, TurnEvent};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@ -49,6 +49,29 @@ type EmitFn = dyn Fn(TurnEvent) -> Result<(), TurnEmitError> + Send + Sync;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct TurnEmitter {
|
pub struct TurnEmitter {
|
||||||
emit: Arc<EmitFn>,
|
emit: Arc<EmitFn>,
|
||||||
|
enabled: Arc<Mutex<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session-owned identity and emitter for one AgentLoop execution.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AgentTurnContext {
|
||||||
|
pub turn_id: String,
|
||||||
|
pub message_id: String,
|
||||||
|
pub emitter: TurnEmitter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTurnContext {
|
||||||
|
pub fn new(
|
||||||
|
turn_id: impl Into<String>,
|
||||||
|
message_id: impl Into<String>,
|
||||||
|
emitter: TurnEmitter,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
turn_id: turn_id.into(),
|
||||||
|
message_id: message_id.into(),
|
||||||
|
emitter,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TurnEmitter {
|
impl TurnEmitter {
|
||||||
@ -58,10 +81,27 @@ impl TurnEmitter {
|
|||||||
{
|
{
|
||||||
Self {
|
Self {
|
||||||
emit: Arc::new(emit),
|
emit: Arc::new(emit),
|
||||||
|
enabled: Arc::new(Mutex::new(true)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn emit(&self, event: TurnEvent) -> Result<(), TurnEmitError> {
|
pub fn emit(&self, event: TurnEvent) -> Result<(), TurnEmitError> {
|
||||||
|
let enabled = self
|
||||||
|
.enabled
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
if !*enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
(self.emit)(event)
|
(self.emit)(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop forwarding new presentation facts without changing durable or
|
||||||
|
/// terminal Turn status. Session uses this before invalidating a worker.
|
||||||
|
pub fn deactivate(&self) {
|
||||||
|
*self
|
||||||
|
.enabled
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner()) = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,8 @@ pub use self::openai::OpenAIProvider;
|
|||||||
|
|
||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
pub use stream::{
|
pub use stream::{
|
||||||
DynProviderError, FinishReason, ProviderChunk, ProviderStream, ProviderStreamItem,
|
DynProviderError, FinishReason, ProviderChunk, ProviderResponseAccumulator, ProviderStream,
|
||||||
collect_provider_stream, provider_stream_from_response,
|
ProviderStreamItem, collect_provider_stream, provider_stream_from_response,
|
||||||
};
|
};
|
||||||
pub use traits::{
|
pub use traits::{
|
||||||
ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, Tool, ToolCall,
|
ChatCompletionRequest, ChatCompletionResponse, LLMProvider, Message, Tool, ToolCall,
|
||||||
|
|||||||
@ -64,34 +64,35 @@ struct PartialToolCall {
|
|||||||
arguments: String,
|
arguments: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn collect_provider_stream(
|
#[derive(Default)]
|
||||||
mut provider_stream: ProviderStream,
|
pub struct ProviderResponseAccumulator {
|
||||||
) -> Result<ChatCompletionResponse, DynProviderError> {
|
id: String,
|
||||||
let mut id = String::new();
|
model: String,
|
||||||
let mut model = String::new();
|
content: String,
|
||||||
let mut content = String::new();
|
reasoning_content: String,
|
||||||
let mut reasoning_content = String::new();
|
provider_state: Option<ProviderReasoningState>,
|
||||||
let mut provider_state = None;
|
usage: Usage,
|
||||||
let mut usage = Usage::default();
|
tool_calls: BTreeMap<usize, PartialToolCall>,
|
||||||
let mut tool_calls = BTreeMap::<usize, PartialToolCall>::new();
|
}
|
||||||
|
|
||||||
while let Some(chunk) = provider_stream.next().await {
|
impl ProviderResponseAccumulator {
|
||||||
match chunk? {
|
pub fn push(&mut self, chunk: ProviderChunk) {
|
||||||
|
match chunk {
|
||||||
ProviderChunk::Metadata {
|
ProviderChunk::Metadata {
|
||||||
id: response_id,
|
id: response_id,
|
||||||
model: response_model,
|
model: response_model,
|
||||||
} => {
|
} => {
|
||||||
id = response_id;
|
self.id = response_id;
|
||||||
model = response_model;
|
self.model = response_model;
|
||||||
}
|
}
|
||||||
ProviderChunk::Text(delta) => content.push_str(&delta),
|
ProviderChunk::Text(delta) => self.content.push_str(&delta),
|
||||||
ProviderChunk::Reasoning(delta) => reasoning_content.push_str(&delta),
|
ProviderChunk::Reasoning(delta) => self.reasoning_content.push_str(&delta),
|
||||||
ProviderChunk::ToolCallStart {
|
ProviderChunk::ToolCallStart {
|
||||||
index,
|
index,
|
||||||
id: call_id,
|
id: call_id,
|
||||||
name,
|
name,
|
||||||
} => {
|
} => {
|
||||||
let partial = tool_calls.entry(index).or_default();
|
let partial = self.tool_calls.entry(index).or_default();
|
||||||
if call_id.is_some() {
|
if call_id.is_some() {
|
||||||
partial.id = call_id;
|
partial.id = call_id;
|
||||||
}
|
}
|
||||||
@ -100,36 +101,51 @@ pub async fn collect_provider_stream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ProviderChunk::ToolCallArguments { index, delta } => {
|
ProviderChunk::ToolCallArguments { index, delta } => {
|
||||||
tool_calls
|
self.tool_calls
|
||||||
.entry(index)
|
.entry(index)
|
||||||
.or_default()
|
.or_default()
|
||||||
.arguments
|
.arguments
|
||||||
.push_str(&delta);
|
.push_str(&delta);
|
||||||
}
|
}
|
||||||
ProviderChunk::ProviderState(state) => provider_state = Some(state),
|
ProviderChunk::ProviderState(state) => self.provider_state = Some(state),
|
||||||
ProviderChunk::Usage(value) => usage = value,
|
ProviderChunk::Usage(value) => self.usage = value,
|
||||||
ProviderChunk::Done(_) => {}
|
ProviderChunk::Done(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let tool_calls = tool_calls
|
pub fn finish(self) -> ChatCompletionResponse {
|
||||||
.into_iter()
|
let tool_calls = self
|
||||||
.map(|(index, partial)| ToolCall {
|
.tool_calls
|
||||||
id: partial.id.unwrap_or_else(|| format!("tool_call_{index}")),
|
.into_iter()
|
||||||
name: partial.name.unwrap_or_default(),
|
.map(|(index, partial)| ToolCall {
|
||||||
arguments: serde_json::from_str(&partial.arguments).unwrap_or(serde_json::Value::Null),
|
id: partial.id.unwrap_or_else(|| format!("tool_call_{index}")),
|
||||||
})
|
name: partial.name.unwrap_or_default(),
|
||||||
.collect();
|
arguments: serde_json::from_str(&partial.arguments)
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(ChatCompletionResponse {
|
ChatCompletionResponse {
|
||||||
id,
|
id: self.id,
|
||||||
model,
|
model: self.model,
|
||||||
content,
|
content: self.content,
|
||||||
reasoning_content: (!reasoning_content.is_empty()).then_some(reasoning_content),
|
reasoning_content: (!self.reasoning_content.is_empty())
|
||||||
provider_state,
|
.then_some(self.reasoning_content),
|
||||||
tool_calls,
|
provider_state: self.provider_state,
|
||||||
usage,
|
tool_calls,
|
||||||
})
|
usage: self.usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collect_provider_stream(
|
||||||
|
mut provider_stream: ProviderStream,
|
||||||
|
) -> Result<ChatCompletionResponse, DynProviderError> {
|
||||||
|
let mut accumulator = ProviderResponseAccumulator::default();
|
||||||
|
while let Some(chunk) = provider_stream.next().await {
|
||||||
|
accumulator.push(chunk?);
|
||||||
|
}
|
||||||
|
Ok(accumulator.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adapt a complete response to the stream-first provider contract.
|
/// Adapt a complete response to the stream-first provider contract.
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::{fmt::Display, future::Future};
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use super::session::{MessagePersistSnapshot, Session};
|
use super::session::{MessagePersistSnapshot, Session};
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
use crate::storage::StorageError;
|
use crate::storage::StorageError;
|
||||||
|
use crate::{providers::Usage, session::TurnController};
|
||||||
|
|
||||||
async fn persist_added_messages(
|
async fn persist_added_messages(
|
||||||
snapshots: Vec<Option<MessagePersistSnapshot>>,
|
snapshots: Vec<Option<MessagePersistSnapshot>>,
|
||||||
@ -63,3 +65,74 @@ pub(super) async fn append_persisted_messages(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish `Completed` only after the supplied durable write succeeds.
|
||||||
|
///
|
||||||
|
/// Keeping this ordering in one helper makes the user-visible terminal status
|
||||||
|
/// impossible to publish optimistically before SQLite commits.
|
||||||
|
pub(super) async fn finalize_turn_after_persistence<F, T, E>(
|
||||||
|
controller: &TurnController,
|
||||||
|
usage: Option<Usage>,
|
||||||
|
persistence: F,
|
||||||
|
) -> Result<T, E>
|
||||||
|
where
|
||||||
|
F: Future<Output = Result<T, E>>,
|
||||||
|
E: Display,
|
||||||
|
{
|
||||||
|
controller.begin_finalizing();
|
||||||
|
match persistence.await {
|
||||||
|
Ok(value) => {
|
||||||
|
controller.complete(usage);
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
controller.fail(format!("failed to persist turn: {error}"));
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::session::{TurnController, TurnStatus};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn completed_is_published_only_after_persistence_succeeds() {
|
||||||
|
let (controller, _emitter, receiver) = TurnController::start("session", "message");
|
||||||
|
let persisted = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let persisted_in_future = persisted.clone();
|
||||||
|
|
||||||
|
let result: Result<(), String> =
|
||||||
|
finalize_turn_after_persistence(&controller, None, async move {
|
||||||
|
assert_eq!(receiver.borrow().status, TurnStatus::Running);
|
||||||
|
assert_eq!(
|
||||||
|
receiver.borrow().phase,
|
||||||
|
crate::session::TurnPhase::Finalizing
|
||||||
|
);
|
||||||
|
persisted_in_future.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(persisted.load(std::sync::atomic::Ordering::SeqCst));
|
||||||
|
assert_eq!(controller.snapshot().status, TurnStatus::Completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persistence_failure_never_publishes_completed() {
|
||||||
|
let (controller, _emitter, _receiver) = TurnController::start("session", "message");
|
||||||
|
let result: Result<(), &str> =
|
||||||
|
finalize_turn_after_persistence(&controller, None, async { Err("database down") })
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result, Err("database down"));
|
||||||
|
let snapshot = controller.snapshot();
|
||||||
|
assert_eq!(snapshot.status, TurnStatus::Failed);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.error.as_deref(),
|
||||||
|
Some("failed to persist turn: database down")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,8 +3,11 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use tokio::sync::{Mutex, mpsc, oneshot};
|
use tokio::sync::{Mutex, mpsc, oneshot};
|
||||||
|
|
||||||
use super::persistence::append_persisted_messages;
|
use super::persistence::{append_persisted_messages, finalize_turn_after_persistence};
|
||||||
use crate::bus::{ChatMessage, MediaItem, MediaRef, MessageSource, OutboundMessage, SourceKind};
|
use super::turn::{TurnBlock, TurnController, TurnSnapshot};
|
||||||
|
use crate::bus::{
|
||||||
|
ChatMessage, CompletionStatus, MediaItem, MediaRef, MessageSource, OutboundMessage, SourceKind,
|
||||||
|
};
|
||||||
use crate::mcp::get_mcp_status;
|
use crate::mcp::get_mcp_status;
|
||||||
use crate::storage::{Storage, StorageError};
|
use crate::storage::{Storage, StorageError};
|
||||||
use std::sync::Arc as StdArc;
|
use std::sync::Arc as StdArc;
|
||||||
@ -37,7 +40,7 @@ pub enum HandleResult {
|
|||||||
}
|
}
|
||||||
use crate::agent::context_compressor::ContextCompressionConfig;
|
use crate::agent::context_compressor::ContextCompressionConfig;
|
||||||
use crate::agent::system_prompt::{build_runtime_context, build_system_prompt};
|
use crate::agent::system_prompt::{build_runtime_context, build_system_prompt};
|
||||||
use crate::agent::{AgentError, AgentLoop, ContextCompressor};
|
use crate::agent::{AgentError, AgentLoop, AgentTurnContext, ContextCompressor, TurnEmitter};
|
||||||
use crate::channels::slash_command::parse_slash_command;
|
use crate::channels::slash_command::parse_slash_command;
|
||||||
use crate::config::BrowserConfig;
|
use crate::config::BrowserConfig;
|
||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
@ -53,6 +56,115 @@ fn is_context_overflow_error(msg: &str) -> bool {
|
|||||||
|| lower.contains("prompt is too long")
|
|| lower.contains("prompt is too long")
|
||||||
|| lower.contains("input is too long")
|
|| lower.contains("input is too long")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn partial_assistant_message(
|
||||||
|
snapshot: &TurnSnapshot,
|
||||||
|
completion_status: CompletionStatus,
|
||||||
|
) -> Option<ChatMessage> {
|
||||||
|
let mut assistant_segments = Vec::new();
|
||||||
|
let mut reasoning_segments = Vec::new();
|
||||||
|
let mut last_iteration = None;
|
||||||
|
for block in &snapshot.blocks {
|
||||||
|
match block {
|
||||||
|
TurnBlock::Assistant {
|
||||||
|
iteration, text, ..
|
||||||
|
} if !text.is_empty() => {
|
||||||
|
assistant_segments.push(text.as_str());
|
||||||
|
last_iteration = Some(*iteration);
|
||||||
|
}
|
||||||
|
TurnBlock::Reasoning { text, .. } if !text.is_empty() => {
|
||||||
|
reasoning_segments.push(text.as_str());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if assistant_segments.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut message = ChatMessage::assistant(assistant_segments.join("\n\n"));
|
||||||
|
message.id = snapshot.message_id.clone();
|
||||||
|
message.turn_id = Some(snapshot.id.0.clone());
|
||||||
|
message.iteration = last_iteration;
|
||||||
|
message.completion_status = completion_status;
|
||||||
|
message.reasoning_content =
|
||||||
|
(!reasoning_segments.is_empty()).then(|| reasoning_segments.join("\n\n"));
|
||||||
|
Some(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail_turn_with_partial(
|
||||||
|
controller: &TurnController,
|
||||||
|
session: &Arc<Mutex<Session>>,
|
||||||
|
error: String,
|
||||||
|
) {
|
||||||
|
let snapshot = controller.snapshot();
|
||||||
|
let partial = partial_assistant_message(&snapshot, CompletionStatus::Interrupted);
|
||||||
|
if let Some(partial) = partial {
|
||||||
|
controller.begin_finalizing();
|
||||||
|
if let Err(persistence_error) = append_persisted_messages(session, vec![partial]).await {
|
||||||
|
controller.fail(format!(
|
||||||
|
"{error}; failed to persist interrupted turn: {persistence_error}"
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.fail(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod cancelled_partial_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::agent::TurnEvent;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visible_partial_text_becomes_cancelled_persisted_message() {
|
||||||
|
let (controller, emitter, _) = TurnController::start("session", "message-id");
|
||||||
|
emitter
|
||||||
|
.emit(TurnEvent::ReasoningDelta {
|
||||||
|
iteration: 0,
|
||||||
|
delta: "reason".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
emitter
|
||||||
|
.emit(TurnEvent::TextDelta {
|
||||||
|
iteration: 0,
|
||||||
|
delta: "first".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
emitter
|
||||||
|
.emit(TurnEvent::TextSegmentFinished { iteration: 0 })
|
||||||
|
.unwrap();
|
||||||
|
emitter
|
||||||
|
.emit(TurnEvent::TextDelta {
|
||||||
|
iteration: 1,
|
||||||
|
delta: "second".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let message =
|
||||||
|
partial_assistant_message(&controller.snapshot(), CompletionStatus::Cancelled).unwrap();
|
||||||
|
assert_eq!(message.id, "message-id");
|
||||||
|
assert_eq!(message.content, "first\n\nsecond");
|
||||||
|
assert_eq!(message.reasoning_content.as_deref(), Some("reason"));
|
||||||
|
assert_eq!(message.iteration, Some(1));
|
||||||
|
assert_eq!(message.completion_status, CompletionStatus::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_only_cancel_does_not_create_assistant_history() {
|
||||||
|
let (controller, emitter, _) = TurnController::start("session", "message-id");
|
||||||
|
emitter
|
||||||
|
.emit(TurnEvent::ReasoningDelta {
|
||||||
|
iteration: 0,
|
||||||
|
delta: "private".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
partial_assistant_message(&controller.snapshot(), CompletionStatus::Cancelled,)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
use crate::bus::MessageBus;
|
use crate::bus::MessageBus;
|
||||||
use crate::providers::{LLMProvider, create_provider};
|
use crate::providers::{LLMProvider, create_provider};
|
||||||
use crate::session::events::DialogInfo;
|
use crate::session::events::DialogInfo;
|
||||||
@ -93,6 +205,7 @@ pub struct Session {
|
|||||||
agent_tx: Option<mpsc::Sender<AgentTask>>,
|
agent_tx: Option<mpsc::Sender<AgentTask>>,
|
||||||
/// Cancel signal for the currently executing agent task
|
/// Cancel signal for the currently executing agent task
|
||||||
current_cancel: Option<oneshot::Sender<()>>,
|
current_cancel: Option<oneshot::Sender<()>>,
|
||||||
|
active_turn_emitter: Option<ActiveTurnEmitter>,
|
||||||
/// Monotonic counter to detect stale workers
|
/// Monotonic counter to detect stale workers
|
||||||
worker_generation: u64,
|
worker_generation: u64,
|
||||||
/// Monotonic counter for in-memory session mutations.
|
/// Monotonic counter for in-memory session mutations.
|
||||||
@ -108,6 +221,11 @@ pub struct Session {
|
|||||||
pub(super) persistence_lock: Arc<Mutex<()>>,
|
pub(super) persistence_lock: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ActiveTurnEmitter {
|
||||||
|
turn_id: String,
|
||||||
|
emitter: TurnEmitter,
|
||||||
|
}
|
||||||
|
|
||||||
/// A task to be processed by the per-session agent worker
|
/// A task to be processed by the per-session agent worker
|
||||||
struct AgentTask {
|
struct AgentTask {
|
||||||
channel: String,
|
channel: String,
|
||||||
@ -178,6 +296,7 @@ impl Session {
|
|||||||
memory_manager,
|
memory_manager,
|
||||||
agent_tx: None,
|
agent_tx: None,
|
||||||
current_cancel: None,
|
current_cancel: None,
|
||||||
|
active_turn_emitter: None,
|
||||||
worker_generation: 0,
|
worker_generation: 0,
|
||||||
state_version: 0,
|
state_version: 0,
|
||||||
persistence_lock: Arc::new(Mutex::new(())),
|
persistence_lock: Arc::new(Mutex::new(())),
|
||||||
@ -366,6 +485,7 @@ impl Session {
|
|||||||
memory_manager,
|
memory_manager,
|
||||||
agent_tx: None,
|
agent_tx: None,
|
||||||
current_cancel: None,
|
current_cancel: None,
|
||||||
|
active_turn_emitter: None,
|
||||||
worker_generation: 0,
|
worker_generation: 0,
|
||||||
state_version: 0,
|
state_version: 0,
|
||||||
persistence_lock: Arc::new(Mutex::new(())),
|
persistence_lock: Arc::new(Mutex::new(())),
|
||||||
@ -1473,6 +1593,9 @@ impl SessionManager {
|
|||||||
if guard.current_cancel.take().is_some() {
|
if guard.current_cancel.take().is_some() {
|
||||||
msgs.push("当前任务已发送停止信号。".to_string());
|
msgs.push("当前任务已发送停止信号。".to_string());
|
||||||
}
|
}
|
||||||
|
if let Some(active_turn) = guard.active_turn_emitter.take() {
|
||||||
|
active_turn.emitter.deactivate();
|
||||||
|
}
|
||||||
if guard.agent_tx.take().is_some() {
|
if guard.agent_tx.take().is_some() {
|
||||||
msgs.push("消息队列已清空。".to_string());
|
msgs.push("消息队列已清空。".to_string());
|
||||||
}
|
}
|
||||||
@ -2391,12 +2514,40 @@ fn spawn_agent_worker(
|
|||||||
Session::append_runtime_context_to_user_message(last_msg, &runtime_context);
|
Session::append_runtime_context_to_user_message(last_msg, &runtime_context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (turn_controller, turn_emitter, _turn_receiver) = TurnController::start(
|
||||||
|
unified_str.clone(),
|
||||||
|
uuid::Uuid::new_v4().to_string(),
|
||||||
|
);
|
||||||
|
let initial_turn = turn_controller.snapshot();
|
||||||
|
let active_turn_id = initial_turn.id.0.clone();
|
||||||
|
{
|
||||||
|
let mut guard = session.lock().await;
|
||||||
|
if guard.worker_generation != worker_gen || guard.state_version != base_version {
|
||||||
|
turn_emitter.deactivate();
|
||||||
|
turn_controller.cancel(Some(
|
||||||
|
"session changed before model execution".to_string(),
|
||||||
|
));
|
||||||
|
guard.current_cancel = None;
|
||||||
|
continue 'tasks;
|
||||||
|
}
|
||||||
|
guard.active_turn_emitter = Some(ActiveTurnEmitter {
|
||||||
|
turn_id: initial_turn.id.0.clone(),
|
||||||
|
emitter: turn_emitter.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let agent_turn = AgentTurnContext::new(
|
||||||
|
initial_turn.id.0.clone(),
|
||||||
|
initial_turn.message_id.clone(),
|
||||||
|
turn_emitter,
|
||||||
|
);
|
||||||
|
|
||||||
// Phase 2 + 3: LLM call with cancellation
|
// Phase 2 + 3: LLM call with cancellation
|
||||||
let session2 = session.clone();
|
let session2 = session.clone();
|
||||||
let bus2 = bus.clone();
|
let bus2 = bus.clone();
|
||||||
let chan2 = task_chan.clone();
|
let chan2 = task_chan.clone();
|
||||||
let cid2 = task_cid.clone();
|
let cid2 = task_cid.clone();
|
||||||
let unified_str2 = unified_str.clone();
|
let unified_str2 = unified_str.clone();
|
||||||
|
let turn_lifecycle = &turn_controller;
|
||||||
let process_future = async move {
|
let process_future = async move {
|
||||||
let response_session_id = unified_str2.clone();
|
let response_session_id = unified_str2.clone();
|
||||||
let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope(
|
let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope(
|
||||||
@ -2405,7 +2556,7 @@ fn spawn_agent_worker(
|
|||||||
channel: chan2.clone(),
|
channel: chan2.clone(),
|
||||||
chat_id: cid2.clone(),
|
chat_id: cid2.clone(),
|
||||||
},
|
},
|
||||||
agent.process(history_out.clone()),
|
agent.process_streaming(history_out.clone(), agent_turn.clone()),
|
||||||
).await;
|
).await;
|
||||||
let result = match process_result {
|
let result = match process_result {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
@ -2435,6 +2586,12 @@ fn spawn_agent_worker(
|
|||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Retry compression failed");
|
tracing::error!(error = %e, "Retry compression failed");
|
||||||
|
fail_turn_with_partial(
|
||||||
|
turn_lifecycle,
|
||||||
|
&session2,
|
||||||
|
format!("context overflow handling failed: {e}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let err_outbound = OutboundMessage {
|
let err_outbound = OutboundMessage {
|
||||||
channel: chan2,
|
channel: chan2,
|
||||||
chat_id: cid2,
|
chat_id: cid2,
|
||||||
@ -2459,6 +2616,10 @@ fn spawn_agent_worker(
|
|||||||
session_id = %guard.id,
|
session_id = %guard.id,
|
||||||
"Session changed while retry-compressing after context overflow"
|
"Session changed while retry-compressing after context overflow"
|
||||||
);
|
);
|
||||||
|
turn_lifecycle.cancel(Some(
|
||||||
|
"session changed during context overflow recovery"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
guard.compressor.set_context_window(new_window);
|
guard.compressor.set_context_window(new_window);
|
||||||
@ -2490,13 +2651,22 @@ fn spawn_agent_worker(
|
|||||||
retry
|
retry
|
||||||
};
|
};
|
||||||
|
|
||||||
match agent.process(retry_history).await {
|
match agent
|
||||||
|
.process_streaming(retry_history, agent_turn.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
error = %e,
|
error = %e,
|
||||||
"Agent retry after overflow failed"
|
"Agent retry after overflow failed"
|
||||||
);
|
);
|
||||||
|
fail_turn_with_partial(
|
||||||
|
turn_lifecycle,
|
||||||
|
&session2,
|
||||||
|
e.to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let err_outbound = OutboundMessage {
|
let err_outbound = OutboundMessage {
|
||||||
channel: chan2,
|
channel: chan2,
|
||||||
chat_id: cid2,
|
chat_id: cid2,
|
||||||
@ -2513,6 +2683,12 @@ fn spawn_agent_worker(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "Agent processing error");
|
tracing::error!(error = %e, "Agent processing error");
|
||||||
|
fail_turn_with_partial(
|
||||||
|
turn_lifecycle,
|
||||||
|
&session2,
|
||||||
|
e.to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let err_outbound = OutboundMessage {
|
let err_outbound = OutboundMessage {
|
||||||
channel: chan2,
|
channel: chan2,
|
||||||
chat_id: cid2,
|
chat_id: cid2,
|
||||||
@ -2529,16 +2705,36 @@ fn spawn_agent_worker(
|
|||||||
|
|
||||||
let response_content = result.final_response.content;
|
let response_content = result.final_response.content;
|
||||||
let total_tokens = result.total_tokens;
|
let total_tokens = result.total_tokens;
|
||||||
let response =
|
let usage = result.usage;
|
||||||
if let Err(e) = append_persisted_messages(&session2, result.emitted_messages).await {
|
{
|
||||||
tracing::error!(error = %e, "Failed to atomically persist agent turn");
|
let guard = session2.lock().await;
|
||||||
None
|
if guard.worker_generation != worker_gen
|
||||||
} else {
|
|| guard.state_version != base_version
|
||||||
|
{
|
||||||
|
turn_lifecycle.cancel(Some(
|
||||||
|
"session changed before turn commit".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let response = match finalize_turn_after_persistence(
|
||||||
|
turn_lifecycle,
|
||||||
|
usage,
|
||||||
|
append_persisted_messages(&session2, result.emitted_messages),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
let mut guard = session2.lock().await;
|
let mut guard = session2.lock().await;
|
||||||
let sent_count = guard.messages.len();
|
let sent_count = guard.messages.len();
|
||||||
guard.compressor.set_last_api_info(sent_count, total_tokens);
|
guard.compressor.set_last_api_info(sent_count, total_tokens);
|
||||||
Some(response_content)
|
Some(response_content)
|
||||||
};
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to atomically persist agent turn");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let Some(response) = response else {
|
let Some(response) = response else {
|
||||||
let err_outbound = OutboundMessage {
|
let err_outbound = OutboundMessage {
|
||||||
@ -2575,11 +2771,39 @@ fn spawn_agent_worker(
|
|||||||
() = process_future => {}
|
() = process_future => {}
|
||||||
_ = cancel_rx => {
|
_ = cancel_rx => {
|
||||||
// cancelled — current_cancel already taken by /stop
|
// cancelled — current_cancel already taken by /stop
|
||||||
|
let snapshot = turn_controller.snapshot();
|
||||||
|
if let Some(partial) = partial_assistant_message(
|
||||||
|
&snapshot,
|
||||||
|
CompletionStatus::Cancelled,
|
||||||
|
) {
|
||||||
|
turn_controller.begin_finalizing();
|
||||||
|
match append_persisted_messages(&session, vec![partial]).await {
|
||||||
|
Ok(()) => {
|
||||||
|
turn_controller.cancel(Some("stopped by user".to_string()));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(error = %error, "Failed to persist cancelled partial turn");
|
||||||
|
turn_controller.fail(format!(
|
||||||
|
"failed to persist cancelled turn: {error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
turn_controller.cancel(Some("stopped by user".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
let mut guard = session.lock().await;
|
let mut guard = session.lock().await;
|
||||||
|
if guard
|
||||||
|
.active_turn_emitter
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|active| active.turn_id == active_turn_id)
|
||||||
|
&& let Some(active) = guard.active_turn_emitter.take()
|
||||||
|
{
|
||||||
|
active.emitter.deactivate();
|
||||||
|
}
|
||||||
if guard.worker_generation == worker_gen {
|
if guard.worker_generation == worker_gen {
|
||||||
guard.current_cancel = None;
|
guard.current_cancel = None;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -589,4 +589,18 @@ mod tests {
|
|||||||
assert_eq!(snapshot.error.as_deref(), Some("provider disconnected"));
|
assert_eq!(snapshot.error.as_deref(), Some("provider disconnected"));
|
||||||
assert_eq!(snapshot.phase, TurnPhase::Finalizing);
|
assert_eq!(snapshot.phase, TurnPhase::Finalizing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deactivated_emitter_drops_events_before_stale_worker_reduction() {
|
||||||
|
let (controller, emitter, _) = start();
|
||||||
|
emitter.deactivate();
|
||||||
|
emitter
|
||||||
|
.emit(TurnEvent::TextDelta {
|
||||||
|
iteration: 0,
|
||||||
|
delta: "late".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(controller.snapshot().revision, 0);
|
||||||
|
assert!(controller.snapshot().blocks.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user