feat: 子代理 todo 列表支持 — 进入子代理视图时显示子代理的待办

- SubAgentEmitter 添加 todo_write 持久化(照搬 BusToolCallEmitter 模式)
- DefaultSubAgentRuntime 加 store 字段,透传给 emitter
- Command::ListTodos 加 task_id 参数
- list_todos handler: 当 task_id 存在时,scope_key = sub:{parent}:{task_id}
- 前端: 子代理视图下自动带 task_id 请求子代理的 todo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
oudecheng 2026-06-12 18:01:57 +08:00
parent eef0d24dcd
commit 24bbd5f8c9
7 changed files with 104 additions and 21 deletions

View File

@ -20,7 +20,7 @@ impl ListTodosCommandHandler {
#[async_trait] #[async_trait]
impl CommandHandler for ListTodosCommandHandler { impl CommandHandler for ListTodosCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool { fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListTodos) matches!(cmd, Command::ListTodos { .. })
} }
fn metadata(&self) -> Option<CommandMetadata> { fn metadata(&self) -> Option<CommandMetadata> {
@ -33,12 +33,20 @@ impl CommandHandler for ListTodosCommandHandler {
async fn handle( async fn handle(
&self, &self,
_cmd: Command, cmd: Command,
ctx: CommandContext, ctx: CommandContext,
) -> Result<CommandResponse, CommandError> { ) -> Result<CommandResponse, CommandError> {
// scope_key = topic_id.unwrap_or(session_id) let task_id = match cmd {
let scope_key = ctx Command::ListTodos { task_id } => task_id,
.topic_id _ => None,
};
// 子代理scope_key = sub:{parent_session_id}:{task_id}
// 主代理scope_key = topic_id.unwrap_or(session_id)
let scope_key = if let (Some(tid), Some(parent_sid)) = (task_id.as_deref(), ctx.session_id.as_deref()) {
format!("sub:{}:{}", parent_sid, tid)
} else {
ctx.topic_id
.as_deref() .as_deref()
.filter(|t| !t.is_empty()) .filter(|t| !t.is_empty())
.or(ctx.session_id.as_deref()) .or(ctx.session_id.as_deref())
@ -47,11 +55,13 @@ impl CommandHandler for ListTodosCommandHandler {
"MISSING_CONTEXT", "MISSING_CONTEXT",
"Cannot list todos: no session_id or topic_id in command context", "Cannot list todos: no session_id or topic_id in command context",
) )
})?; })?
.to_string()
};
let records = self let records = self
.store .store
.list_todos(scope_key) .list_todos(&scope_key)
.map_err(|e| CommandError::new("LIST_TODOS_ERROR", e.to_string()))?; .map_err(|e| CommandError::new("LIST_TODOS_ERROR", e.to_string()))?;
tracing::info!( tracing::info!(
@ -77,6 +87,6 @@ impl CommandHandler for ListTodosCommandHandler {
Ok(CommandResponse::success(ctx.request_id) Ok(CommandResponse::success(ctx.request_id)
.with_metadata("todos", &todos_json) .with_metadata("todos", &todos_json)
.with_metadata("todos_scope_key", scope_key)) .with_metadata("todos_scope_key", &scope_key))
} }
} }

View File

@ -74,7 +74,11 @@ pub enum Command {
/// 列出所有技能 /// 列出所有技能
ListSkills, ListSkills,
/// 列出当前 Todo 列表 /// 列出当前 Todo 列表
ListTodos, ListTodos {
/// 子代理的 task_id进入子代理视图时传入
#[serde(default)]
task_id: Option<String>,
},
} }
impl Command { impl Command {
@ -102,7 +106,7 @@ impl Command {
Command::UpdateMemory { .. } => "update_memory", Command::UpdateMemory { .. } => "update_memory",
Command::DeleteMemory { .. } => "delete_memory", Command::DeleteMemory { .. } => "delete_memory",
Command::ListSkills => "list_skills", Command::ListSkills => "list_skills",
Command::ListTodos => "list_todos", Command::ListTodos { .. } => "list_todos",
} }
} }
} }

View File

@ -201,6 +201,7 @@ pub(crate) fn build_session_manager_with_sender(
provider_config.clone(), provider_config.clone(),
catalog, catalog,
bus.clone(), bus.clone(),
store.clone(),
)); ));
(factory.with_subagent_runtime(subagent_runtime), task_repository) (factory.with_subagent_runtime(subagent_runtime), task_repository)

View File

@ -12,7 +12,7 @@ use crate::bus::ChatMessage;
use crate::bus::message::{OutboundMessage, OutboundEventKind}; use crate::bus::message::{OutboundMessage, OutboundEventKind};
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::config::{LLMProviderConfig, SubagentsConfig}; use crate::config::{LLMProviderConfig, SubagentsConfig};
use crate::storage::ConversationRepository; use crate::storage::{ConversationRepository, SessionStore};
use crate::tools::{ToolContext, ToolRegistry}; use crate::tools::{ToolContext, ToolRegistry};
use super::error::TaskError; use super::error::TaskError;
@ -105,6 +105,8 @@ struct SubAgentEmitter {
channel_name: String, channel_name: String,
chat_id: String, chat_id: String,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
store: Arc<SessionStore>,
sub_session_id: String,
} }
#[async_trait] #[async_trait]
@ -151,6 +153,57 @@ impl EmittedMessageHandler for SubAgentEmitter {
); );
} }
} }
// 拦截 todo_write 结果:持久化到 SQLite子代理用 session_id 作为 scope_key
if message.tool_name.as_deref() == Some("todo_write") {
self.persist_todo_write_result(&message);
}
}
}
impl SubAgentEmitter {
fn persist_todo_write_result(&self, message: &ChatMessage) {
let parsed: serde_json::Value = match serde_json::from_str(&message.content) {
Ok(v) => v,
Err(_) => return,
};
let Some(todos_array) = parsed.get("current_todos").and_then(|v| v.as_array()) else {
return;
};
let scope_key = &self.sub_session_id;
let records: Vec<crate::storage::TodoRecord> = todos_array
.iter()
.filter_map(|item| {
Some(crate::storage::TodoRecord {
id: item.get("id")?.as_str()?.to_string(),
scope_key: scope_key.clone(),
session_id: scope_key.clone(),
topic_id: None,
content: item.get("content")?.as_str()?.to_string(),
status: item.get("status")?.as_str()?.to_string(),
priority: item.get("priority")?.as_str()?.to_string(),
created_at: item.get("created_at")?.as_i64()?,
updated_at: item.get("updated_at")?.as_i64()?,
})
})
.collect();
if records.is_empty() {
return;
}
tracing::info!(
scope_key = %scope_key,
todo_count = records.len(),
"SubAgentEmitter: persisting todo_write result"
);
if let Err(e) = self.store.replace_todos(scope_key, &records) {
tracing::warn!(error = %e, %scope_key, "Failed to persist sub-agent todo list");
}
} }
} }
@ -173,6 +226,7 @@ pub struct DefaultSubAgentRuntime {
/// 子代理定义目录(内置 + 自定义) /// 子代理定义目录(内置 + 自定义)
catalog: Arc<SubagentCatalog>, catalog: Arc<SubagentCatalog>,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>,
} }
impl DefaultSubAgentRuntime { impl DefaultSubAgentRuntime {
@ -184,6 +238,7 @@ impl DefaultSubAgentRuntime {
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
catalog: Arc<SubagentCatalog>, catalog: Arc<SubagentCatalog>,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>,
) -> Self { ) -> Self {
Self { Self {
config, config,
@ -193,6 +248,7 @@ impl DefaultSubAgentRuntime {
provider_config, provider_config,
catalog, catalog,
bus, bus,
store,
} }
} }
@ -258,6 +314,8 @@ impl DefaultSubAgentRuntime {
channel_name: session.parent_channel_name.clone(), channel_name: session.parent_channel_name.clone(),
chat_id: session.parent_chat_id.clone(), chat_id: session.parent_chat_id.clone(),
metadata, metadata,
store: self.store.clone(),
sub_session_id: session.session_id.clone(),
}, },
self.conversation_repository.clone(), self.conversation_repository.clone(),
session.session_id.clone(), session.session_id.clone(),

View File

@ -45,6 +45,7 @@ function App() {
requestSkillList, requestSkillList,
todos, todos,
requestTodoList, requestTodoList,
requestSubAgentTodoList,
// 定时任务 // 定时任务
schedulerJobs, schedulerJobs,
sidebarTab, sidebarTab,
@ -342,10 +343,12 @@ function App() {
const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}` const key = `${selectedTopic ?? ''}|${subAgentView?.taskId ?? ''}`
if (key === prevTodoTriggerRef.current) return if (key === prevTodoTriggerRef.current) return
prevTodoTriggerRef.current = key prevTodoTriggerRef.current = key
const todoCmd = requestTodoList() const todoCmd = subAgentView?.taskId
? requestSubAgentTodoList(subAgentView.taskId)
: requestTodoList()
handleCommand(todoCmd) handleCommand(todoCmd)
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) }) sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) })
}, [status, selectedTopic, subAgentView, handleCommand, sendMessage, requestTodoList]) }, [status, selectedTopic, subAgentView, handleCommand, sendMessage, requestTodoList, requestSubAgentTodoList])
const handleRefreshMemories = useCallback(() => { const handleRefreshMemories = useCallback(() => {
const cmd = requestMemoryList() const cmd = requestMemoryList()

View File

@ -97,6 +97,7 @@ interface UseChatReturn {
// Todo 状态 // Todo 状态
todos: TodoItemSummary[] todos: TodoItemSummary[]
requestTodoList: () => Command requestTodoList: () => Command
requestSubAgentTodoList: (subTaskId: string) => Command
// 定时任务状态 // 定时任务状态
schedulerJobs: SchedulerJobSummary[] schedulerJobs: SchedulerJobSummary[]
@ -766,6 +767,10 @@ export function useChat(): UseChatReturn {
return { type: 'list_todos' } return { type: 'list_todos' }
}, []) }, [])
const requestSubAgentTodoList = useCallback((subTaskId: string): Command => {
return { type: 'list_todos', task_id: subTaskId }
}, [])
// 定时任务方法 // 定时任务方法
const requestSchedulerJobList = useCallback((): Command => { const requestSchedulerJobList = useCallback((): Command => {
return { type: 'list_scheduler_jobs' } return { type: 'list_scheduler_jobs' }
@ -856,6 +861,7 @@ export function useChat(): UseChatReturn {
requestSkillList, requestSkillList,
todos, todos,
requestTodoList, requestTodoList,
requestSubAgentTodoList,
schedulerJobs, schedulerJobs,
sidebarTab, sidebarTab,
setSidebarTab, setSidebarTab,

View File

@ -390,6 +390,7 @@ export interface ListSkillsCommand {
export interface ListTodosCommand { export interface ListTodosCommand {
type: 'list_todos' type: 'list_todos'
task_id?: string
} }
export type Command = export type Command =