diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index 517add44b1..ddb83e3c50 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -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", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index 9af4352172..361e8ea45c 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -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": "群组协调", diff --git a/packages/builtin-tool-group-management/src/client/Inspector/ExecuteAgentTask/index.tsx b/packages/builtin-tool-group-management/src/client/Inspector/ExecuteAgentTask/index.tsx index df1b4ce99d..b015cfbc55 100644 --- a/packages/builtin-tool-group-management/src/client/Inspector/ExecuteAgentTask/index.tsx +++ b/packages/builtin-tool-group-management/src/client/Inspector/ExecuteAgentTask/index.tsx @@ -32,6 +32,7 @@ export const ExecuteAgentTaskInspector = memo - {t('builtins.lobe-group-management.apiName.executeAgentTask')} - - ); + if (isArgumentsStreaming) { + if (!agent && !taskTitle) + return ( +
+ {t('builtins.lobe-group-management.apiName.executeAgentTask')} +
+ ); + if (agent) { + return ( + + + {t('builtins.lobe-group-management.inspector.executeAgentTask.assignTo')} + + {agent && ( + <> + + {agent?.title} + + )} + {taskTitle && ( + <> + + {t('builtins.lobe-group-management.inspector.executeAgentTask.task')} + + {taskTitle} + + )} + + ); + } } const agentName = agent?.title || agentId; @@ -60,7 +96,7 @@ export const ExecuteAgentTaskInspector = memo - {t('builtins.lobe-group-management.inspector.executeAgentTask.title')} + {t('builtins.lobe-group-management.inspector.executeAgentTask.assignTo')} {agent && ( )} - {agentName && {agentName}} + {agentName && {agentName}} + {taskTitle && ( + <> + + {t('builtins.lobe-group-management.inspector.executeAgentTask.task')} + + {taskTitle} + + )} ); }, diff --git a/packages/builtin-tool-group-management/src/client/Render/ExecuteTask/index.tsx b/packages/builtin-tool-group-management/src/client/Render/ExecuteTask/index.tsx index a6a2e76624..79f5d1a833 100644 --- a/packages/builtin-tool-group-management/src/client/Render/ExecuteTask/index.tsx +++ b/packages/builtin-tool-group-management/src/client/Render/ExecuteTask/index.tsx @@ -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 { 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 - - - {agent?.title || t('agentGroupManagement.executeTask.intervention.unknownAgent')} - + {args?.title} diff --git a/packages/builtin-tool-group-management/src/executor.test.ts b/packages/builtin-tool-group-management/src/executor.test.ts index 3e16d10694..9149c91d17 100644 --- a/packages/builtin-tool-group-management/src/executor.test.ts +++ b/packages/builtin-tool-group-management/src/executor.test.ts @@ -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', }); }); diff --git a/packages/builtin-tool-group-management/src/manifest.ts b/packages/builtin-tool-group-management/src/manifest.ts index 365e46dae3..f12fe9ed95 100644 --- a/packages/builtin-tool-group-management/src/manifest.ts +++ b/packages/builtin-tool-group-management/src/manifest.ts @@ -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', }, }, diff --git a/packages/builtin-tool-group-management/src/types.ts b/packages/builtin-tool-group-management/src/types.ts index 3c03aa6e1f..b4c89fbfd2 100644 --- a/packages/builtin-tool-group-management/src/types.ts +++ b/packages/builtin-tool-group-management/src/types.ts @@ -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 { diff --git a/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx b/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx index 4ecdaacef2..55e5124822 100644 --- a/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx +++ b/packages/builtin-tool-local-system/src/client/Render/WriteFile/index.tsx @@ -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>(({ args }) => { if (!args) return ; 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 ( + + {args.content} + + ); + } + + return ( + + {args.content} + + ); + }; return ( - - - - + + + + + + + + {args.content && {renderContent()}} ); }); diff --git a/packages/builtin-tool-local-system/src/client/Streaming/WriteFile/index.tsx b/packages/builtin-tool-local-system/src/client/Streaming/WriteFile/index.tsx new file mode 100644 index 0000000000..c3dcf7c5fe --- /dev/null +++ b/packages/builtin-tool-local-system/src/client/Streaming/WriteFile/index.tsx @@ -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>(({ 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 {content}; + } + + return ( + + {content} + + ); +}); + +WriteFileStreaming.displayName = 'WriteFileStreaming'; diff --git a/packages/builtin-tool-local-system/src/client/Streaming/index.ts b/packages/builtin-tool-local-system/src/client/Streaming/index.ts index 278353ddb6..032ebd9fca 100644 --- a/packages/builtin-tool-local-system/src/client/Streaming/index.ts +++ b/packages/builtin-tool-local-system/src/client/Streaming/index.ts @@ -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, }; diff --git a/src/app/[variants]/(main)/group/features/Conversation/Header/index.tsx b/src/app/[variants]/(main)/group/features/Conversation/Header/index.tsx index a8f6762b94..01077b3593 100644 --- a/src/app/[variants]/(main)/group/features/Conversation/Header/index.tsx +++ b/src/app/[variants]/(main)/group/features/Conversation/Header/index.tsx @@ -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={ - + + + } /> diff --git a/src/locales/default/plugin.ts b/src/locales/default/plugin.ts index 97a0bbe163..159677fec8 100644 --- a/src/locales/default/plugin.ts +++ b/src/locales/default/plugin.ts @@ -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', diff --git a/src/store/chat/agents/GroupOrchestration/__tests__/call-supervisor.test.ts b/src/store/chat/agents/GroupOrchestration/__tests__/call-supervisor.test.ts new file mode 100644 index 0000000000..436d626d49 --- /dev/null +++ b/src/store/chat/agents/GroupOrchestration/__tests__/call-supervisor.test.ts @@ -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 => { + const operations: Record = {}; + + 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 => { + 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(); + }); + }); +}); diff --git a/src/store/chat/agents/GroupOrchestration/createGroupOrchestrationExecutors.ts b/src/store/chat/agents/GroupOrchestration/createGroupOrchestrationExecutors.ts index a1dfd54ef9..69f3c297d3 100644 --- a/src/store/chat/agents/GroupOrchestration/createGroupOrchestrationExecutors.ts +++ b/src/store/chat/agents/GroupOrchestration/createGroupOrchestrationExecutors.ts @@ -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, diff --git a/src/store/chat/slices/plugin/actions/exector.ts b/src/store/chat/slices/plugin/actions/exector.ts new file mode 100644 index 0000000000..e99f6d23cf --- /dev/null +++ b/src/store/chat/slices/plugin/actions/exector.ts @@ -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; + +/** + * 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, + }; +}; diff --git a/src/store/chat/slices/plugin/actions/pluginTypes.ts b/src/store/chat/slices/plugin/actions/pluginTypes.ts index da127c1bb8..723d4af705 100644 --- a/src/store/chat/slices/plugin/actions/pluginTypes.ts +++ b/src/store/chat/slices/plugin/actions/pluginTypes.ts @@ -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; + + /** + * Internal unified method to invoke remote tool plugins (Klavis, LobeHub Skill, etc.) + */ + internal_invokeRemoteToolPlugin: ( + id: string, + payload: ChatToolPayload, + executor: RemoteToolExecutor, + logPrefix: string, + ) => Promise; } 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;