🐛 fix: fix group subagent task issue (#11589)

* improve WriteFile feeling

* refactor exector

* improve task title

* fix flick

* improve i18n

* fix tests
This commit is contained in:
Arvin Xu
2026-01-19 01:30:59 +08:00
committed by GitHub
parent b4d103b438
commit 9ad468be06
16 changed files with 651 additions and 226 deletions

View File

@@ -70,7 +70,8 @@
"builtins.lobe-group-management.apiName.summarize": "Summarize conversation",
"builtins.lobe-group-management.apiName.vote": "Start vote",
"builtins.lobe-group-management.inspector.broadcast.title": "Following Agents speak:",
"builtins.lobe-group-management.inspector.executeAgentTask.title": "Assigning task to:",
"builtins.lobe-group-management.inspector.executeAgentTask.assignTo": "Assign",
"builtins.lobe-group-management.inspector.executeAgentTask.task": "task:",
"builtins.lobe-group-management.inspector.executeAgentTasks.title": "Assigning tasks to:",
"builtins.lobe-group-management.inspector.speak.title": "Designated Agent speaks:",
"builtins.lobe-group-management.title": "Group Coordinator",

View File

@@ -39,14 +39,14 @@
"builtins.lobe-cloud-sandbox.apiName.writeLocalFile": "写入文件",
"builtins.lobe-cloud-sandbox.title": "云端沙盒",
"builtins.lobe-group-agent-builder.apiName.batchCreateAgents": "批量创建 Agent",
"builtins.lobe-group-agent-builder.apiName.createAgent": "创建理",
"builtins.lobe-group-agent-builder.apiName.createAgent": "创建理",
"builtins.lobe-group-agent-builder.apiName.getAvailableModels": "获取可用模型",
"builtins.lobe-group-agent-builder.apiName.installPlugin": "安装技能",
"builtins.lobe-group-agent-builder.apiName.inviteAgent": "邀请成员",
"builtins.lobe-group-agent-builder.apiName.removeAgent": "移除成员",
"builtins.lobe-group-agent-builder.apiName.searchAgent": "搜索理",
"builtins.lobe-group-agent-builder.apiName.searchAgent": "搜索理",
"builtins.lobe-group-agent-builder.apiName.searchMarketTools": "搜索技能市场",
"builtins.lobe-group-agent-builder.apiName.updateAgentConfig": "更新理配置",
"builtins.lobe-group-agent-builder.apiName.updateAgentConfig": "更新理配置",
"builtins.lobe-group-agent-builder.apiName.updateAgentPrompt": "更新助理提示词",
"builtins.lobe-group-agent-builder.apiName.updateGroup": "更新群组",
"builtins.lobe-group-agent-builder.apiName.updateGroupPrompt": "更新群组提示词",
@@ -70,7 +70,8 @@
"builtins.lobe-group-management.apiName.summarize": "总结对话",
"builtins.lobe-group-management.apiName.vote": "发起投票",
"builtins.lobe-group-management.inspector.broadcast.title": "以下 Agent 发言:",
"builtins.lobe-group-management.inspector.executeAgentTask.title": "分配任务给:",
"builtins.lobe-group-management.inspector.executeAgentTask.assignTo": "分配",
"builtins.lobe-group-management.inspector.executeAgentTask.task": "任务:",
"builtins.lobe-group-management.inspector.executeAgentTasks.title": "分配任务给:",
"builtins.lobe-group-management.inspector.speak.title": "指定 Agent 发言:",
"builtins.lobe-group-management.title": "群组协调",

View File

@@ -32,6 +32,7 @@ export const ExecuteAgentTaskInspector = memo<BuiltinInspectorProps<ExecuteTaskP
const { t } = useTranslation('plugin');
const agentId = args?.agentId || partialArgs?.agentId;
const taskTitle = args?.title || partialArgs?.title;
// Get active group ID and agent from store
const activeGroupId = useAgentGroupStore(agentGroupSelectors.activeGroupId);
@@ -42,12 +43,47 @@ export const ExecuteAgentTaskInspector = memo<BuiltinInspectorProps<ExecuteTaskP
);
const theme = useTheme();
if (isArgumentsStreaming && !agent) {
return (
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-group-management.apiName.executeAgentTask')}</span>
</div>
);
if (isArgumentsStreaming) {
if (!agent && !taskTitle)
return (
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-group-management.apiName.executeAgentTask')}</span>
</div>
);
if (agent) {
return (
<Flexbox
align={'center'}
className={cx(styles.root, isArgumentsStreaming && shinyTextStyles.shinyText)}
gap={8}
horizontal
>
<span className={styles.title}>
{t('builtins.lobe-group-management.inspector.executeAgentTask.assignTo')}
</span>
{agent && (
<>
<Avatar
avatar={agent.avatar || DEFAULT_AVATAR}
background={agent.backgroundColor || theme.colorBgContainer}
shape={'square'}
size={24}
title={agent.title || undefined}
/>
<span>{agent?.title}</span>
</>
)}
{taskTitle && (
<>
<span className={styles.title}>
{t('builtins.lobe-group-management.inspector.executeAgentTask.task')}
</span>
<span className={highlightTextStyles.primary}>{taskTitle}</span>
</>
)}
</Flexbox>
);
}
}
const agentName = agent?.title || agentId;
@@ -60,7 +96,7 @@ export const ExecuteAgentTaskInspector = memo<BuiltinInspectorProps<ExecuteTaskP
horizontal
>
<span className={styles.title}>
{t('builtins.lobe-group-management.inspector.executeAgentTask.title')}
{t('builtins.lobe-group-management.inspector.executeAgentTask.assignTo')}
</span>
{agent && (
<Avatar
@@ -71,7 +107,15 @@ export const ExecuteAgentTaskInspector = memo<BuiltinInspectorProps<ExecuteTaskP
title={agent.title || undefined}
/>
)}
{agentName && <span className={highlightTextStyles.primary}>{agentName}</span>}
{agentName && <span>{agentName}</span>}
{taskTitle && (
<>
<span className={styles.title}>
{t('builtins.lobe-group-management.inspector.executeAgentTask.task')}
</span>
<span className={highlightTextStyles.primary}>{taskTitle}</span>
</>
)}
</Flexbox>
);
},

View File

@@ -1,15 +1,12 @@
'use client';
import { BuiltinRenderProps } from '@lobechat/types';
import { Avatar, Flexbox, Text } from '@lobehub/ui';
import { Flexbox, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Clock } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAgentGroupStore } from '@/store/agentGroup';
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
import type { ExecuteTaskParams, ExecuteTaskState } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
@@ -40,14 +37,6 @@ const ExecuteTaskRender = memo<BuiltinRenderProps<ExecuteTaskParams, ExecuteTask
({ args }) => {
const { t } = useTranslation('tool');
// Get agent info from store
const activeGroupId = useAgentGroupStore(agentGroupSelectors.activeGroupId);
const agent = useAgentGroupStore((s) =>
args?.agentId && activeGroupId
? agentGroupSelectors.getAgentByIdFromGroup(activeGroupId, args.agentId)(s)
: undefined,
);
const timeoutMinutes = args?.timeout ? Math.round(args.timeout / 60_000) : 30;
return (
@@ -55,15 +44,7 @@ const ExecuteTaskRender = memo<BuiltinRenderProps<ExecuteTaskParams, ExecuteTask
{/* Header: Agent info + Timeout */}
<Flexbox align={'center'} gap={12} horizontal justify={'space-between'}>
<Flexbox align={'center'} flex={1} gap={12} horizontal style={{ minWidth: 0 }}>
<Avatar
avatar={agent?.avatar || '🤖'}
background={agent?.backgroundColor || undefined}
size={24}
style={{ borderRadius: 8, flexShrink: 0 }}
/>
<span className={styles.agentTitle}>
{agent?.title || t('agentGroupManagement.executeTask.intervention.unknownAgent')}
</span>
<span className={styles.agentTitle}>{args?.title}</span>
</Flexbox>
<Flexbox align="center" className={styles.timeout} gap={4} horizontal>
<Clock size={14} />

View File

@@ -280,7 +280,7 @@ describe('GroupManagementExecutor', () => {
const ctx = createMockContext();
const result = await groupManagementExecutor.executeAgentTask(
{ agentId: 'agent-1', task: 'Do something' },
{ agentId: 'agent-1', task: 'Do something', title: 'Test Task' },
ctx,
);
@@ -291,6 +291,7 @@ describe('GroupManagementExecutor', () => {
agentId: 'agent-1',
task: 'Do something',
timeout: undefined,
title: 'Test Task',
type: 'executeAgentTask',
});
});
@@ -314,7 +315,7 @@ describe('GroupManagementExecutor', () => {
);
await groupManagementExecutor.executeAgentTask(
{ agentId: 'agent-1', task: 'Do something', timeout: 30000 },
{ agentId: 'agent-1', task: 'Do something', timeout: 30000, title: 'Test Task' },
ctx,
);
@@ -339,7 +340,7 @@ describe('GroupManagementExecutor', () => {
const ctx = createMockContext();
const result = await groupManagementExecutor.executeAgentTask(
{ agentId: 'agent-1', task: 'Do something' },
{ agentId: 'agent-1', task: 'Do something', title: 'Test Task' },
ctx,
);
@@ -351,7 +352,7 @@ describe('GroupManagementExecutor', () => {
const ctx = createMockContext();
const result = await groupManagementExecutor.executeAgentTask(
{ agentId: 'agent-1', task: 'Do something', timeout: 60000 },
{ agentId: 'agent-1', task: 'Do something', timeout: 60000, title: 'Test Task' },
ctx,
);
@@ -360,6 +361,7 @@ describe('GroupManagementExecutor', () => {
agentId: 'agent-1',
task: 'Do something',
timeout: 60000,
title: 'Test Task',
type: 'executeAgentTask',
});
});

View File

@@ -95,6 +95,10 @@ export const GroupManagementManifest: BuiltinToolManifest = {
description: 'The ID of the agent to execute the task.',
type: 'string',
},
title: {
description: 'Brief title describing what this task does (shown in UI).',
type: 'string',
},
task: {
description:
'Clear description of the task to perform. Be specific about expected deliverables.',
@@ -113,7 +117,7 @@ export const GroupManagementManifest: BuiltinToolManifest = {
type: 'boolean',
},
},
required: ['agentId', 'task'],
required: ['agentId', 'title', 'task'],
type: 'object',
},
},

View File

@@ -80,6 +80,8 @@ export interface ExecuteTaskParams {
skipCallSupervisor?: boolean;
task: string;
timeout?: number;
/** Brief title describing what this task does (shown in UI) */
title: string;
}
export interface TaskItem {

View File

@@ -1,22 +1,65 @@
import { type WriteLocalFileParams } from '@lobechat/electron-client-ipc';
import { type BuiltinRenderProps } from '@lobechat/types';
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { Flexbox, Highlighter, Icon, Markdown, Skeleton } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { ChevronRight } from 'lucide-react';
import path from 'path-browserify-esm';
import { memo } from 'react';
import { LocalFile, LocalFolder } from '@/features/LocalFile';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
padding: 8px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillQuaternary};
`,
previewBox: css`
overflow: hidden;
border-radius: 8px;
background: ${cssVar.colorBgContainer};
`,
}));
const WriteFile = memo<BuiltinRenderProps<WriteLocalFileParams>>(({ args }) => {
if (!args) return <Skeleton active />;
const { base, dir } = path.parse(args.path);
const ext = path.extname(args.path).slice(1).toLowerCase();
const renderContent = () => {
if (!args.content) return null;
if (ext === 'md' || ext === 'mdx') {
return (
<Markdown style={{ maxHeight: 240, overflow: 'auto', padding: '0 8px' }}>
{args.content}
</Markdown>
);
}
return (
<Highlighter
language={ext || 'text'}
showLanguage={false}
style={{ maxHeight: 240, overflow: 'auto' }}
variant={'borderless'}
wrap
>
{args.content}
</Highlighter>
);
};
return (
<Flexbox horizontal>
<LocalFolder path={dir} />
<Icon icon={ChevronRight} />
<LocalFile name={base} path={args.path} />
<Flexbox className={styles.container} gap={12}>
<Flexbox align={'center'} horizontal>
<LocalFolder path={dir} />
<Icon icon={ChevronRight} />
<LocalFile name={base} path={args.path} />
</Flexbox>
{args.content && <Flexbox className={styles.previewBox}>{renderContent()}</Flexbox>}
</Flexbox>
);
});

View File

@@ -0,0 +1,39 @@
'use client';
import { type WriteLocalFileParams } from '@lobechat/electron-client-ipc';
import { type BuiltinStreamingProps } from '@lobechat/types';
import { Highlighter, Markdown } from '@lobehub/ui';
import path from 'path-browserify-esm';
import { memo } from 'react';
export const WriteFileStreaming = memo<BuiltinStreamingProps<WriteLocalFileParams>>(({ args }) => {
const { content, path: filePath } = args || {};
// Don't render if no content yet
if (!content) return null;
const ext = path
.extname(filePath || '')
.slice(1)
.toLowerCase();
// Use Markdown for .md files, Highlighter for others
if (ext === 'md' || ext === 'mdx') {
return <Markdown style={{ overflow: 'auto' }}>{content}</Markdown>;
}
return (
<Highlighter
animated
language={ext || 'text'}
showLanguage={false}
style={{ padding: '4px 8px' }}
variant={'outlined'}
wrap
>
{content}
</Highlighter>
);
});
WriteFileStreaming.displayName = 'WriteFileStreaming';

View File

@@ -1,9 +1,11 @@
import { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
/**
* Local System Streaming Components Registry
*/
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};

View File

@@ -2,7 +2,7 @@
import { Flexbox } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { memo } from 'react';
import { Suspense, memo } from 'react';
import NavHeader from '@/features/NavHeader';
import WideScreenButton from '@/features/WideScreenContainer/WideScreenButton';
@@ -15,7 +15,9 @@ const Header = memo(() => {
right={
<Flexbox horizontal style={{ backgroundColor: cssVar.colorBgContainer }}>
<WideScreenButton />
<ShareButton />
<Suspense>
<ShareButton />
</Suspense>
</Flexbox>
}
/>

View File

@@ -70,7 +70,8 @@ export default {
'builtins.lobe-group-management.apiName.summarize': 'Summarize conversation',
'builtins.lobe-group-management.apiName.vote': 'Start vote',
'builtins.lobe-group-management.inspector.broadcast.title': 'Following Agents speak:',
'builtins.lobe-group-management.inspector.executeAgentTask.title': 'Assigning task to:',
'builtins.lobe-group-management.inspector.executeAgentTask.assignTo': 'Assign',
'builtins.lobe-group-management.inspector.executeAgentTask.task': 'task:',
'builtins.lobe-group-management.inspector.executeAgentTasks.title': 'Assigning tasks to:',
'builtins.lobe-group-management.inspector.speak.title': 'Designated Agent speaks:',
'builtins.lobe-group-management.title': 'Group Coordinator',

View File

@@ -0,0 +1,305 @@
import type { AgentState } from '@lobechat/agent-runtime';
import type { UIChatMessage } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
import { describe, expect, it, vi } from 'vitest';
import type { ChatStore } from '@/store/chat/store';
import { createGroupOrchestrationExecutors } from '../createGroupOrchestrationExecutors';
const TEST_IDS = {
GROUP_ID: 'test-group-id',
OPERATION_ID: 'test-operation-id',
ORCHESTRATION_OPERATION_ID: 'test-orchestration-operation-id',
SUPERVISOR_AGENT_ID: 'test-supervisor-agent-id',
TOPIC_ID: 'test-topic-id',
USER_MESSAGE_ID: 'test-user-message-id',
};
/**
* Create a minimal mock store for group orchestration executor tests
*/
const createMockStore = (overrides: Partial<ChatStore> = {}): ChatStore => {
const operations: Record<string, any> = {};
return {
dbMessagesMap: {},
internal_execAgentRuntime: vi.fn().mockResolvedValue(undefined),
messagesMap: {},
operations,
startOperation: vi.fn().mockImplementation((config) => {
const operationId = `op_${nanoid()}`;
const abortController = new AbortController();
operations[operationId] = {
abortController,
context: config.context || {},
id: operationId,
status: 'running',
type: config.type,
};
return { abortController, operationId };
}),
...overrides,
} as unknown as ChatStore;
};
/**
* Create initial agent state for testing
*/
const createInitialState = (overrides: Partial<AgentState> = {}): AgentState => {
return {
cost: {
calculatedAt: new Date().toISOString(),
currency: 'USD',
llm: { byModel: [], currency: 'USD', total: 0 },
tools: { byTool: [], currency: 'USD', total: 0 },
total: 0,
},
createdAt: new Date().toISOString(),
lastModified: new Date().toISOString(),
maxSteps: 10,
messages: [],
operationId: TEST_IDS.OPERATION_ID,
status: 'running',
stepCount: 0,
toolManifestMap: {},
usage: {
humanInteraction: {
approvalRequests: 0,
promptRequests: 0,
selectRequests: 0,
totalWaitingTimeMs: 0,
},
llm: { apiCalls: 0, processingTimeMs: 0, tokens: { input: 0, output: 0, total: 0 } },
tools: { byTool: [], totalCalls: 0, totalTimeMs: 0 },
},
userInterventionConfig: { allowList: [], approvalMode: 'auto' },
...overrides,
} as AgentState;
};
describe('createGroupOrchestrationExecutors', () => {
describe('call_supervisor executor', () => {
it('should NOT pass operationId to internal_execAgentRuntime (creates new child operation)', async () => {
const mockStore = createMockStore({
dbMessagesMap: {
[`group_${TEST_IDS.GROUP_ID}_${TEST_IDS.TOPIC_ID}`]: [
{
content: 'Hello',
createdAt: Date.now(),
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
updatedAt: Date.now(),
} as UIChatMessage,
],
},
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const callSupervisorExecutor = executors.call_supervisor!;
await callSupervisorExecutor(
{
payload: { round: 1, supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID },
type: 'call_supervisor',
},
createInitialState(),
);
// Verify internal_execAgentRuntime was called
expect(mockStore.internal_execAgentRuntime).toHaveBeenCalledTimes(1);
// Verify operationId is NOT passed (should be undefined)
// This ensures a new child operation is created
const callArgs = (mockStore.internal_execAgentRuntime as any).mock.calls[0][0];
expect(callArgs.operationId).toBeUndefined();
// Verify parentOperationId is passed correctly
expect(callArgs.parentOperationId).toBe(TEST_IDS.ORCHESTRATION_OPERATION_ID);
// Verify isSupervisor is passed in context
expect(callArgs.context.isSupervisor).toBe(true);
// Verify agentId is the supervisor agent id
expect(callArgs.context.agentId).toBe(TEST_IDS.SUPERVISOR_AGENT_ID);
});
it('should pass isSupervisor: true in context for supervisor messages metadata', async () => {
const mockStore = createMockStore({
dbMessagesMap: {
[`group_${TEST_IDS.GROUP_ID}_${TEST_IDS.TOPIC_ID}`]: [
{
content: 'Hello',
createdAt: Date.now(),
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
updatedAt: Date.now(),
} as UIChatMessage,
],
},
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const callSupervisorExecutor = executors.call_supervisor!;
await callSupervisorExecutor(
{
payload: { round: 1, supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID },
type: 'call_supervisor',
},
createInitialState(),
);
const callArgs = (mockStore.internal_execAgentRuntime as any).mock.calls[0][0];
// The key assertion: isSupervisor must be true
// This is used by createAgentExecutors to set metadata.isSupervisor on assistant messages
expect(callArgs.context).toMatchObject({
agentId: TEST_IDS.SUPERVISOR_AGENT_ID,
isSupervisor: true,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
});
});
});
describe('call_agent executor', () => {
it('should NOT pass operationId to internal_execAgentRuntime (creates new child operation)', async () => {
const mockStore = createMockStore({
dbMessagesMap: {
[`group_${TEST_IDS.GROUP_ID}_${TEST_IDS.TOPIC_ID}`]: [
{
content: 'Hello',
createdAt: Date.now(),
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
updatedAt: Date.now(),
} as UIChatMessage,
],
},
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
const callAgentExecutor = executors.call_agent!;
const targetAgentId = 'target-agent-id';
await callAgentExecutor(
{
payload: { agentId: targetAgentId, instruction: 'Please respond' },
type: 'call_agent',
},
createInitialState(),
);
// Verify internal_execAgentRuntime was called
expect(mockStore.internal_execAgentRuntime).toHaveBeenCalledTimes(1);
// Verify operationId is NOT passed (should be undefined)
const callArgs = (mockStore.internal_execAgentRuntime as any).mock.calls[0][0];
expect(callArgs.operationId).toBeUndefined();
// Verify parentOperationId is passed correctly
expect(callArgs.parentOperationId).toBe(TEST_IDS.ORCHESTRATION_OPERATION_ID);
// Verify subAgentId is passed (NOT isSupervisor)
expect(callArgs.context.subAgentId).toBe(targetAgentId);
expect(callArgs.context.isSupervisor).toBeUndefined();
});
});
describe('operation structure comparison', () => {
it('call_supervisor and call_agent should both create independent child operations', async () => {
const mockStore = createMockStore({
dbMessagesMap: {
[`group_${TEST_IDS.GROUP_ID}_${TEST_IDS.TOPIC_ID}`]: [
{
content: 'Hello',
createdAt: Date.now(),
id: TEST_IDS.USER_MESSAGE_ID,
role: 'user',
updatedAt: Date.now(),
} as UIChatMessage,
],
},
});
const executors = createGroupOrchestrationExecutors({
get: () => mockStore,
messageContext: {
agentId: TEST_IDS.GROUP_ID,
scope: 'group',
topicId: TEST_IDS.TOPIC_ID,
},
orchestrationOperationId: TEST_IDS.ORCHESTRATION_OPERATION_ID,
supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID,
});
// Execute call_supervisor
await executors.call_supervisor!(
{
payload: { round: 1, supervisorAgentId: TEST_IDS.SUPERVISOR_AGENT_ID },
type: 'call_supervisor',
},
createInitialState(),
);
// Execute call_agent
await executors.call_agent!(
{
payload: { agentId: 'agent-1', instruction: 'test' },
type: 'call_agent',
},
createInitialState(),
);
// Verify both were called
expect(mockStore.internal_execAgentRuntime).toHaveBeenCalledTimes(2);
// Get both call arguments
const supervisorCallArgs = (mockStore.internal_execAgentRuntime as any).mock.calls[0][0];
const agentCallArgs = (mockStore.internal_execAgentRuntime as any).mock.calls[1][0];
// Both should NOT have operationId (create new child operations)
expect(supervisorCallArgs.operationId).toBeUndefined();
expect(agentCallArgs.operationId).toBeUndefined();
// Both should have same parentOperationId (orchestration operation)
expect(supervisorCallArgs.parentOperationId).toBe(TEST_IDS.ORCHESTRATION_OPERATION_ID);
expect(agentCallArgs.parentOperationId).toBe(TEST_IDS.ORCHESTRATION_OPERATION_ID);
// Supervisor should have isSupervisor: true
expect(supervisorCallArgs.context.isSupervisor).toBe(true);
expect(agentCallArgs.context.isSupervisor).toBeUndefined();
});
});
});

View File

@@ -126,10 +126,11 @@ export const createGroupOrchestrationExecutors = (
// Execute Supervisor agent with the supervisor's agentId in context
// Mark isSupervisor=true so assistant messages get metadata.isSupervisor for UI rendering
// Note: Don't pass operationId - let it create a new child operation (same as call_agent)
// This ensures each call has its own immutable context with isSupervisor properly set
await get().internal_execAgentRuntime({
context: { ...messageContext, agentId: supervisorAgentId, isSupervisor: true },
messages,
operationId: state.operationId,
parentMessageId: lastMessage.id,
parentMessageType: lastMessage.role as 'user' | 'assistant' | 'tool',
parentOperationId: orchestrationOperationId,

View File

@@ -0,0 +1,92 @@
import { type MCPToolCallResult } from '@/libs/mcp';
import { useToolStore } from '@/store/tool';
import { type ChatToolPayload } from '@/types/message';
import { safeParseJSON } from '@/utils/safeParseJSON';
/**
* Executor function type for remote tool invocation
* @param payload - Tool call payload
* @returns Promise with MCPToolCallResult data
*/
export type RemoteToolExecutor = (payload: ChatToolPayload) => Promise<MCPToolCallResult>;
/**
* Create a failed MCPToolCallResult
*/
const createFailedResult = (
errorMessage: string,
): { content: string; error: any; state: any; success: false } => ({
content: errorMessage,
error: { message: errorMessage },
state: {},
success: false,
});
export const klavisExecutor: RemoteToolExecutor = async (p) => {
// payload.identifier 现在是存储用的 identifier如 'google-calendar'
const identifier = p.identifier;
const klavisServers = useToolStore.getState().servers || [];
const server = klavisServers.find((s) => s.identifier === identifier);
if (!server) {
return createFailedResult(`Klavis server not found: ${identifier}`);
}
// Parse arguments
const args = safeParseJSON(p.arguments) || {};
// Call Klavis tool via store action
const result = await useToolStore.getState().callKlavisTool({
serverUrl: server.serverUrl,
toolArgs: args,
toolName: p.apiName,
});
if (!result.success) {
return createFailedResult(result.error || 'Klavis tool execution failed');
}
// result.data is MCPToolCallProcessedResult from server
// Convert to MCPToolCallResult format
const toolResult = result.data;
if (toolResult) {
return {
content: toolResult.content,
error: toolResult.state?.isError ? toolResult.state : undefined,
state: toolResult.state,
success: toolResult.success,
};
}
return createFailedResult('Klavis tool returned empty result');
};
export const lobehubSkillExecutor: RemoteToolExecutor = async (p) => {
// payload.identifier is the provider id (e.g., 'linear', 'microsoft')
const provider = p.identifier;
// Parse arguments
const args = safeParseJSON(p.arguments) || {};
// Call LobeHub Skill tool via store action
const result = await useToolStore.getState().callLobehubSkillTool({
args,
provider,
toolName: p.apiName,
});
if (!result.success) {
return createFailedResult(
result.error || `LobeHub Skill tool ${provider} ${p.apiName} execution failed`,
);
}
// Convert to MCPToolCallResult format
const content = typeof result.data === 'string' ? result.data : JSON.stringify(result.data);
return {
content,
error: undefined,
state: { content: [{ text: content, type: 'text' }] },
success: true,
};
};

View File

@@ -19,6 +19,7 @@ import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
import { safeParseJSON } from '@/utils/safeParseJSON';
import { dbMessageSelectors } from '../../message/selectors';
import { RemoteToolExecutor, klavisExecutor, lobehubSkillExecutor } from './exector';
const log = debug('lobe-store:plugin-types');
@@ -86,6 +87,16 @@ export interface PluginTypesAction {
* Internal method to call plugin API
*/
internal_callPluginApi: (id: string, payload: ChatToolPayload) => Promise<string | undefined>;
/**
* Internal unified method to invoke remote tool plugins (Klavis, LobeHub Skill, etc.)
*/
internal_invokeRemoteToolPlugin: (
id: string,
payload: ChatToolPayload,
executor: RemoteToolExecutor,
logPrefix: string,
) => Promise<string | undefined>;
}
export const pluginTypes: StateCreator<
@@ -360,191 +371,21 @@ export const pluginTypes: StateCreator<
},
invokeKlavisTypePlugin: async (id, payload) => {
let data: MCPToolCallResult | undefined;
// Get message to extract sessionId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(get());
// Get abort controller from operation
const operationId = get().messageOperationMap[id];
const operation = operationId ? get().operations[operationId] : undefined;
const abortController = operation?.abortController;
log(
'[invokeKlavisTypePlugin] messageId=%s, tool=%s, operationId=%s, aborted=%s',
return get().internal_invokeRemoteToolPlugin(
id,
payload.apiName,
operationId,
abortController?.signal.aborted,
payload,
klavisExecutor,
'invokeKlavisTypePlugin',
);
try {
// payload.identifier 现在是存储用的 identifier如 'google-calendar'
const identifier = payload.identifier;
const klavisServers = useToolStore.getState().servers || [];
const server = klavisServers.find((s) => s.identifier === identifier);
if (!server) {
throw new Error(`Klavis server not found: ${identifier}`);
}
// Parse arguments
const args = safeParseJSON(payload.arguments) || {};
// Call Klavis tool via store action
const result = await useToolStore.getState().callKlavisTool({
serverUrl: server.serverUrl,
toolArgs: args,
toolName: payload.apiName,
});
if (!result.success) {
throw new Error(result.error || 'Klavis tool execution failed');
}
// result.data is MCPToolCallProcessedResult from server
// Convert to MCPToolCallResult format
const toolResult = result.data;
if (toolResult) {
data = {
content: toolResult.content,
error: toolResult.state?.isError ? toolResult.state : undefined,
state: toolResult.state,
success: toolResult.success,
};
}
} catch (error) {
console.error('[invokeKlavisTypePlugin] Error:', error);
// ignore the aborted request error
const err = error as Error;
if (err.message.includes('aborted')) {
log('[invokeKlavisTypePlugin] Request aborted: messageId=%s, tool=%s', id, payload.apiName);
} else {
const result = await messageService.updateMessageError(id, error as any, {
agentId: message?.agentId,
topicId: message?.topicId,
});
if (result?.success && result.messages) {
get().replaceMessages(result.messages, {
context: {
agentId: message?.agentId,
topicId: message?.topicId,
},
});
}
}
}
// 如果报错则结束了
if (!data) return;
// operationId already declared above, reuse it
const context = operationId ? { operationId } : undefined;
// Use optimisticUpdateToolMessage to update content and state/error in a single call
await get().optimisticUpdateToolMessage(
id,
{
content: data.content,
pluginError: data.success ? undefined : data.error,
pluginState: data.success ? data.state : undefined,
},
context,
);
return data.content;
},
invokeLobehubSkillTypePlugin: async (id, payload) => {
let data: MCPToolCallResult | undefined;
// Get message to extract sessionId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(get());
// Get abort controller from operation
const operationId = get().messageOperationMap[id];
const operation = operationId ? get().operations[operationId] : undefined;
const abortController = operation?.abortController;
log(
'[invokeLobehubSkillTypePlugin] messageId=%s, tool=%s, operationId=%s, aborted=%s',
return get().internal_invokeRemoteToolPlugin(
id,
payload.apiName,
operationId,
abortController?.signal.aborted,
payload,
lobehubSkillExecutor,
'invokeLobehubSkillTypePlugin',
);
try {
// payload.identifier is the provider id (e.g., 'linear', 'microsoft')
const provider = payload.identifier;
// Parse arguments
const args = safeParseJSON(payload.arguments) || {};
// Call LobeHub Skill tool via store action
const result = await useToolStore.getState().callLobehubSkillTool({
args,
provider,
toolName: payload.apiName,
});
if (!result.success) {
throw new Error(result.error || 'LobeHub Skill tool execution failed');
}
// Convert to MCPToolCallResult format
const content = typeof result.data === 'string' ? result.data : JSON.stringify(result.data);
data = {
content,
error: undefined,
state: { content: [{ text: content, type: 'text' }] },
success: true,
};
} catch (error) {
console.error('[invokeLobehubSkillTypePlugin] Error:', error);
// ignore the aborted request error
const err = error as Error;
if (err.message.includes('aborted')) {
log(
'[invokeLobehubSkillTypePlugin] Request aborted: messageId=%s, tool=%s',
id,
payload.apiName,
);
} else {
const result = await messageService.updateMessageError(id, error as any, {
agentId: message?.agentId,
topicId: message?.topicId,
});
if (result?.success && result.messages) {
get().replaceMessages(result.messages, {
context: {
agentId: message?.agentId,
topicId: message?.topicId,
},
});
}
}
}
// If error occurred, exit
if (!data) return;
const context = operationId ? { operationId } : undefined;
// Use optimisticUpdateToolMessage to update content and state/error in a single call
await get().optimisticUpdateToolMessage(
id,
{
content: data.content,
pluginError: data.success ? undefined : data.error,
pluginState: data.success ? data.state : undefined,
},
context,
);
return data.content;
},
invokeMarkdownTypePlugin: async (id, payload) => {
@@ -653,6 +494,70 @@ export const pluginTypes: StateCreator<
return data.content;
},
internal_invokeRemoteToolPlugin: async (id, payload, executor, logPrefix) => {
let data: MCPToolCallResult | undefined;
// Get message to extract sessionId/topicId
const message = dbMessageSelectors.getDbMessageById(id)(get());
// Get abort controller from operation
const operationId = get().messageOperationMap[id];
const operation = operationId ? get().operations[operationId] : undefined;
const abortController = operation?.abortController;
log(
'[%s] messageId=%s, tool=%s, operationId=%s, aborted=%s',
logPrefix,
id,
payload.apiName,
operationId,
abortController?.signal.aborted,
);
try {
data = await executor(payload);
} catch (error) {
console.error(`[${logPrefix}] Error:`, error);
// ignore the aborted request error
const err = error as Error;
if (err.message.includes('aborted')) {
log('[%s] Request aborted: messageId=%s, tool=%s', logPrefix, id, payload.apiName);
} else {
const result = await messageService.updateMessageError(id, error as any, {
agentId: message?.agentId,
topicId: message?.topicId,
});
if (result?.success && result.messages) {
get().replaceMessages(result.messages, {
context: {
agentId: message?.agentId,
topicId: message?.topicId,
},
});
}
}
}
// If error occurred, exit
if (!data) return;
const context = operationId ? { operationId } : undefined;
// Use optimisticUpdateToolMessage to update content and state/error in a single call
await get().optimisticUpdateToolMessage(
id,
{
content: data.content,
pluginError: data.success ? undefined : data.error,
pluginState: data.success ? data.state : undefined,
},
context,
);
return data.content;
},
internal_callPluginApi: async (id, payload) => {
const { optimisticUpdateMessageContent } = get();
let data: string;