Compare commits
No commits in common. "95cf152ab9e271b58b172b57275e65a24a65c9b7" and "d802534abe85733f341904f75a6a7ef232189ce5" have entirely different histories.
95cf152ab9
...
d802534abe
@ -1416,14 +1416,7 @@ impl AgentLoop {
|
||||
};
|
||||
|
||||
match tool
|
||||
.execute_with_context(
|
||||
&{
|
||||
let mut ctx = self.tool_context.clone();
|
||||
ctx.tool_call_id = Some(tool_call.id.clone());
|
||||
ctx
|
||||
},
|
||||
normalized_arguments.clone(),
|
||||
)
|
||||
.execute_with_context(&self.tool_context, normalized_arguments.clone())
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
|
||||
@ -408,28 +408,13 @@ Be concise, aim for {} characters or less.
|
||||
.summarize_segment(&summary_source, provider_config)
|
||||
.await?;
|
||||
|
||||
// Sanitize preserved messages: the boundary between the summarized
|
||||
// and preserved sections can split an assistant tool_calls message
|
||||
// from its tool result messages, creating orphaned sequences that
|
||||
// would cause API 400 errors.
|
||||
let mut preserved_messages = history[preserved_turn_start..].to_vec();
|
||||
let removed =
|
||||
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut preserved_messages);
|
||||
if removed > 0 {
|
||||
tracing::warn!(
|
||||
removed_count = removed,
|
||||
preserved_turn_start,
|
||||
"Compaction plan: removed incomplete tool call sequences from preserved messages"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Some(HistoryCompactionPlan {
|
||||
preserved_system_messages,
|
||||
summary_message: ChatMessage::system_with_context(
|
||||
format!("[Compressed History]\n\n{}", summary),
|
||||
Some(SYSTEM_CONTEXT_HISTORY_COMPACTION.to_string()),
|
||||
),
|
||||
preserved_messages,
|
||||
preserved_messages: history[preserved_turn_start..].to_vec(),
|
||||
compressed_turns: turn_ranges.len() - self.config.retain_last_user_turns,
|
||||
preserved_turns: self.config.retain_last_user_turns,
|
||||
}))
|
||||
@ -460,7 +445,7 @@ Be concise, aim for {} characters or less.
|
||||
"Starting context compression"
|
||||
);
|
||||
|
||||
let mut current_history = match self
|
||||
let current_history = match self
|
||||
.build_compaction_plan(&history, provider_config)
|
||||
.await?
|
||||
{
|
||||
@ -476,21 +461,6 @@ Be concise, aim for {} characters or less.
|
||||
None => history,
|
||||
};
|
||||
|
||||
// Post-compression sanitization: compression can split an assistant
|
||||
// tool_calls message from its tool result messages at the boundary
|
||||
// between the summarized and preserved sections. This pass removes
|
||||
// any orphaned tool_calls or tool results to ensure the message
|
||||
// sequence is always valid for the LLM API.
|
||||
let removed =
|
||||
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut current_history);
|
||||
if removed > 0 {
|
||||
tracing::warn!(
|
||||
removed_count = removed,
|
||||
remaining_messages = current_history.len(),
|
||||
"Post-compression sanitization removed incomplete tool call sequences"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
final_tokens = estimate_tokens(¤t_history),
|
||||
final_msg_count = current_history.len(),
|
||||
|
||||
@ -76,7 +76,6 @@ impl CommandHandler for ListTodosCommandHandler {
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
status: r.status,
|
||||
created_by_message_id: r.created_by_message_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@ -82,7 +82,6 @@ impl AgentFactory {
|
||||
nesting_depth: 0,
|
||||
task_id: None,
|
||||
parent_task_id: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
// 如果有取消信号接收端,注入 Agent
|
||||
if let Some(token) = request.cancel_token {
|
||||
|
||||
@ -200,7 +200,6 @@ impl BusToolCallEmitter {
|
||||
priority: "medium".to_string(),
|
||||
created_at: now + idx as i64,
|
||||
updated_at: now,
|
||||
created_by_message_id: Some(message.id.clone()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -492,7 +492,7 @@ async fn handle_inbound(
|
||||
if let Some(task_session_id) = response.metadata.get("task_session_id") {
|
||||
// 提前提取 task_id,用于给历史消息打标记
|
||||
let task_id = response.metadata.get("task_id").cloned().unwrap_or_default();
|
||||
if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone()), Some(&state.task_repository)).await {
|
||||
if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone())).await {
|
||||
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
|
||||
}
|
||||
|
||||
@ -570,7 +570,7 @@ async fn handle_inbound(
|
||||
&load_chat_channel,
|
||||
load_chat_id,
|
||||
);
|
||||
if let Err(e) = send_task_messages(&store, &session_id, sender, None, None).await {
|
||||
if let Err(e) = send_task_messages(&store, &session_id, sender, None).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
channel = %load_chat_channel,
|
||||
@ -669,13 +669,9 @@ async fn send_topic_history(
|
||||
|
||||
for task in running_tasks {
|
||||
if task.state == TaskSessionState::Running {
|
||||
// 判断是否为孙智能体:parent_session_id 以 "sub:" 开头表示父会话是子智能体
|
||||
let parent_task_id = extract_parent_task_id(&task);
|
||||
|
||||
tracing::info!(
|
||||
task_id = %task.id,
|
||||
description = %task.description,
|
||||
parent_task_id = ?parent_task_id,
|
||||
"Re-sending TaskStarted for running task after topic history load"
|
||||
);
|
||||
let _ = sender
|
||||
@ -684,8 +680,7 @@ async fn send_topic_history(
|
||||
description: task.description.clone(),
|
||||
subagent_type: task.subagent_type.clone(),
|
||||
topic_id: Some(topic_id.to_string()),
|
||||
parent_task_id,
|
||||
tool_call_id: None,
|
||||
parent_task_id: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@ -700,7 +695,6 @@ async fn send_task_messages(
|
||||
session_id: &str,
|
||||
sender: &mpsc::Sender<WsOutbound>,
|
||||
subagent_task_id: Option<String>,
|
||||
task_repository: Option<&Arc<dyn TaskRepository>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let messages = store.load_messages(session_id)?;
|
||||
|
||||
@ -718,37 +712,6 @@ async fn send_task_messages(
|
||||
}
|
||||
}
|
||||
|
||||
// 补发子任务(孙智能体)的 TaskStarted 事件
|
||||
// 解决重新进入子智能体视图后 navigateToTaskId 丢失的问题
|
||||
if let (Some(repo), Some(parent_task_id)) = (task_repository, &subagent_task_id) {
|
||||
match repo.list_tasks_for_session(session_id).await {
|
||||
Ok(child_tasks) => {
|
||||
for child in child_tasks {
|
||||
if child.state == TaskSessionState::Running {
|
||||
tracing::info!(
|
||||
child_task_id = %child.id,
|
||||
parent_task_id = %parent_task_id,
|
||||
"Re-sending TaskStarted for child task after sub-agent view re-enter"
|
||||
);
|
||||
let _ = sender
|
||||
.send(WsOutbound::TaskStarted {
|
||||
task_id: child.id.clone(),
|
||||
description: child.description.clone(),
|
||||
subagent_type: child.subagent_type.clone(),
|
||||
topic_id: child.parent_topic_id.clone(),
|
||||
parent_task_id: Some(parent_task_id.clone()),
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, session_id = %session_id, "Failed to list child tasks for resend");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -773,20 +736,6 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 TaskSession 中提取父任务 ID(仅孙智能体有值)。
|
||||
/// 孙智能体的 parent_session_id 格式为 "sub:{grandparent_session}:task:{parent_task_uuid}",
|
||||
/// 从中提取 "task:{parent_task_uuid}" 作为 parent_task_id。
|
||||
fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option<String> {
|
||||
let parent = &task.parent_session_id;
|
||||
// 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}")
|
||||
if parent.starts_with("sub:") {
|
||||
if let Some(pos) = parent.find(":task:") {
|
||||
return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 将 ChatMessage 转换为 WsOutbound 列表
|
||||
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound> {
|
||||
use crate::bus::message::ToolMessageState;
|
||||
|
||||
@ -88,7 +88,6 @@ pub struct TodoItemSummary {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub status: String,
|
||||
pub created_by_message_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@ -215,8 +214,6 @@ pub enum WsOutbound {
|
||||
topic_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
parent_task_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
},
|
||||
#[serde(rename = "session_established")]
|
||||
SessionEstablished { session_id: String },
|
||||
|
||||
@ -174,7 +174,6 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
|
||||
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
|
||||
topic_id: message.metadata.get("topic_id").cloned(),
|
||||
parent_task_id: message.metadata.get("parent_task_id").cloned(),
|
||||
tool_call_id: message.metadata.get("tool_call_id").cloned(),
|
||||
}],
|
||||
OutboundEventKind::StreamDelta => vec![WsOutbound::StreamDelta {
|
||||
id: message.tool_call_id.clone().unwrap_or_default(),
|
||||
|
||||
@ -632,68 +632,41 @@ impl OpenAIProvider {
|
||||
fn build_request_body(&self, request: &ChatCompletionRequest) -> Value {
|
||||
let supports_images = self.supports_images();
|
||||
|
||||
// --- Final defense: position-aware tool_call / tool result validation ---
|
||||
//
|
||||
// Scan right-to-left (matching sanitize_incomplete_tool_call_sequences)
|
||||
// so that only tool results appearing AFTER an assistant message count
|
||||
// as "resolved". A simple global scan would incorrectly accept a tool
|
||||
// result that precedes its parent assistant (e.g. after compaction
|
||||
// boundary splits), leading to API 400 errors:
|
||||
// "insufficient tool messages following tool_calls message".
|
||||
// --- Final defense: validate tool_call / tool result pairing ---
|
||||
// Collect all tool_call_ids that have a corresponding tool result message.
|
||||
// Any assistant tool_call NOT in this set is orphaned and must be stripped
|
||||
// to avoid API 400 errors ("insufficient tool messages following tool_calls").
|
||||
let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
let mut skip_assistant_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
|
||||
for (i, m) in request.messages.iter().enumerate().rev() {
|
||||
for m in &request.messages {
|
||||
if m.role == "tool" {
|
||||
if let Some(ref tc_id) = m.tool_call_id {
|
||||
resolved_tool_ids.insert(tc_id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the set of assistant tool_call_ids that are fully valid
|
||||
// (ALL tool_calls in the message have corresponding results).
|
||||
// If an assistant has partial or no valid tool_calls, we strip the
|
||||
// tool_calls field and serialize it as a plain assistant message.
|
||||
let mut valid_tool_call_parent_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for m in &request.messages {
|
||||
if m.role == "assistant" {
|
||||
if let Some(ref calls) = m.tool_calls {
|
||||
if !calls.is_empty() {
|
||||
let all_resolved =
|
||||
calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
|
||||
if all_resolved {
|
||||
if !calls.is_empty()
|
||||
&& calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str()))
|
||||
{
|
||||
for tc in calls {
|
||||
with_parent.insert(tc.id.as_str());
|
||||
}
|
||||
} else {
|
||||
skip_assistant_indices.insert(i);
|
||||
valid_tool_call_parent_ids.insert(tc.id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
|
||||
// whose parent assistant has ALL results after it)
|
||||
let valid_tool_call_parent_ids = &with_parent;
|
||||
|
||||
let mut body = json!({
|
||||
"model": self.model_id,
|
||||
"messages": request.messages.iter().enumerate().filter_map(|(i, m)| {
|
||||
// Skip assistant messages identified as having incomplete tool_calls
|
||||
// (position-aware scan found missing tool results after this assistant).
|
||||
if skip_assistant_indices.contains(&i) {
|
||||
tracing::warn!(
|
||||
message_index = i,
|
||||
"build_request_body: skipping assistant with incomplete tool call sequence \
|
||||
(tool results missing after this position)"
|
||||
);
|
||||
// Serialize as plain assistant message (strip tool_calls)
|
||||
let mut message = json!({
|
||||
"role": m.role,
|
||||
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
|
||||
});
|
||||
if let Some(reasoning_content) = &m.reasoning_content {
|
||||
message["reasoning_content"] = Value::String(reasoning_content.clone());
|
||||
}
|
||||
return Some(message);
|
||||
}
|
||||
|
||||
if m.role == "tool" {
|
||||
// Skip orphaned tool results (no matching assistant tool_call)
|
||||
let is_orphaned = match &m.tool_call_id {
|
||||
|
||||
@ -1517,7 +1517,7 @@ impl SessionStore {
|
||||
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
|
||||
let conn = self.pool.get()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id
|
||||
"SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at
|
||||
FROM todos
|
||||
WHERE scope_key = ?1
|
||||
ORDER BY created_at ASC",
|
||||
@ -1534,7 +1534,6 @@ impl SessionStore {
|
||||
priority: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
created_by_message_id: row.get(9)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@ -1914,7 +1913,6 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_by_message_id TEXT,
|
||||
PRIMARY KEY (id, scope_key)
|
||||
);
|
||||
|
||||
@ -1955,7 +1953,6 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_by_message_id TEXT,
|
||||
PRIMARY KEY (id, scope_key)
|
||||
);
|
||||
|
||||
@ -1977,17 +1974,6 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
|
||||
tracing::info!("Todos table migration complete");
|
||||
}
|
||||
|
||||
// Column migration: add created_by_message_id if it doesn't exist
|
||||
let has_column = has_column(&conn, "todos", "created_by_message_id")?;
|
||||
if !has_column {
|
||||
tracing::info!("Adding created_by_message_id column to todos table");
|
||||
conn.execute(
|
||||
"ALTER TABLE todos ADD COLUMN created_by_message_id TEXT",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("Todos table column migration complete");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -46,7 +46,6 @@ pub struct TodoRecord {
|
||||
pub priority: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub created_by_message_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@ -73,20 +73,6 @@ impl ToolRegistry {
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 创建一个排除指定工具的新 registry 副本
|
||||
pub fn without(&self, exclude: &[&str]) -> Self {
|
||||
let exclude_set: std::collections::HashSet<&str> = exclude.iter().copied().collect();
|
||||
let tools = self.tools.read().expect("ToolRegistry lock poisoned");
|
||||
let filtered: HashMap<String, Arc<dyn ToolTrait>> = tools
|
||||
.iter()
|
||||
.filter(|(name, _)| !exclude_set.contains(name.as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
let new_registry = ToolRegistry::new();
|
||||
*new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered;
|
||||
new_registry
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolRegistry {
|
||||
|
||||
@ -19,7 +19,6 @@ use crate::tools::{ToolContext, ToolRegistry};
|
||||
use super::error::TaskError;
|
||||
use super::prompt::{extract_summary, SubagentPromptBuilder};
|
||||
use super::repository::TaskRepository;
|
||||
use super::tool::TaskTool;
|
||||
use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult};
|
||||
|
||||
/// 子代理运行时配置
|
||||
@ -235,7 +234,6 @@ impl SubAgentEmitter {
|
||||
priority: "medium".to_string(),
|
||||
created_at: now + idx as i64,
|
||||
updated_at: now,
|
||||
created_by_message_id: Some(message.id.clone()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@ -334,17 +332,9 @@ impl DefaultSubAgentRuntime {
|
||||
) -> Result<AgentLoop, TaskError> {
|
||||
let prompt_provider = Arc::new(StaticSystemPromptProvider::new(system_prompt));
|
||||
|
||||
// 孙智能体(depth >= 2)不注册 task 工具,防止无限嵌套
|
||||
let child_depth = parent_nesting_depth + 1;
|
||||
let tools = if child_depth >= 2 {
|
||||
Arc::new(self.subagent_tools.without(&[TaskTool::TOOL_NAME]))
|
||||
} else {
|
||||
self.subagent_tools.clone()
|
||||
};
|
||||
|
||||
AgentLoop::with_tools_and_system_prompt_provider(
|
||||
AgentRuntimeConfig::from(self.provider_config.clone()),
|
||||
tools,
|
||||
self.subagent_tools.clone(),
|
||||
prompt_provider,
|
||||
None, // 子代理不需要 skill provider
|
||||
)
|
||||
@ -361,7 +351,6 @@ impl DefaultSubAgentRuntime {
|
||||
nesting_depth: parent_nesting_depth + 1,
|
||||
task_id: Some(session.id.clone()),
|
||||
parent_task_id,
|
||||
tool_call_id: None,
|
||||
});
|
||||
|
||||
// 如果有 MessageBus,附加实时广播 emitter
|
||||
@ -554,11 +543,6 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
|
||||
metadata.insert("parent_task_id".to_string(), ptid.clone());
|
||||
}
|
||||
|
||||
// 传递 tool_call_id,前端据此精确匹配创建此任务的 tool_call
|
||||
if let Some(ref tcid) = parent_context.tool_call_id {
|
||||
metadata.insert("tool_call_id".to_string(), tcid.clone());
|
||||
}
|
||||
|
||||
let event = OutboundMessage {
|
||||
channel: session.parent_channel_name.clone(),
|
||||
chat_id: session.parent_chat_id.clone(),
|
||||
|
||||
@ -15,8 +15,6 @@ pub struct TaskTool {
|
||||
}
|
||||
|
||||
impl TaskTool {
|
||||
pub const TOOL_NAME: &'static str = "task";
|
||||
|
||||
/// 创建 TaskTool
|
||||
/// - `max_nesting_depth = None`:无深度限制(主 agent)
|
||||
/// - `max_nesting_depth = Some(N)`:允许最多 N 层嵌套(子 agent)
|
||||
@ -31,7 +29,7 @@ impl TaskTool {
|
||||
#[async_trait]
|
||||
impl Tool for TaskTool {
|
||||
fn name(&self) -> &str {
|
||||
Self::TOOL_NAME
|
||||
"task"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
|
||||
@ -118,7 +118,6 @@ impl Tool for TodoReadTool {
|
||||
id: r.id,
|
||||
content: r.content,
|
||||
status: r.status,
|
||||
created_by_message_id: r.created_by_message_id,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -186,7 +185,6 @@ mod tests {
|
||||
nesting_depth: 0,
|
||||
task_id: None,
|
||||
parent_task_id: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,7 +224,6 @@ mod tests {
|
||||
priority: "medium".to_string(),
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
created_by_message_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,7 +238,6 @@ mod tests {
|
||||
id: "a1".to_string(),
|
||||
content: "任务A".to_string(),
|
||||
status: "pending".to_string(),
|
||||
created_by_message_id: None,
|
||||
}],
|
||||
);
|
||||
}
|
||||
@ -317,7 +313,6 @@ mod tests {
|
||||
id: "m1".to_string(),
|
||||
content: "主会话任务".to_string(),
|
||||
status: "pending".to_string(),
|
||||
created_by_message_id: None,
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,7 +45,6 @@ pub(crate) struct TodoItem {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub status: String,
|
||||
pub created_by_message_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具完整返回
|
||||
@ -144,10 +143,7 @@ impl Tool for TodoWriteTool {
|
||||
None => return Ok(error_result("todo_write requires session_id or topic_id in tool context")),
|
||||
};
|
||||
|
||||
// 2. 提取当前消息 ID(用于记录待办的创建来源)
|
||||
let message_id = context.message_id.clone();
|
||||
|
||||
// 3. 解析入参
|
||||
// 2. 解析入参
|
||||
let todos_array = match args.get("todos").and_then(|v| v.as_array()) {
|
||||
Some(arr) => arr,
|
||||
None => return Ok(error_result("Missing required parameter: todos (must be an array)")),
|
||||
@ -223,7 +219,6 @@ impl Tool for TodoWriteTool {
|
||||
id,
|
||||
content,
|
||||
status: new_status.as_str().to_string(),
|
||||
created_by_message_id: message_id.clone(),
|
||||
});
|
||||
} else if merge_mode {
|
||||
// merge 模式:id 不匹配,尝试 content fallback
|
||||
@ -243,7 +238,6 @@ impl Tool for TodoWriteTool {
|
||||
id: old_item.id.clone(),
|
||||
content,
|
||||
status: new_status.as_str().to_string(),
|
||||
created_by_message_id: message_id.clone(),
|
||||
});
|
||||
} else {
|
||||
// 全新项
|
||||
@ -251,7 +245,6 @@ impl Tool for TodoWriteTool {
|
||||
id,
|
||||
content,
|
||||
status: new_status.as_str().to_string(),
|
||||
created_by_message_id: message_id.clone(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@ -260,7 +253,6 @@ impl Tool for TodoWriteTool {
|
||||
id,
|
||||
content,
|
||||
status: new_status.as_str().to_string(),
|
||||
created_by_message_id: message_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -423,7 +415,6 @@ mod tests {
|
||||
nesting_depth: 0,
|
||||
task_id: None,
|
||||
parent_task_id: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,8 +24,6 @@ pub struct ToolContext {
|
||||
pub task_id: Option<String>,
|
||||
/// 父任务 ID(仅子/孙智能体有值,用于构建任务层级)
|
||||
pub parent_task_id: Option<String>,
|
||||
/// 当前工具调用的 ID(由 agent_loop 在执行前注入,用于精确关联 TaskStarted 事件)
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@ -13,7 +13,7 @@ import { ChannelSelector } from './components/Header/ChannelSelector'
|
||||
import { SessionSelector } from './components/Header/SessionSelector'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
import { useChat } from './hooks/useChat'
|
||||
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup, TodoItemSummary } from './types/protocol'
|
||||
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol'
|
||||
|
||||
function getInitialSettings(): GatewaySettings {
|
||||
return getGatewaySettings()
|
||||
@ -54,9 +54,6 @@ function App() {
|
||||
setTodos,
|
||||
requestTodoList,
|
||||
requestSubAgentTodoList,
|
||||
// 高亮消息
|
||||
highlightedMessageId,
|
||||
setHighlightedMessageId,
|
||||
// 定时任务
|
||||
schedulerJobs,
|
||||
sidebarTab,
|
||||
@ -399,17 +396,6 @@ function App() {
|
||||
: requestTodoList()
|
||||
}, [subAgentView, requestTodoList, requestSubAgentTodoList])
|
||||
|
||||
// 点击待办项后滚动到对应消息
|
||||
const handleTodoClick = useCallback((todo: TodoItemSummary) => {
|
||||
// 直接使用后端返回的 created_by_message_id
|
||||
if (todo.created_by_message_id) {
|
||||
setHighlightedMessageId(todo.created_by_message_id)
|
||||
} else {
|
||||
// 如果消息 ID 不存在(旧数据),给出友好提示
|
||||
alert('该待办的完成记录无法定位,可能是历史数据')
|
||||
}
|
||||
}, [setHighlightedMessageId])
|
||||
|
||||
const handleRefreshSchedulerJobs = useCallback(() => {
|
||||
const cmd = requestSchedulerJobList()
|
||||
handleCommand(cmd)
|
||||
@ -708,13 +694,11 @@ function App() {
|
||||
onStop={handleStopExecution}
|
||||
showThinking={showThinking}
|
||||
viewKey={viewKey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
todoPanel={
|
||||
<TodoPanel
|
||||
todos={todos}
|
||||
requestTodoList={refreshTodoList}
|
||||
sendCommand={sendMemoryCommand}
|
||||
onTodoClick={handleTodoClick}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@ -15,8 +15,6 @@ interface ChatContainerProps {
|
||||
todoPanel?: React.ReactNode
|
||||
/** 视图标识,用于保存/恢复滚动位置 */
|
||||
viewKey?: string
|
||||
/** 高亮的消息 ID */
|
||||
highlightedMessageId?: string | null
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
@ -30,12 +28,11 @@ export function ChatContainer({
|
||||
showThinking = true,
|
||||
todoPanel,
|
||||
viewKey,
|
||||
highlightedMessageId,
|
||||
}: ChatContainerProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col relative">
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
|
||||
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} />
|
||||
{todoPanel}
|
||||
</div>
|
||||
<MessageInput
|
||||
|
||||
@ -647,7 +647,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-message-id={message.id} className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
|
||||
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
|
||||
>
|
||||
|
||||
@ -9,11 +9,9 @@ interface MessageListProps {
|
||||
showThinking?: boolean
|
||||
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
|
||||
viewKey?: string
|
||||
/** 高亮的消息 ID,点击待办项后滚动并高亮显示 */
|
||||
highlightedMessageId?: string | null
|
||||
}
|
||||
|
||||
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId }: MessageListProps) {
|
||||
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
@ -119,28 +117,6 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ---- highlight and scroll to todo message ----
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedMessageId) return
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
// 查找目标消息元素
|
||||
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
|
||||
if (!targetElement) return
|
||||
|
||||
// 滚动到目标位置
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
// 添加高亮样式
|
||||
targetElement.classList.add('todo-highlight')
|
||||
setTimeout(() => {
|
||||
targetElement.classList.remove('todo-highlight')
|
||||
}, 2000)
|
||||
}, [highlightedMessageId])
|
||||
|
||||
// ---- empty state ----
|
||||
|
||||
if (messages.length === 0) {
|
||||
|
||||
@ -6,7 +6,6 @@ interface TodoPanelProps {
|
||||
todos: TodoItemSummary[]
|
||||
requestTodoList: () => Command
|
||||
sendCommand: (cmd: Command) => void
|
||||
onTodoClick?: (todo: TodoItemSummary) => void
|
||||
}
|
||||
|
||||
/* ── status config ────────────────────────────────────── */
|
||||
@ -65,7 +64,7 @@ function savePos(pos: { x: number; y: number }) {
|
||||
|
||||
/* ── TodoPanel ────────────────────────────────────────── */
|
||||
|
||||
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
|
||||
export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) {
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false }
|
||||
})
|
||||
@ -250,16 +249,12 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
|
||||
<div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}>
|
||||
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTodoClick?.(item)}
|
||||
className="group/item w-full text-left py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5 cursor-pointer"
|
||||
>
|
||||
<div key={item.id} className="group/item py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5">
|
||||
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0 mt-1.5`} />
|
||||
<span className="text-[13px] leading-relaxed text-[var(--text-primary)]/85 group-hover/item:text-[var(--text-primary)] transition-colors break-words">
|
||||
{item.content}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -106,10 +106,6 @@ interface UseChatReturn {
|
||||
requestTodoList: () => Command
|
||||
requestSubAgentTodoList: (subTaskId: string) => Command
|
||||
|
||||
// 高亮消息 ID(点击待办后滚动到对应消息)
|
||||
highlightedMessageId: string | null
|
||||
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
|
||||
|
||||
// 定时任务状态
|
||||
schedulerJobs: SchedulerJobSummary[]
|
||||
sidebarTab: 'topics' | 'scheduler'
|
||||
@ -160,7 +156,6 @@ export function useChat(): UseChatReturn {
|
||||
const [memories, setMemories] = useState<MemorySummary[]>([])
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||
const [todos, setTodos] = useState<TodoItemSummary[]>([])
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
|
||||
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
||||
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
||||
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
||||
@ -181,10 +176,6 @@ export function useChat(): UseChatReturn {
|
||||
const selectedTopicRef = useRef<string | null>(null)
|
||||
const pendingNewTopicRef = useRef(false)
|
||||
|
||||
// Pending task navigations: tool_call_id -> task_id
|
||||
// Used when task_started arrives before the tool_call is in the sub-agent view
|
||||
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map())
|
||||
|
||||
// Ref to send commands from within handleServerMessage (set by App.tsx)
|
||||
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null)
|
||||
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
|
||||
@ -400,30 +391,14 @@ export function useChat(): UseChatReturn {
|
||||
if (message.type === 'task_started') {
|
||||
const msg = message as TaskStarted
|
||||
if (msg.parent_task_id === currentSubAgentView.taskId) {
|
||||
let matched = false
|
||||
setSubAgentStack((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
const top = prev[prev.length - 1]
|
||||
const updatedMessages = [...top.messages]
|
||||
|
||||
// 优先:按 tool_call_id 精确匹配
|
||||
if (msg.tool_call_id) {
|
||||
const idx = updatedMessages.findIndex(m =>
|
||||
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
|
||||
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
|
||||
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id }
|
||||
matched = true
|
||||
const newStack = [...prev]
|
||||
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
||||
return newStack
|
||||
}
|
||||
}
|
||||
// 回退:backward-search (兼容无 tool_call_id 的旧版本)
|
||||
for (let i = updatedMessages.length - 1; i >= 0; i--) {
|
||||
const m = updatedMessages[i]
|
||||
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
|
||||
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -431,11 +406,6 @@ export function useChat(): UseChatReturn {
|
||||
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
||||
return newStack
|
||||
})
|
||||
if (!matched) {
|
||||
// tool_call 尚未到达,存储 pending navigation 等后续 tool_call 到达时回填
|
||||
const key = msg.tool_call_id || `fallback:${msg.task_id}`
|
||||
pendingTaskNavsRef.current.set(key, msg.task_id)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -446,33 +416,6 @@ export function useChat(): UseChatReturn {
|
||||
const msgSubagentTaskId = getSubagentTaskId(message)
|
||||
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
|
||||
appendToSubAgentViewMessage(message)
|
||||
|
||||
// 检查 pending navigation:当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId
|
||||
if (message.type === 'tool_call') {
|
||||
const tc = message as ToolCall
|
||||
if (tc.tool_name === 'task' && tc.tool_call_id) {
|
||||
const key = tc.tool_call_id
|
||||
const pendingTaskId = pendingTaskNavsRef.current.get(key)
|
||||
if (pendingTaskId) {
|
||||
pendingTaskNavsRef.current.delete(key)
|
||||
setSubAgentStack((prev) => {
|
||||
if (prev.length === 0) return prev
|
||||
const top = prev[prev.length - 1]
|
||||
const updatedMessages = [...top.messages]
|
||||
const idx = updatedMessages.findIndex(m =>
|
||||
m.toolCallId === tc.tool_call_id && m.type === 'tool_call')
|
||||
if (idx >= 0) {
|
||||
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: pendingTaskId }
|
||||
const newStack = [...prev]
|
||||
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
||||
return newStack
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 子代理 todo_write 完成后自动刷新待办列表
|
||||
if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') {
|
||||
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId)
|
||||
@ -516,22 +459,10 @@ export function useChat(): UseChatReturn {
|
||||
|
||||
// 设置 navigateToTaskId,让用户可以点击查看实时进度
|
||||
setMessages((prev) => {
|
||||
console.log('[useChat] task_started searching messages for task tool_call, total messages:', prev.length, 'tool_call_id:', msg.tool_call_id)
|
||||
// 优先:按 tool_call_id 精确匹配
|
||||
if (msg.tool_call_id) {
|
||||
const idx = prev.findIndex(m =>
|
||||
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
|
||||
if (idx >= 0 && !prev[idx].navigateToTaskId) {
|
||||
console.log('[useChat] task_started EXACT MATCH at index', idx, 'task_id:', msg.task_id)
|
||||
const updated = [...prev]
|
||||
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id }
|
||||
return updated
|
||||
}
|
||||
}
|
||||
// 回退:backward-search (兼容无 tool_call_id 的旧版本)
|
||||
console.log('[useChat] task_started searching messages for task tool_call, total messages:', prev.length)
|
||||
for (let i = prev.length - 1; i >= 0; i--) {
|
||||
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
|
||||
console.log('[useChat] task_started BACKWARD MATCH at index', i, 'task_id:', msg.task_id)
|
||||
console.log('[useChat] task_started SET navigateToTaskId at index', i, 'task_id:', msg.task_id)
|
||||
const updated = [...prev]
|
||||
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
|
||||
return updated
|
||||
@ -1106,8 +1037,6 @@ export function useChat(): UseChatReturn {
|
||||
setTodos,
|
||||
requestTodoList,
|
||||
requestSubAgentTodoList,
|
||||
highlightedMessageId,
|
||||
setHighlightedMessageId,
|
||||
schedulerJobs,
|
||||
sidebarTab,
|
||||
setSidebarTab,
|
||||
|
||||
@ -294,25 +294,10 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes todo-highlight-pulse {
|
||||
0%, 100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
50% {
|
||||
background-color: rgba(0, 240, 255, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-todo-card-in { animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
||||
.animate-todo-item-in { animation: todo-item-in 0.2s ease-out forwards; }
|
||||
.animate-todo-ring-pulse { animation: todo-ring-pulse 2s ease-in-out infinite; }
|
||||
|
||||
/* 待办点击高亮效果 */
|
||||
.todo-highlight {
|
||||
animation: todo-highlight-pulse 1s ease-in-out 2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 分组折叠内容展开/收起 */
|
||||
.todo-group-body {
|
||||
overflow: hidden;
|
||||
|
||||
@ -102,7 +102,6 @@ export interface TaskStarted {
|
||||
subagent_type: string
|
||||
topic_id?: string
|
||||
parent_task_id?: string
|
||||
tool_call_id?: string
|
||||
}
|
||||
|
||||
export interface SessionEstablished {
|
||||
@ -210,7 +209,6 @@ export interface TodoItemSummary {
|
||||
priority: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
created_by_message_id?: string
|
||||
}
|
||||
|
||||
export interface TodoList {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user