feat: support exec async sub agent task

This commit is contained in:
arvinxx
2025-12-29 15:58:32 +08:00
parent 6099ac380a
commit dba1acf2b4
28 changed files with 2699 additions and 71 deletions

View File

@@ -60,6 +60,9 @@
"builtins.lobe-gtd.apiName.createPlan": "Create plan",
"builtins.lobe-gtd.apiName.createPlan.result": "Create plan: <goal>{{goal}}</goal>",
"builtins.lobe-gtd.apiName.createTodos": "Create todos",
"builtins.lobe-gtd.apiName.execTask": "Execute task",
"builtins.lobe-gtd.apiName.execTask.result": "Execute: <desc>{{description}}</desc>",
"builtins.lobe-gtd.apiName.execTasks": "Execute tasks",
"builtins.lobe-gtd.apiName.removeTodos": "Delete todos",
"builtins.lobe-gtd.apiName.updatePlan": "Update plan",
"builtins.lobe-gtd.apiName.updatePlan.completed": "Completed",

View File

@@ -60,6 +60,9 @@
"builtins.lobe-gtd.apiName.createPlan": "创建计划",
"builtins.lobe-gtd.apiName.createPlan.result": "创建计划:<goal>{{goal}}</goal>",
"builtins.lobe-gtd.apiName.createTodos": "创建待办",
"builtins.lobe-gtd.apiName.execTask": "执行任务",
"builtins.lobe-gtd.apiName.execTask.result": "执行:<desc>{{description}}</desc>",
"builtins.lobe-gtd.apiName.execTasks": "执行多个任务",
"builtins.lobe-gtd.apiName.removeTodos": "删除待办",
"builtins.lobe-gtd.apiName.updatePlan": "更新计划",
"builtins.lobe-gtd.apiName.updatePlan.completed": "已完成",

View File

@@ -13,6 +13,8 @@ import {
GeneralAgentCallingToolInstructionPayload,
GeneralAgentConfig,
HumanAbortPayload,
TaskResultPayload,
TasksBatchResultPayload,
} from '../types';
/**
@@ -315,7 +317,37 @@ export class GeneralChatAgent implements Agent {
}
case 'tool_result': {
const { parentMessageId } = context.payload as GeneralAgentCallToolResultPayload;
const { data, parentMessageId, stop } =
context.payload as GeneralAgentCallToolResultPayload;
// Check if this is a GTD async task request (only execTask/execTasks are passed here with stop=true)
if (stop && data?.state) {
const stateType = data.state.type;
// GTD async task (single)
if (stateType === 'execTask') {
const { parentMessageId: execParentId, task } = data.state as {
parentMessageId: string;
task: any;
};
return {
payload: { parentMessageId: execParentId, task },
type: 'exec_task',
};
}
// GTD async tasks (multiple)
if (stateType === 'execTasks') {
const { parentMessageId: execParentId, tasks } = data.state as {
parentMessageId: string;
tasks: any[];
};
return {
payload: { parentMessageId: execParentId, tasks },
type: 'exec_tasks',
};
}
}
// Check if there are still pending tool messages waiting for approval
const pendingToolMessages = state.messages.filter(
@@ -380,6 +412,40 @@ export class GeneralChatAgent implements Agent {
};
}
case 'task_result': {
// Single async task completed, continue to call LLM with result
const { parentMessageId } = context.payload as TaskResultPayload;
// Continue to call LLM with updated messages (task message is already in state)
return {
payload: {
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload,
type: 'call_llm',
};
}
case 'tasks_batch_result': {
// Async tasks batch completed, continue to call LLM with results
const { parentMessageId } = context.payload as TasksBatchResultPayload;
// Continue to call LLM with updated messages (task messages are already in state)
return {
payload: {
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload,
type: 'call_llm',
};
}
case 'human_abort': {
// User aborted the operation
const { hasToolsCalling, parentMessageId, toolsCalling, reason } =

View File

@@ -893,6 +893,164 @@ describe('GeneralChatAgent', () => {
});
});
describe('task_result phase (single task)', () => {
it('should return call_llm when task completed', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState({
messages: [
{ role: 'user', content: 'Execute task' },
{ role: 'assistant', content: '' },
{ role: 'task', content: 'Task result', metadata: { instruction: 'Do task' } },
] as any,
});
const context = createMockContext('task_result', {
parentMessageId: 'task-parent-msg',
result: { success: true, taskMessageId: 'task-1', threadId: 'thread-1', result: 'Task result' },
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'call_llm',
payload: {
messages: state.messages,
model: 'gpt-4o-mini',
parentMessageId: 'task-parent-msg',
provider: 'openai',
tools: undefined,
},
});
});
it('should return call_llm even when task failed', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState({
messages: [
{ role: 'user', content: 'Execute task' },
{ role: 'assistant', content: '' },
{ role: 'task', content: 'Task failed: timeout', metadata: { instruction: 'Do task' } },
] as any,
});
const context = createMockContext('task_result', {
parentMessageId: 'task-parent-msg',
result: {
success: false,
taskMessageId: 'task-1',
threadId: 'thread-1',
error: 'Task timeout after 1800000ms',
},
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'call_llm',
payload: {
messages: state.messages,
model: 'gpt-4o-mini',
parentMessageId: 'task-parent-msg',
provider: 'openai',
tools: undefined,
},
});
});
});
describe('tasks_batch_result phase (multiple tasks)', () => {
it('should return call_llm when tasks completed', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState({
messages: [
{ role: 'user', content: 'Execute tasks' },
{ role: 'assistant', content: '' },
{ role: 'task', content: 'Task 1 result', metadata: { instruction: 'Do task 1' } },
{ role: 'task', content: 'Task 2 result', metadata: { instruction: 'Do task 2' } },
] as any,
});
const context = createMockContext('tasks_batch_result', {
parentMessageId: 'task-parent-msg',
results: [
{ success: true, taskMessageId: 'task-1', threadId: 'thread-1', result: 'Task 1 result' },
{ success: true, taskMessageId: 'task-2', threadId: 'thread-2', result: 'Task 2 result' },
],
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'call_llm',
payload: {
messages: state.messages,
model: 'gpt-4o-mini',
parentMessageId: 'task-parent-msg',
provider: 'openai',
tools: undefined,
},
});
});
it('should return call_llm even when some tasks failed', async () => {
const agent = new GeneralChatAgent({
agentConfig: { maxSteps: 100 },
operationId: 'test-session',
modelRuntimeConfig: mockModelRuntimeConfig,
});
const state = createMockState({
messages: [
{ role: 'user', content: 'Execute tasks' },
{ role: 'assistant', content: '' },
{ role: 'task', content: 'Task 1 result', metadata: { instruction: 'Do task 1' } },
{ role: 'task', content: 'Task failed: timeout', metadata: { instruction: 'Do task 2' } },
] as any,
});
const context = createMockContext('tasks_batch_result', {
parentMessageId: 'task-parent-msg',
results: [
{ success: true, taskMessageId: 'task-1', threadId: 'thread-1', result: 'Task 1 result' },
{
success: false,
taskMessageId: 'task-2',
threadId: 'thread-2',
error: 'Task timeout after 1800000ms',
},
],
});
const result = await agent.runner(context, state);
expect(result).toEqual({
type: 'call_llm',
payload: {
messages: state.messages,
model: 'gpt-4o-mini',
parentMessageId: 'task-parent-msg',
provider: 'openai',
tools: undefined,
},
});
});
});
describe('unknown phase', () => {
it('should return finish instruction for unknown phase', async () => {
const agent = new GeneralChatAgent({

View File

@@ -27,6 +27,8 @@ export interface GeneralAgentCallToolResultPayload {
executionTime: number;
isSuccess: boolean;
parentMessageId: string;
/** Whether tool requested to stop execution (e.g., group management speak/delegate, GTD async tasks) */
stop?: boolean;
toolCall: ChatToolPayload;
toolCallId: string;
}

View File

@@ -35,6 +35,8 @@ export interface AgentRuntimeContext {
| 'llm_result'
| 'tool_result'
| 'tools_batch_result'
| 'task_result'
| 'tasks_batch_result'
| 'human_response'
| 'human_approved_tool'
| 'human_abort'
@@ -224,6 +226,88 @@ export interface AgentInstructionCompressContext {
type: 'compress_context';
}
/**
* Task definition for exec_tasks instruction
*/
export interface ExecTaskItem {
/** Brief description of what this task does (shown in UI) */
description: string;
/** Whether to inherit context messages from parent conversation */
inheritMessages?: boolean;
/** Detailed instruction/prompt for the task execution */
instruction: string;
/** Timeout in milliseconds (optional, default 30 minutes) */
timeout?: number;
}
/**
* Instruction to execute a single async task
*/
export interface AgentInstructionExecTask {
payload: {
/** Parent message ID (tool message that triggered the task) */
parentMessageId: string;
/** Task to execute */
task: ExecTaskItem;
};
type: 'exec_task';
}
/**
* Instruction to execute multiple async tasks in parallel
*/
export interface AgentInstructionExecTasks {
payload: {
/** Parent message ID (tool message that triggered the tasks) */
parentMessageId: string;
/** Array of tasks to execute */
tasks: ExecTaskItem[];
};
type: 'exec_tasks';
}
/**
* Payload for task_result phase (single task)
*/
export interface TaskResultPayload {
/** Parent message ID */
parentMessageId: string;
/** Result from executed task */
result: {
/** Error message if task failed */
error?: string;
/** Task result content */
result?: string;
/** Whether the task completed successfully */
success: boolean;
/** Task message ID */
taskMessageId: string;
/** Thread ID where the task was executed */
threadId: string;
};
}
/**
* Payload for tasks_batch_result phase (multiple tasks)
*/
export interface TasksBatchResultPayload {
/** Parent message ID */
parentMessageId: string;
/** Results from executed tasks */
results: Array<{
/** Error message if task failed */
error?: string;
/** Task result content */
result?: string;
/** Whether the task completed successfully */
success: boolean;
/** Task message ID */
taskMessageId: string;
/** Thread ID where the task was executed */
threadId: string;
}>;
}
/**
* A serializable instruction object that the "Agent" (Brain) returns
* to the "AgentRuntime" (Engine) to execute.
@@ -232,6 +316,8 @@ export type AgentInstruction =
| AgentInstructionCallLlm
| AgentInstructionCallTool
| AgentInstructionCallToolsBatch
| AgentInstructionExecTask
| AgentInstructionExecTasks
| AgentInstructionRequestHumanPrompt
| AgentInstructionRequestHumanSelect
| AgentInstructionRequestHumanApprove

View File

@@ -14,48 +14,4 @@ Your role is to:
- Provide clear and concise explanations
- Be friendly and professional in your responses
<builtin_tools_guidelines>
You have access to two built-in tools: **Notebook** for content creation and **GTD** for task management.
## Notebook Tool (createDocument)
Use Notebook to create documents when:
- User requests relatively long content (articles, reports, analyses, tutorials, guides)
- User explicitly asks to "write", "draft", "create", "generate" substantial content
- Output would exceed ~500 words or benefit from structured formatting
- Content should be preserved for future reference or editing
- Creating deliverables: blog posts, documentation, summaries, research notes
**When to respond directly in chat instead**:
- Short answers, explanations, or clarifications
- Quick Q&A interactions
- Code snippets or brief examples
- Conversational exchanges
## GTD Tool (createPlan, createTodos)
**ONLY use GTD when user explicitly requests task/project management**:
- User explicitly asks to "create a plan", "make a todo list", "track tasks"
- User says "help me plan [project]", "organize my tasks", "remind me to..."
- User provides a list of things they need to do and wants to track them
**When NOT to use GTD** (respond in chat instead):
- Answering questions (even if about "what to do" or "steps to take")
- Providing advice, analysis, or opinions
- Code review or technical consultations
- Explaining concepts or procedures
- Any question that starts with "Is...", "Can...", "Should...", "Would...", "What if..."
- Security assessments or risk analysis
**Key principle**: GTD is for ACTION TRACKING, not for answering questions. If the user is asking a question (even about tasks or plans), just answer it directly.
## Choosing the Right Tool
- "Write me an article about..." → Notebook
- "Help me plan my project" → GTD (plan + todos)
- "Create a to-do list for..." → GTD (todos)
- "Draft a report on..." → Notebook
- "What are the steps to..." → Chat (just explain)
- "Is this code secure?" → Chat (just answer)
- "Should I do X or Y?" → Chat (just advise)
- "Remember to..." / "Add to my list..." → GTD (todos)
</builtin_tools_guidelines>
Respond in the same language the user is using.`;

View File

@@ -0,0 +1,61 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { shinyTextStyles } from '@/styles';
import type { ExecTaskParams, ExecTaskState } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
description: css`
padding-block-end: 1px;
color: ${cssVar.colorText};
background: linear-gradient(to top, ${cssVar.colorInfoBg} 40%, transparent 40%);
`,
root: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
color: ${cssVar.colorTextSecondary};
`,
}));
export const ExecTaskInspector = memo<BuiltinInspectorProps<ExecTaskParams, ExecTaskState>>(
({ args, partialArgs, isArgumentsStreaming }) => {
const { t } = useTranslation('plugin');
const description = args?.description || partialArgs?.description;
if (isArgumentsStreaming && !description) {
return (
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-gtd.apiName.execTask')}</span>
</div>
);
}
return (
<div className={cx(styles.root, isArgumentsStreaming && shinyTextStyles.shinyText)}>
{description ? (
<Trans
components={{ desc: <span className={styles.description} /> }}
i18nKey="builtins.lobe-gtd.apiName.execTask.result"
ns="plugin"
values={{ description }}
/>
) : (
<span>{t('builtins.lobe-gtd.apiName.execTask')}</span>
)}
</div>
);
},
);
ExecTaskInspector.displayName = 'ExecTaskInspector';
export default ExecTaskInspector;

View File

@@ -0,0 +1,62 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { ListTodo } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { shinyTextStyles } from '@/styles';
import type { ExecTasksParams, ExecTasksState } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
count: css`
font-family: ${cssVar.fontFamilyCode};
color: ${cssVar.colorInfo};
`,
root: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
`,
title: css`
margin-inline-end: 8px;
color: ${cssVar.colorText};
`,
}));
export const ExecTasksInspector = memo<BuiltinInspectorProps<ExecTasksParams, ExecTasksState>>(
({ args, partialArgs, isArgumentsStreaming }) => {
const { t } = useTranslation('plugin');
const tasks = args?.tasks || partialArgs?.tasks || [];
const count = tasks.length;
if (isArgumentsStreaming && count === 0) {
return (
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-gtd.apiName.execTasks')}</span>
</div>
);
}
return (
<div className={cx(styles.root, isArgumentsStreaming && shinyTextStyles.shinyText)}>
<span className={styles.title}>{t('builtins.lobe-gtd.apiName.execTasks')}</span>
{count > 0 && (
<span className={styles.count}>
<Icon icon={ListTodo} size={12} />
{count}
</span>
)}
</div>
);
},
);
ExecTasksInspector.displayName = 'ExecTasksInspector';
export default ExecTasksInspector;

View File

@@ -5,6 +5,8 @@ import { ClearTodosInspector } from './ClearTodos';
import { CompleteTodosInspector } from './CompleteTodos';
import { CreatePlanInspector } from './CreatePlan';
import { CreateTodosInspector } from './CreateTodos';
import { ExecTaskInspector } from './ExecTask';
import { ExecTasksInspector } from './ExecTasks';
import { RemoveTodosInspector } from './RemoveTodos';
import { UpdatePlanInspector } from './UpdatePlan';
import { UpdateTodosInspector } from './UpdateTodos';
@@ -20,6 +22,8 @@ export const GTDInspectors: Record<string, BuiltinInspector> = {
[GTDApiName.completeTodos]: CompleteTodosInspector as BuiltinInspector,
[GTDApiName.createPlan]: CreatePlanInspector as BuiltinInspector,
[GTDApiName.createTodos]: CreateTodosInspector as BuiltinInspector,
[GTDApiName.execTask]: ExecTaskInspector as BuiltinInspector,
[GTDApiName.execTasks]: ExecTasksInspector as BuiltinInspector,
[GTDApiName.removeTodos]: RemoveTodosInspector as BuiltinInspector,
[GTDApiName.updatePlan]: UpdatePlanInspector as BuiltinInspector,
[GTDApiName.updateTodos]: UpdateTodosInspector as BuiltinInspector,

View File

@@ -0,0 +1,47 @@
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Markdown } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import type { ExecTaskParams } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 12px;
border-radius: 8px;
background: ${cssVar.colorFillQuaternary};
`,
description: css`
margin-bottom: 8px;
font-weight: 500;
color: ${cssVar.colorText};
`,
instruction: css`
font-size: 13px;
color: ${cssVar.colorTextSecondary};
`,
}));
export const ExecTaskStreaming = memo<BuiltinStreamingProps<ExecTaskParams>>(({ args }) => {
const { instruction } = args || {};
if (!instruction) return null;
return (
<div className={styles.container}>
{instruction && (
<div className={styles.instruction}>
<Markdown animated variant={'chat'}>
{instruction}
</Markdown>
</div>
)}
</div>
);
});
ExecTaskStreaming.displayName = 'ExecTaskStreaming';
export default ExecTaskStreaming;

View File

@@ -0,0 +1,56 @@
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Markdown } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import type { ExecTasksParams } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
display: flex;
flex-direction: column;
gap: 8px;
`,
description: css`
font-weight: 500;
color: ${cssVar.colorText};
`,
instruction: css`
font-size: 13px;
color: ${cssVar.colorTextSecondary};
`,
taskItem: css`
padding: 12px;
border-radius: 8px;
background: ${cssVar.colorFillQuaternary};
`,
}));
export const ExecTasksStreaming = memo<BuiltinStreamingProps<ExecTasksParams>>(({ args }) => {
const { tasks } = args || {};
if (!tasks || tasks.length === 0) return null;
return (
<div className={styles.container}>
{tasks.map((task, index) => (
<div className={styles.taskItem} key={index}>
{task.description && <div className={styles.description}>{task.description}</div>}
{task.instruction && (
<div className={styles.instruction}>
<Markdown animated variant={'chat'}>
{task.instruction}
</Markdown>
</div>
)}
</div>
))}
</div>
);
});
ExecTasksStreaming.displayName = 'ExecTasksStreaming';
export default ExecTasksStreaming;

View File

@@ -0,0 +1,18 @@
import type { BuiltinStreaming } from '@lobechat/types';
import { GTDApiName } from '../../types';
import { ExecTaskStreaming } from './ExecTask';
import { ExecTasksStreaming } from './ExecTasks';
/**
* GTD Streaming Components Registry
*
* Streaming components render tool calls while they are
* still executing, allowing real-time feedback to users.
*/
export const GTDStreamings: Record<string, BuiltinStreaming> = {
[GTDApiName.execTask]: ExecTaskStreaming as BuiltinStreaming,
[GTDApiName.execTasks]: ExecTasksStreaming as BuiltinStreaming,
};
export { ExecTaskStreaming, ExecTasksStreaming };

View File

@@ -5,6 +5,9 @@ export { GTDInspectors } from './Inspector';
export type { TodoListRenderState } from './Render';
export { GTDRenders, TodoListRender, TodoListUI } from './Render';
// Streaming components (real-time tool execution feedback)
export { ExecTaskStreaming, ExecTasksStreaming, GTDStreamings } from './Streaming';
// Intervention components (interactive editing)
export { AddTodoIntervention, ClearTodosIntervention, GTDInterventions } from './Intervention';

View File

@@ -10,6 +10,8 @@ import {
type CompleteTodosParams,
type CreatePlanParams,
type CreateTodosParams,
type ExecTaskParams,
type ExecTasksParams,
GTDApiName,
type Plan,
type RemoveTodosParams,
@@ -19,12 +21,13 @@ import {
} from '../types';
import { getTodosFromContext } from './helper';
// API enum for MVP (Todo + Plan)
const GTDApiNameMVP = {
const GTDApiNameEnum = {
clearTodos: GTDApiName.clearTodos,
completeTodos: GTDApiName.completeTodos,
createPlan: GTDApiName.createPlan,
createTodos: GTDApiName.createTodos,
execTask: GTDApiName.execTask,
execTasks: GTDApiName.execTasks,
removeTodos: GTDApiName.removeTodos,
updatePlan: GTDApiName.updatePlan,
updateTodos: GTDApiName.updateTodos,
@@ -33,9 +36,9 @@ const GTDApiNameMVP = {
/**
* GTD Tool Executor
*/
class GTDExecutor extends BaseExecutor<typeof GTDApiNameMVP> {
class GTDExecutor extends BaseExecutor<typeof GTDApiNameEnum> {
readonly identifier = GTDIdentifier;
protected readonly apiEnum = GTDApiNameMVP;
protected readonly apiEnum = GTDApiNameEnum;
// ==================== Todo APIs ====================
@@ -477,6 +480,92 @@ class GTDExecutor extends BaseExecutor<typeof GTDApiNameMVP> {
};
}
};
// ==================== Async Tasks API ====================
/**
* Execute a single async task
*
* This method triggers async task execution by returning a special state.
* The AgentRuntime's executor will recognize this state and trigger the exec_task instruction.
*
* Flow:
* 1. GTD tool returns stop: true with state.type = 'execTask'
* 2. AgentRuntime executor recognizes the state and triggers exec_task instruction
* 3. exec_task executor creates task message and polls for completion
*/
execTask = async (
params: ExecTaskParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const { description, instruction, inheritMessages, timeout } = params;
if (!description || !instruction) {
return {
content: 'Task description and instruction are required.',
success: false,
};
}
// Return stop: true with special state that AgentRuntime will recognize
// The exec_task executor will be triggered by the runtime when it sees this state
return {
content: `🚀 Triggered async task for execution:\n- ${description}`,
state: {
parentMessageId: ctx.messageId,
task: {
description,
inheritMessages,
instruction,
timeout,
},
type: 'execTask',
},
stop: true,
success: true,
};
};
/**
* Execute one or more async tasks
*
* This method triggers async task execution by returning a special state.
* The AgentRuntime's executor will recognize this state and trigger the exec_tasks instruction.
*
* Flow:
* 1. GTD tool returns stop: true with state.type = 'execTasks'
* 2. AgentRuntime executor recognizes the state and triggers exec_tasks instruction
* 3. exec_tasks executor creates task messages and polls for completion
*/
execTasks = async (
params: ExecTasksParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const { tasks } = params;
if (!tasks || tasks.length === 0) {
return {
content: 'No tasks provided to execute.',
success: false,
};
}
const taskCount = tasks.length;
const taskList = tasks.map((t, i) => `${i + 1}. ${t.description}`).join('\n');
// Return stop: true with special state that AgentRuntime will recognize
// The exec_tasks executor will be triggered by the runtime when it sees this state
return {
content: `🚀 Triggered ${taskCount} async task${taskCount > 1 ? 's' : ''} for execution:\n${taskList}`,
state: {
parentMessageId: ctx.messageId,
tasks,
type: 'execTasks',
},
stop: true,
success: true,
};
};
}
// Export the executor instance for registration

View File

@@ -37,12 +37,13 @@ export const GTDManifest: BuiltinToolManifest = {
},
{
description:
'Update an existing plan document. Use this to modify the goal, description, context, or mark the plan as completed.',
'Update an existing plan document. Only use this when the goal fundamentally changes. Plans should remain stable once created - do not update plans just because details change.',
name: GTDApiName.updatePlan,
parameters: {
properties: {
planId: {
description: 'The ID of the plan to update.',
description:
'The document ID of the plan to update (e.g., "docs_xxx"). This ID is returned in the createPlan response. Do NOT use the goal text as planId.',
type: 'string',
},
goal: {
@@ -57,10 +58,6 @@ export const GTDManifest: BuiltinToolManifest = {
description: 'Updated detailed context.',
type: 'string',
},
completed: {
description: 'Mark the plan as completed.',
type: 'boolean',
},
},
required: ['planId'],
type: 'object',
@@ -71,8 +68,7 @@ export const GTDManifest: BuiltinToolManifest = {
{
description: 'Create new todo items. Pass an array of text strings.',
name: GTDApiName.createTodos,
humanIntervention: 'always',
renderDisplayControl: 'expand',
humanIntervention: 'required',
parameters: {
properties: {
adds: {
@@ -179,6 +175,74 @@ export const GTDManifest: BuiltinToolManifest = {
type: 'object',
},
},
// ==================== Async Tasks ====================
{
description:
'Execute a single long-running async task. The task runs in an isolated context and can take significant time to complete. Use this for a single complex operation that requires extended processing.',
name: GTDApiName.execTask,
parameters: {
properties: {
description: {
description: 'Brief description of what this task does (shown in UI).',
type: 'string',
},
instruction: {
description: 'Detailed instruction/prompt for the task execution.',
type: 'string',
},
inheritMessages: {
description:
'Whether to inherit context messages from the parent conversation. Default is false.',
type: 'boolean',
},
timeout: {
description: 'Optional timeout in milliseconds. Default is 30 minutes.',
type: 'number',
},
},
required: ['description', 'instruction'],
type: 'object',
},
},
{
description:
'Execute one or more long-running async tasks. Each task runs in an isolated context and can take significant time to complete. Use this for complex operations that require extended processing.',
name: GTDApiName.execTasks,
parameters: {
properties: {
tasks: {
description: 'Array of tasks to execute asynchronously.',
items: {
properties: {
description: {
description: 'Brief description of what this task does (shown in UI).',
type: 'string',
},
instruction: {
description: 'Detailed instruction/prompt for the task execution.',
type: 'string',
},
inheritMessages: {
description:
'Whether to inherit context messages from the parent conversation. Default is false.',
type: 'boolean',
},
timeout: {
description: 'Optional timeout in milliseconds. Default is 30 minutes.',
type: 'number',
},
},
required: ['description', 'instruction'],
type: 'object',
},
type: 'array',
},
},
required: ['tasks'],
type: 'object',
},
},
],
identifier: GTDIdentifier,
meta: {

View File

@@ -1,12 +1,13 @@
export const systemPrompt = `You have GTD (Getting Things Done) tools to help manage plans and todos effectively. These tools support two levels of task management:
export const systemPrompt = `You have GTD (Getting Things Done) tools to help manage plans, todos and tasks effectively. These tools support three levels of task management:
- **Plan**: A high-level strategic document describing goals, context, and overall direction. Plans do NOT contain actionable steps - they define the "what" and "why".
- **Todo**: The concrete execution list with actionable items. Todos define the "how" - specific tasks to accomplish the plan.
- **Plan**: A high-level strategic document describing goals, context, and overall direction. Plans do NOT contain actionable steps - they define the "what" and "why". **Plans should be stable once created** - they represent the overarching objective that rarely changes.
- **Todo**: The concrete execution list with actionable items. Todos define the "how" - specific tasks to accomplish the plan. **Todos are dynamic** - they can be added, updated, completed, and removed as work progresses.
- **Task**: Long-running async operations that execute in isolated contexts. Tasks are for complex, multi-step operations that require extended processing time. **Tasks run independently** - they can inherit context but execute separately from the main conversation.
<tool_overview>
**Planning Tools** - For high-level goal documentation:
- \`createPlan\`: Create a strategic plan document with goal and context
- \`updatePlan\`: Update plan details or mark as completed
- \`updatePlan\`: Update plan details
**Todo Tools** - For actionable execution items:
- \`createTodos\`: Create new todo items from text array
@@ -14,11 +15,14 @@ export const systemPrompt = `You have GTD (Getting Things Done) tools to help ma
- \`completeTodos\`: Mark items as done by indices
- \`removeTodos\`: Remove items by indices
- \`clearTodos\`: Clear completed or all items
**Async Task Tools** - For long-running background tasks:
- \`execTask\`: Execute a single async task in isolated context
- \`execTasks\`: Execute multiple async tasks in parallel
</tool_overview>
<default_workflow>
**IMPORTANT: Always create a Plan first, then Todos.**
When a user asks you to help with a task, goal, or project:
1. **First**, use \`createPlan\` to document the goal and relevant context
2. **Then**, use \`createTodos\` to break down the plan into actionable steps
@@ -47,6 +51,26 @@ This "Plan-First" approach ensures:
- User explicitly requests only action items
- Capturing quick, simple tasks that don't need planning
- Tracking progress on concrete deliverables
**Use Async Tasks when:**
- **The request requires gathering external information**: User wants you to research, investigate, or find information that you don't already know. This requires web searches, reading multiple sources, and synthesizing information.
- **The task involves multiple steps**: The request cannot be answered in one simple response - it requires searching, reading, analyzing, and summarizing.
- **Quality depends on thorough investigation**: A superficial answer would be insufficient; the user expects comprehensive, well-researched results.
- **Independent execution is beneficial**: The task can run separately while freeing up the main conversation.
**How to identify async task scenarios:**
Ask yourself: "Can I answer this well from my existing knowledge, or does this require actively gathering new information?"
- If you need to search the web, read articles, or investigate → Use async task
- If you can answer directly from knowledge → Just respond
Use \`execTask\` for a single task, \`execTasks\` for multiple parallel tasks.
**Example scenarios:**
- User asks about best restaurants in a city → execTask (needs current info from reviews, searches)
- User wants research on a topic → execTask (multi-step: search, read, analyze, summarize)
- User asks to compare products/services → execTask (needs to gather data from multiple sources)
- User asks a factual question you know → Just answer directly
- User wants multiple independent analyses → execTasks (parallel execution)
</when_to_use>
<best_practices>
@@ -58,6 +82,57 @@ This "Plan-First" approach ensures:
- **Track progress**: Use todo completion to measure plan progress
</best_practices>
<todo_granularity>
**IMPORTANT: Keep todos focused on major stages, not detailed sub-tasks.**
- **Limit to 5-10 items**: A todo list should contain around 5-10 major milestones or stages, not 20+ detailed tasks.
- **Think in phases**: Group related tasks into higher-level stages (e.g., "Plan itinerary" instead of listing every city separately).
- **Use hierarchical numbering** when more detail is needed: Use "1.", "2.", "2.1", "2.2", "3." format to show parent-child relationships.
**Good example** (Japan trip - 7 items, stage-focused):
- 1. Determine travel dates and duration
- 2. Handle visa and documentation
- 3. Book flights and accommodation
- 4. Plan city itineraries
- 5. Arrange local transportation
- 6. Prepare for departure
- 7. Final confirmation before trip
**Bad example** (20+ detailed items):
- Book Tokyo hotel
- Book Kyoto hotel
- Book Osaka hotel
- Buy Suica card
- Download Google Maps
- Download translation app
- ... (too granular!)
**When user needs more detail**, use hierarchical numbering:
- 1. Determine travel dates
- 2. Plan itinerary
- 2.1 Tokyo attractions (3 days)
- 2.2 Kyoto attractions (2 days)
- 2.3 Osaka attractions (2 days)
- 3. Handle bookings
- 3.1 Flights
- 3.2 Hotels
- 3.3 JR Pass
- 4. Departure preparation
</todo_granularity>
<plan_stability>
**IMPORTANT: Plans should remain stable once created. Each conversation has only ONE plan.**
- **Do NOT update plans** when details change (dates, locations, preferences). Instead, update the todos to reflect new information.
- **Only use updatePlan** when the user's goal fundamentally changes (e.g., destination changes from Japan to Korea).
- When user provides more specific information (like exact dates or preferences), **update or add todos** - not the plan.
Example:
- User: "Plan a trip to Japan" → Create plan with goal "Japan Trip"
- User: "I want to go in February" → Update todos to include February-specific tasks, NOT update the plan
- User: "Actually I want to go to Korea instead" → Use updatePlan to change the goal to "Korea Trip" (fundamental goal change)
</plan_stability>
<response_format>
When working with GTD tools:
- Confirm actions: "Created plan: [goal]" or "Added [n] todo items"

View File

@@ -24,6 +24,13 @@ export const GTDApiName = {
/** Create new todo items */
createTodos: 'createTodos',
// ==================== Async Tasks ====================
/** Execute a single async task */
execTask: 'execTask',
/** Execute one or more async tasks */
execTasks: 'execTasks',
/** Remove todo items by indices */
removeTodos: 'removeTodos',
@@ -229,3 +236,67 @@ export interface UpdatePlanState {
/** The updated plan document */
plan: Plan;
}
// ==================== Async Tasks Types ====================
/**
* Single task item for execution
*/
export interface ExecTaskItem {
/** Brief description of what this task does (shown in UI) */
description: string;
/** Whether to inherit context messages from parent conversation */
inheritMessages?: boolean;
/** Detailed instruction/prompt for the task execution */
instruction: string;
/** Timeout in milliseconds (optional, default 30 minutes) */
timeout?: number;
}
/**
* Parameters for execTask API
* Execute a single async task
*/
export interface ExecTaskParams {
/** Brief description of what this task does (shown in UI) */
description: string;
/** Whether to inherit context messages from parent conversation */
inheritMessages?: boolean;
/** Detailed instruction/prompt for the task execution */
instruction: string;
/** Timeout in milliseconds (optional, default 30 minutes) */
timeout?: number;
}
/**
* Parameters for execTasks API
* Execute one or more async tasks
*/
export interface ExecTasksParams {
/** Array of tasks to execute */
tasks: ExecTaskItem[];
}
/**
* State returned after triggering exec_task
*/
export interface ExecTaskState {
/** Parent message ID (tool message) */
parentMessageId: string;
/** The task definition that was triggered */
task: ExecTaskItem;
/** Type identifier for render component */
type: 'execTask';
}
/**
* State returned after triggering exec_tasks
*/
export interface ExecTasksState {
/** Parent message ID (tool message) */
parentMessageId: string;
/** Array of task definitions that were triggered */
tasks: ExecTaskItem[];
/** Type identifier for render component */
type: 'execTasks';
}

View File

@@ -7,6 +7,7 @@ import { memo, useEffect, useState } from 'react';
import Actions from '@/features/Conversation/Messages/AssistantGroup/Tool/Actions';
import { useToolStore } from '@/store/tool';
import { toolSelectors } from '@/store/tool/selectors';
import { getBuiltinRender } from '@/tools/renders';
import { getBuiltinStreaming } from '@/tools/streamings';
import Inspectors from './Inspector';
@@ -79,6 +80,10 @@ const Tool = memo<GroupToolProps>(
const hasStreamingRenderer = !!getBuiltinStreaming(identifier, apiName);
const forceShowStreamingRender = isArgumentsStreaming && hasStreamingRenderer;
// Check if tool has custom render - if not, disable expand
// Custom render exists when: builtin render exists OR plugin type is not 'default'
const hasCustomRender = !!getBuiltinRender(identifier, apiName) || (!!type && type !== 'default');
// Wrap handleExpand to prevent collapsing when alwaysExpand is set
const wrappedHandleExpand = (expand?: boolean) => {
// Block collapse action when alwaysExpand is set
@@ -117,6 +122,7 @@ const Tool = memo<GroupToolProps>(
showPluginRender={showPluginRender}
/>
}
allowExpand={hasCustomRender}
itemKey={id}
paddingBlock={4}
paddingInline={4}

View File

@@ -60,6 +60,9 @@ export default {
'builtins.lobe-gtd.apiName.createPlan': 'Create plan',
'builtins.lobe-gtd.apiName.createPlan.result': 'Create plan: <goal>{{goal}}</goal>',
'builtins.lobe-gtd.apiName.createTodos': 'Create todos',
'builtins.lobe-gtd.apiName.execTask': 'Execute task',
'builtins.lobe-gtd.apiName.execTask.result': 'Execute: <desc>{{description}}</desc>',
'builtins.lobe-gtd.apiName.execTasks': 'Execute tasks',
'builtins.lobe-gtd.apiName.removeTodos': 'Delete todos',
'builtins.lobe-gtd.apiName.updatePlan': 'Update plan',
'builtins.lobe-gtd.apiName.updatePlan.completed': 'Completed',

View File

@@ -0,0 +1,479 @@
import type { AgentRuntimeContext, TaskResultPayload } from '@lobechat/agent-runtime';
import type { Mock } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { aiAgentService } from '@/services/aiAgent';
import { createExecTaskInstruction } from './fixtures';
import { createMockStore } from './fixtures/mockStore';
import { createInitialState, createTestContext, executeWithMockContext } from './helpers';
// Mock aiAgentService
vi.mock('@/services/aiAgent', () => ({
aiAgentService: {
execSubAgentTask: vi.fn(),
getSubAgentTaskStatus: vi.fn(),
},
}));
// Helper to get typed mocks
const mockExecSubAgentTask = aiAgentService.execSubAgentTask as Mock;
const mockGetSubAgentTaskStatus = aiAgentService.getSubAgentTaskStatus as Mock;
describe('exec_task executor', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Basic Behavior', () => {
it('should execute single task successfully', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTaskInstruction(
{ description: 'Test task', instruction: 'Do something' },
'msg_parent',
);
const state = createInitialState({ operationId: 'test-op' });
// Mock task message creation
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
// Mock task execution
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
// Mock task status polling - completed on first poll
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Task completed successfully',
status: 'completed',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
expect(result.nextContext).toBeDefined();
expect((result.nextContext as AgentRuntimeContext).phase).toBe('task_result');
const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload;
expect(payload.result).toBeDefined();
expect(payload.result.success).toBe(true);
expect(payload.result.threadId).toBe('thread_1');
expect(payload.result.taskMessageId).toBe('task_msg_1');
});
});
describe('Error Handling', () => {
it('should return error when no context available', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext({ agentId: undefined, topicId: null });
const instruction = createExecTaskInstruction();
const state = createInitialState({ operationId: 'test-op' });
// Override operation context to have no agentId/topicId
mockStore.operations[context.operationId] = {
abortController: new AbortController(),
childOperationIds: [],
context: {
agentId: undefined,
topicId: undefined,
},
id: context.operationId,
metadata: { startTime: Date.now() },
status: 'running',
type: 'execAgentRuntime',
};
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
expect(result.nextContext).toBeDefined();
const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload;
expect(payload.result.success).toBe(false);
expect(payload.result.error).toBe('No valid context available');
});
it('should handle task message creation failure', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTaskInstruction();
const state = createInitialState({ operationId: 'test-op' });
// Mock task message creation failure
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce(null);
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload;
expect(payload.result.success).toBe(false);
expect(payload.result.error).toBe('Failed to create task message');
});
it('should handle task creation API failure', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTaskInstruction();
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: '',
error: 'API error',
operationId: '',
success: false,
threadId: '',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload;
expect(payload.result.success).toBe(false);
expect(payload.result.error).toBe('API error');
expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
'task_msg_1',
'Task creation failed: API error',
undefined,
{ operationId: 'test-op' },
);
});
it('should handle task execution failure', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTaskInstruction();
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
error: 'Execution error',
status: 'failed',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload;
expect(payload.result.success).toBe(false);
expect(payload.result.error).toBe('Execution error');
});
});
describe('Task Status Polling', () => {
it('should update task message with taskDetail when completed', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTaskInstruction();
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
// Return completed with taskDetail on first poll
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Done',
status: 'completed',
taskDetail: { status: 'completed' },
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
expect(mockStore.internal_dispatchMessage).toHaveBeenCalledWith(
{
id: 'task_msg_1',
type: 'updateMessage',
value: { taskDetail: { status: 'completed' } },
},
{ operationId: 'test-op' },
);
const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload;
expect(payload.result.success).toBe(true);
});
it('should handle cancelled task status', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTaskInstruction();
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
// Use mockImplementationOnce to ensure fresh mock behavior
mockGetSubAgentTaskStatus.mockImplementationOnce(async () => ({
status: 'cancel',
}));
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload;
expect(payload.result.success).toBe(false);
expect(payload.result.error).toBe('Task was cancelled');
expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
'task_msg_1',
'Task was cancelled',
undefined,
{ operationId: 'test-op' },
);
});
});
describe('Operation Cancellation', () => {
it('should stop polling when operation is cancelled before poll', async () => {
// Given
const mockStore = createMockStore();
// Use same operationId for both context and state
const operationId = 'test-op';
const context = createTestContext({ operationId });
const instruction = createExecTaskInstruction();
const state = createInitialState({ operationId });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
// Mock execSubAgentTask to mark operation as cancelled after it's called
// This simulates cancellation happening right after task creation but before polling
mockExecSubAgentTask.mockImplementation(async () => {
// After task creation API is called, mark operation as cancelled
// This simulates cancellation happening right after task creation
mockStore.operations[operationId].status = 'cancelled';
return {
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
};
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TaskResultPayload;
expect(payload.result.success).toBe(false);
expect(payload.result.error).toBe('Operation cancelled');
// getSubAgentTaskStatus should not be called since operation was cancelled before poll
expect(mockGetSubAgentTaskStatus).not.toHaveBeenCalled();
});
});
describe('Result Phase', () => {
it('should return task_result phase with correct session info', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTaskInstruction(
{ description: 'Test', instruction: 'Test instruction' },
'msg_parent',
);
const state = createInitialState({ operationId: 'test-op', stepCount: 5 });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Done',
status: 'completed',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
expect(result.nextContext).toBeDefined();
const nextContext = result.nextContext as AgentRuntimeContext;
expect(nextContext.phase).toBe('task_result');
expect(nextContext.session?.stepCount).toBe(6);
expect(nextContext.session?.status).toBe('running');
const payload = nextContext.payload as TaskResultPayload;
expect(payload.parentMessageId).toBe('msg_parent');
});
it('should update messages in newState from store', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTaskInstruction();
const state = createInitialState({ messages: [], operationId: 'test-op' });
const updatedMessages = [{ content: 'test', id: 'msg_1', role: 'user' }];
mockStore.dbMessagesMap[context.messageKey] = updatedMessages as any;
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Done',
status: 'completed',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
expect(result.newState.messages).toEqual(updatedMessages);
});
});
describe('Task Message Creation', () => {
it('should create task message with correct parameters', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext({ agentId: 'agent_1', topicId: 'topic_1' });
const instruction = createExecTaskInstruction(
{ description: 'Test task', instruction: 'Do something important' },
'msg_parent',
);
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Done',
status: 'completed',
});
// When
await executeWithMockContext({
context,
executor: 'exec_task',
instruction,
mockStore,
state,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
{
agentId: 'agent_1',
content: '',
metadata: { instruction: 'Do something important' },
parentId: 'msg_parent',
role: 'task',
topicId: 'topic_1',
},
{ operationId: 'test-op' },
);
});
});
});

View File

@@ -0,0 +1,598 @@
import type { AgentRuntimeContext, TasksBatchResultPayload } from '@lobechat/agent-runtime';
import type { Mock } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { aiAgentService } from '@/services/aiAgent';
import { createExecTasksInstruction } from './fixtures';
import { createMockStore } from './fixtures/mockStore';
import { createInitialState, createTestContext, executeWithMockContext } from './helpers';
// Mock aiAgentService
vi.mock('@/services/aiAgent', () => ({
aiAgentService: {
execSubAgentTask: vi.fn(),
getSubAgentTaskStatus: vi.fn(),
},
}));
// Helper to get typed mocks
const mockExecSubAgentTask = aiAgentService.execSubAgentTask as Mock;
const mockGetSubAgentTaskStatus = aiAgentService.getSubAgentTaskStatus as Mock;
describe('exec_tasks executor', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Basic Behavior', () => {
it('should execute single task successfully', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction(
[{ description: 'Test task 1', instruction: 'Do something' }],
'msg_parent',
);
const state = createInitialState({ operationId: 'test-op' });
// Mock task message creation
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
// Mock task execution
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
// Mock task status polling - completed on first poll
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Task completed successfully',
status: 'completed',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
expect(result.nextContext).toBeDefined();
expect((result.nextContext as AgentRuntimeContext).phase).toBe('tasks_batch_result');
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results).toHaveLength(1);
expect(payload.results[0].success).toBe(true);
expect(payload.results[0].threadId).toBe('thread_1');
expect(payload.results[0].taskMessageId).toBe('task_msg_1');
});
it('should execute multiple tasks in parallel', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction(
[
{ description: 'Task 1', instruction: 'Do task 1' },
{ description: 'Task 2', instruction: 'Do task 2' },
{ description: 'Task 3', instruction: 'Do task 3' },
],
'msg_parent',
);
const state = createInitialState({ operationId: 'test-op' });
// Mock task message creation for each task
(mockStore.optimisticCreateMessage as Mock)
.mockResolvedValueOnce({ id: 'task_msg_1' })
.mockResolvedValueOnce({ id: 'task_msg_2' })
.mockResolvedValueOnce({ id: 'task_msg_3' });
// Mock task execution for each task
mockExecSubAgentTask
.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
})
.mockResolvedValueOnce({
assistantMessageId: 'asst_2',
operationId: 'op_2',
success: true,
threadId: 'thread_2',
})
.mockResolvedValueOnce({
assistantMessageId: 'asst_3',
operationId: 'op_3',
success: true,
threadId: 'thread_3',
});
// Mock task status polling - all completed
mockGetSubAgentTaskStatus
.mockResolvedValueOnce({ result: 'Result 1', status: 'completed' })
.mockResolvedValueOnce({ result: 'Result 2', status: 'completed' })
.mockResolvedValueOnce({ result: 'Result 3', status: 'completed' });
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
expect(result.nextContext).toBeDefined();
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results).toHaveLength(3);
expect(payload.results.every((r) => r.success)).toBe(true);
});
});
describe('Error Handling', () => {
it('should return error when no context available', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext({ agentId: undefined, topicId: null });
const instruction = createExecTasksInstruction();
const state = createInitialState({ operationId: 'test-op' });
// Override operation context to have no agentId/topicId
mockStore.operations[context.operationId] = {
abortController: new AbortController(),
childOperationIds: [],
context: {
agentId: undefined,
topicId: undefined,
},
id: context.operationId,
metadata: { startTime: Date.now() },
status: 'running',
type: 'execAgentRuntime',
};
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
expect(result.nextContext).toBeDefined();
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results).toHaveLength(1);
expect(payload.results[0].success).toBe(false);
expect(payload.results[0].error).toBe('No valid context available');
});
it('should handle task message creation failure', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction();
const state = createInitialState({ operationId: 'test-op' });
// Mock task message creation failure
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce(null);
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results[0].success).toBe(false);
expect(payload.results[0].error).toBe('Failed to create task message');
});
it('should handle task creation API failure', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction();
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: '',
error: 'API error',
operationId: '',
success: false,
threadId: '',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results[0].success).toBe(false);
expect(payload.results[0].error).toBe('API error');
expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
'task_msg_1',
'Task creation failed: API error',
undefined,
{ operationId: 'test-op' },
);
});
it('should handle task execution failure', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction();
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
error: 'Execution error',
status: 'failed',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results[0].success).toBe(false);
expect(payload.results[0].error).toBe('Execution error');
});
});
describe('Task Status Polling', () => {
it('should update task message with taskDetail when completed', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction();
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
// Return completed with taskDetail on first poll
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Done',
status: 'completed',
taskDetail: { status: 'completed' },
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
expect(mockStore.internal_dispatchMessage).toHaveBeenCalledWith(
{
id: 'task_msg_1',
type: 'updateMessage',
value: { taskDetail: { status: 'completed' } },
},
{ operationId: 'test-op' },
);
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results[0].success).toBe(true);
});
it('should handle cancelled task status', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction();
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
// Use mockImplementationOnce to ensure fresh mock behavior
mockGetSubAgentTaskStatus.mockImplementationOnce(async () => ({
status: 'cancel',
}));
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results[0].success).toBe(false);
expect(payload.results[0].error).toBe('Task was cancelled');
expect(mockStore.optimisticUpdateMessageContent).toHaveBeenCalledWith(
'task_msg_1',
'Task was cancelled',
undefined,
{ operationId: 'test-op' },
);
});
});
describe('Operation Cancellation', () => {
it('should stop polling when operation is cancelled before poll', async () => {
// Given
const mockStore = createMockStore();
// Use same operationId for both context and state
const operationId = 'test-op';
const context = createTestContext({ operationId });
const instruction = createExecTasksInstruction();
const state = createInitialState({ operationId });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
// Mock execSubAgentTask to mark operation as cancelled after it's called
// This simulates cancellation happening right after task creation but before polling
mockExecSubAgentTask.mockImplementation(async () => {
// After task creation API is called, mark operation as cancelled
// This simulates cancellation happening right after task creation
// Note: state.operationId is used in the polling loop for cancellation check
mockStore.operations[operationId].status = 'cancelled';
return {
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
};
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results[0].success).toBe(false);
expect(payload.results[0].error).toBe('Operation cancelled');
// getSubAgentTaskStatus should not be called since operation was cancelled before poll
expect(mockGetSubAgentTaskStatus).not.toHaveBeenCalled();
});
});
describe('Result Phase', () => {
it('should return tasks_result phase with correct session info', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction(
[{ description: 'Test', instruction: 'Test instruction' }],
'msg_parent',
);
const state = createInitialState({ operationId: 'test-op', stepCount: 5 });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Done',
status: 'completed',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
expect(result.nextContext).toBeDefined();
const nextContext = result.nextContext as AgentRuntimeContext;
expect(nextContext.phase).toBe('tasks_batch_result');
expect(nextContext.session?.stepCount).toBe(6);
expect(nextContext.session?.status).toBe('running');
const payload = nextContext.payload as TasksBatchResultPayload;
expect(payload.parentMessageId).toBe('msg_parent');
});
it('should update messages in newState from store', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction();
const state = createInitialState({ messages: [], operationId: 'test-op' });
const updatedMessages = [{ content: 'test', id: 'msg_1', role: 'user' }];
mockStore.dbMessagesMap[context.messageKey] = updatedMessages as any;
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Done',
status: 'completed',
});
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
expect(result.newState.messages).toEqual(updatedMessages);
});
});
describe('Task Message Creation', () => {
it('should create task message with correct parameters', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext({ agentId: 'agent_1', topicId: 'topic_1' });
const instruction = createExecTasksInstruction(
[{ description: 'Test task', instruction: 'Do something important' }],
'msg_parent',
);
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock).mockResolvedValueOnce({ id: 'task_msg_1' });
mockExecSubAgentTask.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
});
mockGetSubAgentTaskStatus.mockResolvedValueOnce({
result: 'Done',
status: 'completed',
});
// When
await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
expect(mockStore.optimisticCreateMessage).toHaveBeenCalledWith(
{
agentId: 'agent_1',
content: '',
metadata: { instruction: 'Do something important' },
parentId: 'msg_parent',
role: 'task',
topicId: 'topic_1',
},
{ operationId: 'test-op' },
);
});
});
describe('Mixed Results', () => {
it('should handle mix of successful and failed tasks', async () => {
// Given
const mockStore = createMockStore();
const context = createTestContext();
const instruction = createExecTasksInstruction(
[
{ description: 'Task 1', instruction: 'Success task' },
{ description: 'Task 2', instruction: 'Fail task' },
],
'msg_parent',
);
const state = createInitialState({ operationId: 'test-op' });
(mockStore.optimisticCreateMessage as Mock)
.mockResolvedValueOnce({ id: 'task_msg_1' })
.mockResolvedValueOnce({ id: 'task_msg_2' });
mockExecSubAgentTask
.mockResolvedValueOnce({
assistantMessageId: 'asst_1',
operationId: 'op_1',
success: true,
threadId: 'thread_1',
})
.mockResolvedValueOnce({
assistantMessageId: 'asst_2',
operationId: 'op_2',
success: true,
threadId: 'thread_2',
});
mockGetSubAgentTaskStatus
.mockResolvedValueOnce({ result: 'Success', status: 'completed' })
.mockResolvedValueOnce({ error: 'Task failed', status: 'failed' });
// When
const result = await executeWithMockContext({
context,
executor: 'exec_tasks',
instruction,
mockStore,
state,
});
// Then
const payload = (result.nextContext as AgentRuntimeContext).payload as TasksBatchResultPayload;
expect(payload.results).toHaveLength(2);
expect(payload.results[0].success).toBe(true);
expect(payload.results[1].success).toBe(false);
expect(payload.results[1].error).toBe('Task failed');
});
});
});

View File

@@ -2,6 +2,9 @@ import type {
AgentInstruction,
AgentInstructionCallLlm,
AgentInstructionCallTool,
AgentInstructionExecTask,
AgentInstructionExecTasks,
ExecTaskItem,
GeneralAgentCallLLMInstructionPayload,
GeneralAgentCallingToolInstructionPayload,
} from '@lobechat/agent-runtime';
@@ -124,3 +127,50 @@ export const createFinishInstruction = (
type: 'finish',
} as AgentInstruction;
};
/**
* Create a mock exec_task instruction (single task)
*/
export const createExecTaskInstruction = (
task?: Partial<ExecTaskItem>,
parentMessageId?: string,
): AgentInstructionExecTask => {
const defaultTask: ExecTaskItem = {
description: 'Test task',
instruction: 'Execute test task',
...task,
};
return {
payload: {
parentMessageId: parentMessageId || `msg_${nanoid()}`,
task: defaultTask,
},
type: 'exec_task',
};
};
/**
* Create a mock exec_tasks instruction (multiple tasks)
*/
export const createExecTasksInstruction = (
tasks: ExecTaskItem[] = [],
parentMessageId?: string,
): AgentInstructionExecTasks => {
const defaultTasks: ExecTaskItem[] = tasks.length
? tasks
: [
{
description: 'Test task',
instruction: 'Execute test task',
},
];
return {
payload: {
parentMessageId: parentMessageId || `msg_${nanoid()}`,
tasks: defaultTasks,
},
type: 'exec_tasks',
};
};

View File

@@ -56,6 +56,8 @@ export const createMockStore = (overrides: Partial<ChatStore> = {}): ChatStore =
}),
// AI chat methods
internal_dispatchMessage: vi.fn(),
internal_fetchAIChatMessage: vi.fn().mockResolvedValue(undefined),
internal_invokeDifferentTypePlugin: vi.fn().mockResolvedValue({ error: null }),

View File

@@ -3,12 +3,16 @@ import {
type AgentInstruction,
type AgentInstructionCallLlm,
type AgentInstructionCallTool,
type AgentInstructionExecTask,
type AgentInstructionExecTasks,
type AgentRuntimeContext,
type GeneralAgentCallLLMInstructionPayload,
type GeneralAgentCallLLMResultPayload,
type GeneralAgentCallToolResultPayload,
type GeneralAgentCallingToolInstructionPayload,
type InstructionExecutor,
type TaskResultPayload,
type TasksBatchResultPayload,
UsageCounter,
} from '@lobechat/agent-runtime';
import type { ChatToolPayload, CreateMessageParams } from '@lobechat/types';
@@ -16,7 +20,9 @@ import debug from 'debug';
import pMap from 'p-map';
import { LOADING_FLAT } from '@/const/message';
import { aiAgentService } from '@/services/aiAgent';
import type { ChatStore } from '@/store/chat/store';
import { sleep } from '@/utils/sleep';
const log = debug('lobe-store:agent-executors');
@@ -558,21 +564,58 @@ export const createAgentExecutors = (context: {
toolCost.toFixed(4),
);
// Check if tool wants to stop execution flow (e.g., group management tools)
// Check if tool wants to stop execution flow
if (result?.stop) {
log(
'[%s][call_tool] Tool returned stop=true, terminating execution. state: %O',
sessionLogId,
result.state,
);
log('[%s][call_tool] Tool returned stop=true, state: %O', sessionLogId, result.state);
// Mark state as done and return without nextContext to stop the runtime
const stateType = result.state?.type;
// GTD async tasks need to be passed to Agent for exec_task/exec_tasks instruction
if (stateType === 'execTask' || stateType === 'execTasks') {
log(
'[%s][call_tool] Detected %s state, passing to Agent for decision',
sessionLogId,
stateType,
);
return {
events,
newState,
nextContext: {
payload: {
data: result,
executionTime,
isSuccess,
parentMessageId: toolMessageId,
stop: true,
toolCall: chatToolPayload,
toolCallId: chatToolPayload.id,
} as GeneralAgentCallToolResultPayload,
phase: 'tool_result',
session: {
eventCount: events.length,
messageCount: newState.messages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
stepUsage: {
cost: toolCost,
toolName,
unitPrice: toolCost,
usageCount: 1,
},
} as AgentRuntimeContext,
};
}
// Other stop types (speak, delegate, broadcast, etc.) - stop execution immediately
newState.status = 'done';
return {
events,
newState,
nextContext: undefined, // No next context means execution stops
nextContext: undefined,
};
}
@@ -817,6 +860,625 @@ export const createAgentExecutors = (context: {
return { events, newState };
},
/**
* exec_task executor
* Executes a single async task
*
* Flow:
* 1. Create a task message (role: 'task') as placeholder
* 2. Call execSubAgentTask API (backend creates thread)
* 3. Poll for task completion
* 4. Update task message content with result on completion
* 5. Return task_result phase with result
*/
exec_task: async (instruction, state) => {
const { parentMessageId, task } = (instruction as AgentInstructionExecTask).payload;
const events: AgentEvent[] = [];
const sessionLogId = `${state.operationId}:${state.stepCount}`;
log('[%s][exec_task] Starting execution of task: %s', sessionLogId, task.description);
// Get context from operation
const opContext = getOperationContext();
const { agentId, topicId } = opContext;
if (!agentId || !topicId) {
log('[%s][exec_task] No valid context, cannot execute task', sessionLogId);
return {
events,
newState: state,
nextContext: {
payload: {
parentMessageId,
result: {
error: 'No valid context available',
success: false,
taskMessageId: '',
threadId: '',
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: state.messages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
const taskLogId = `${sessionLogId}:task`;
try {
// 1. Create task message as placeholder
const taskMessageResult = await context.get().optimisticCreateMessage(
{
agentId,
content: '',
metadata: { instruction: task.instruction },
parentId: parentMessageId,
role: 'task',
topicId,
},
{ operationId: state.operationId },
);
if (!taskMessageResult) {
log('[%s] Failed to create task message', taskLogId);
return {
events,
newState: state,
nextContext: {
payload: {
parentMessageId,
result: {
error: 'Failed to create task message',
success: false,
taskMessageId: '',
threadId: '',
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: state.messages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
const taskMessageId = taskMessageResult.id;
log('[%s] Created task message: %s', taskLogId, taskMessageId);
// 2. Create task via backend API
const createResult = await aiAgentService.execSubAgentTask({
agentId,
instruction: task.instruction,
parentMessageId: taskMessageId,
topicId,
});
if (!createResult.success) {
log('[%s] Failed to create task: %s', taskLogId, createResult.error);
await context
.get()
.optimisticUpdateMessageContent(
taskMessageId,
`Task creation failed: ${createResult.error}`,
undefined,
{ operationId: state.operationId },
);
return {
events,
newState: state,
nextContext: {
payload: {
parentMessageId,
result: {
error: createResult.error,
success: false,
taskMessageId,
threadId: '',
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: state.messages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
log('[%s] Task created with threadId: %s', taskLogId, createResult.threadId);
// 3. Poll for task completion
const pollInterval = 3000; // 3 seconds
const maxWait = task.timeout || 1_800_000; // Default 30 minutes
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
// Check if operation has been cancelled
const currentOperation = context.get().operations[state.operationId];
if (currentOperation?.status === 'cancelled') {
log('[%s] Operation cancelled, stopping polling', taskLogId);
const updatedMessages = context.get().dbMessagesMap[context.messageKey] || [];
return {
events,
newState: { ...state, messages: updatedMessages },
nextContext: {
payload: {
parentMessageId,
result: {
error: 'Operation cancelled',
success: false,
taskMessageId,
threadId: createResult.threadId,
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: updatedMessages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
const status = await aiAgentService.getSubAgentTaskStatus({
threadId: createResult.threadId,
});
// Update taskDetail in message if available
if (status.taskDetail) {
context.get().internal_dispatchMessage(
{
id: taskMessageId,
type: 'updateMessage',
value: { taskDetail: status.taskDetail },
},
{ operationId: state.operationId },
);
log('[%s] Updated task message with taskDetail', taskLogId);
}
if (status.status === 'completed') {
log('[%s] Task completed successfully', taskLogId);
if (status.result) {
await context
.get()
.optimisticUpdateMessageContent(taskMessageId, status.result, undefined, {
operationId: state.operationId,
});
}
const updatedMessages = context.get().dbMessagesMap[context.messageKey] || [];
return {
events,
newState: { ...state, messages: updatedMessages },
nextContext: {
payload: {
parentMessageId,
result: {
result: status.result,
success: true,
taskMessageId,
threadId: createResult.threadId,
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: updatedMessages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
if (status.status === 'failed') {
log('[%s] Task failed: %s', taskLogId, status.error);
await context
.get()
.optimisticUpdateMessageContent(
taskMessageId,
`Task failed: ${status.error}`,
undefined,
{ operationId: state.operationId },
);
const updatedMessages = context.get().dbMessagesMap[context.messageKey] || [];
return {
events,
newState: { ...state, messages: updatedMessages },
nextContext: {
payload: {
parentMessageId,
result: {
error: status.error,
success: false,
taskMessageId,
threadId: createResult.threadId,
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: updatedMessages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
if (status.status === 'cancel') {
log('[%s] Task was cancelled', taskLogId);
await context
.get()
.optimisticUpdateMessageContent(taskMessageId, 'Task was cancelled', undefined, {
operationId: state.operationId,
});
const updatedMessages = context.get().dbMessagesMap[context.messageKey] || [];
return {
events,
newState: { ...state, messages: updatedMessages },
nextContext: {
payload: {
parentMessageId,
result: {
error: 'Task was cancelled',
success: false,
taskMessageId,
threadId: createResult.threadId,
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: updatedMessages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
// Still processing, wait and poll again
await sleep(pollInterval);
}
// Timeout reached
log('[%s] Task timeout after %dms', taskLogId, maxWait);
await context
.get()
.optimisticUpdateMessageContent(
taskMessageId,
`Task timeout after ${maxWait}ms`,
undefined,
{ operationId: state.operationId },
);
const updatedMessages = context.get().dbMessagesMap[context.messageKey] || [];
return {
events,
newState: { ...state, messages: updatedMessages },
nextContext: {
payload: {
parentMessageId,
result: {
error: `Task timeout after ${maxWait}ms`,
success: false,
taskMessageId,
threadId: createResult.threadId,
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: updatedMessages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
} catch (error) {
log('[%s] Error executing task: %O', taskLogId, error);
return {
events,
newState: state,
nextContext: {
payload: {
parentMessageId,
result: {
error: error instanceof Error ? error.message : 'Unknown error',
success: false,
taskMessageId: '',
threadId: '',
},
} as TaskResultPayload,
phase: 'task_result',
session: {
messageCount: state.messages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
},
/**
* exec_tasks executor
* Executes one or more async tasks in parallel
*
* Flow:
* 1. For each task, create a task message (role: 'task') as placeholder
* 2. Call execSubAgentTask API (backend creates thread)
* 3. Poll for task completion
* 4. Update task message content with result on completion
* 5. Return tasks_batch_result phase with all results
*/
exec_tasks: async (instruction, state) => {
const { parentMessageId, tasks } = (instruction as AgentInstructionExecTasks).payload;
const events: AgentEvent[] = [];
const sessionLogId = `${state.operationId}:${state.stepCount}`;
log('[%s][exec_tasks] Starting execution of %d tasks', sessionLogId, tasks.length);
// Get context from operation
const opContext = getOperationContext();
const { agentId, topicId } = opContext;
if (!agentId || !topicId) {
log('[%s][exec_tasks] No valid context, cannot execute tasks', sessionLogId);
return {
events,
newState: state,
nextContext: {
payload: {
parentMessageId,
results: tasks.map(() => ({
error: 'No valid context available',
success: false,
taskMessageId: '',
threadId: '',
})),
} as TasksBatchResultPayload,
phase: 'tasks_batch_result',
session: {
messageCount: state.messages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
}
// Execute all tasks in parallel
const results = await pMap(
tasks,
async (task, taskIndex) => {
const taskLogId = `${sessionLogId}:task-${taskIndex}`;
log('[%s] Starting task: %s', taskLogId, task.description);
try {
// 1. Create task message as placeholder
const taskMessageResult = await context.get().optimisticCreateMessage(
{
agentId,
content: '',
metadata: { instruction: task.instruction },
parentId: parentMessageId,
role: 'task',
topicId,
},
{ operationId: state.operationId },
);
if (!taskMessageResult) {
log('[%s] Failed to create task message', taskLogId);
return {
error: 'Failed to create task message',
success: false,
taskMessageId: '',
threadId: '',
};
}
const taskMessageId = taskMessageResult.id;
log('[%s] Created task message: %s', taskLogId, taskMessageId);
// 2. Create task via backend API (no groupId for single agent mode)
const createResult = await aiAgentService.execSubAgentTask({
agentId,
instruction: task.instruction,
parentMessageId: taskMessageId,
topicId,
});
if (!createResult.success) {
log('[%s] Failed to create task: %s', taskLogId, createResult.error);
// Update task message with error
await context
.get()
.optimisticUpdateMessageContent(
taskMessageId,
`Task creation failed: ${createResult.error}`,
undefined,
{ operationId: state.operationId },
);
return {
error: createResult.error,
success: false,
taskMessageId,
threadId: '',
};
}
log('[%s] Task created with threadId: %s', taskLogId, createResult.threadId);
// 3. Poll for task completion
const pollInterval = 3000; // 3 seconds
const maxWait = task.timeout || 1_800_000; // Default 30 minutes
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
// Check if operation has been cancelled
const currentOperation = context.get().operations[state.operationId];
if (currentOperation?.status === 'cancelled') {
log('[%s] Operation cancelled, stopping polling', taskLogId);
return {
error: 'Operation cancelled',
success: false,
taskMessageId,
threadId: createResult.threadId,
};
}
const status = await aiAgentService.getSubAgentTaskStatus({
threadId: createResult.threadId,
});
// Update taskDetail in message if available
if (status.taskDetail) {
context.get().internal_dispatchMessage(
{
id: taskMessageId,
type: 'updateMessage',
value: { taskDetail: status.taskDetail },
},
{ operationId: state.operationId },
);
log('[%s] Updated task message with taskDetail', taskLogId);
}
if (status.status === 'completed') {
log('[%s] Task completed successfully', taskLogId);
// 4. Update task message with result
if (status.result) {
await context
.get()
.optimisticUpdateMessageContent(taskMessageId, status.result, undefined, {
operationId: state.operationId,
});
}
return {
result: status.result,
success: true,
taskMessageId,
threadId: createResult.threadId,
};
}
if (status.status === 'failed') {
log('[%s] Task failed: %s', taskLogId, status.error);
// Update task message with error
await context
.get()
.optimisticUpdateMessageContent(
taskMessageId,
`Task failed: ${status.error}`,
undefined,
{ operationId: state.operationId },
);
return {
error: status.error,
success: false,
taskMessageId,
threadId: createResult.threadId,
};
}
if (status.status === 'cancel') {
log('[%s] Task was cancelled', taskLogId);
// Update task message with cancelled status
await context
.get()
.optimisticUpdateMessageContent(taskMessageId, 'Task was cancelled', undefined, {
operationId: state.operationId,
});
return {
error: 'Task was cancelled',
success: false,
taskMessageId,
threadId: createResult.threadId,
};
}
// Still processing, wait and poll again
await sleep(pollInterval);
}
// Timeout reached
log('[%s] Task timeout after %dms', taskLogId, maxWait);
// Update task message with timeout error
await context
.get()
.optimisticUpdateMessageContent(
taskMessageId,
`Task timeout after ${maxWait}ms`,
undefined,
{ operationId: state.operationId },
);
return {
error: `Task timeout after ${maxWait}ms`,
success: false,
taskMessageId,
threadId: createResult.threadId,
};
} catch (error) {
log('[%s] Error executing task: %O', taskLogId, error);
return {
error: error instanceof Error ? error.message : 'Unknown error',
success: false,
taskMessageId: '',
threadId: '',
};
}
},
{ concurrency: 5 }, // Limit concurrent tasks
);
log('[%s][exec_tasks] All tasks completed, results: %O', sessionLogId, results);
// Get latest messages from store
const updatedMessages = context.get().dbMessagesMap[context.messageKey] || [];
const newState = { ...state, messages: updatedMessages };
// Return tasks_batch_result phase
return {
events,
newState,
nextContext: {
payload: {
parentMessageId,
results,
} as TasksBatchResultPayload,
phase: 'tasks_batch_result',
session: {
messageCount: newState.messages.length,
sessionId: state.operationId,
status: 'running',
stepCount: state.stepCount + 1,
},
} as AgentRuntimeContext,
};
},
};
return executors;

View File

@@ -78,12 +78,13 @@ export interface PluginTypesAction {
* @param id - Tool message ID
* @param payload - Tool call payload
* @param stepContext - Optional step context with dynamic state like GTD todos
* @returns The tool execution result (including stop flag for flow control)
*/
invokeBuiltinTool: (
id: string,
payload: ChatToolPayload,
stepContext?: RuntimeStepContext,
) => Promise<void>;
) => Promise<any>;
/**
* Invoke Cloud Code Interpreter tool

View File

@@ -1,3 +1,4 @@
import { GTDManifest, GTDStreamings } from '@lobechat/builtin-tool-gtd/client';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
import { type BuiltinStreaming } from '@lobechat/types';
@@ -15,6 +16,7 @@ import { LocalSystemStreamings } from './local-system/Streaming';
*/
const BuiltinToolStreamings: Record<string, Record<string, BuiltinStreaming>> = {
[CodeInterpreterIdentifier]: CodeInterpreterStreamings as Record<string, BuiltinStreaming>,
[GTDManifest.identifier]: GTDStreamings as Record<string, BuiltinStreaming>,
[LocalSystemManifest.identifier]: LocalSystemStreamings as Record<string, BuiltinStreaming>,
};

View File

@@ -48,6 +48,7 @@ export default defineConfig({
'@/utils/errorResponse': resolve(__dirname, './src/utils/errorResponse'),
'@/utils/unzipFile': resolve(__dirname, './src/utils/unzipFile'),
'@/utils/server': resolve(__dirname, './src/utils/server'),
'@/utils/identifier': resolve(__dirname, './src/utils/identifier'),
'@/utils/electron': resolve(__dirname, './src/utils/electron'),
'@/utils/identifier': resolve(__dirname, './src/utils/identifier'),
'@/utils': resolve(__dirname, './packages/utils/src'),