From 46b95cd65b7e12e108a2c52c151e8232973526a7 Mon Sep 17 00:00:00 2001 From: linonetwo Date: Mon, 8 Dec 2025 21:29:48 +0800 Subject: [PATCH] refactor: simplify tool writing --- .github/mcp.json | 12 + package.json | 1 + src/__tests__/__mocks__/services-i18n.ts | 17 + src/__tests__/setup-vitest.ts | 1 + .../PromptPreviewDialog.promptConcat.test.tsx | 9 +- .../__tests__/taskAgent.test.ts | 10 +- .../agentFrameworks/taskAgent.ts | 8 +- src/services/agentInstance/index.ts | 8 +- .../agentInstance/promptConcat/Readme.md | 92 ++- .../infrastructure/agentStatus.ts | 48 ++ .../promptConcat/infrastructure/index.ts | 24 + .../infrastructure/messagePersistence.ts | 128 ++++ .../infrastructure/streamingResponse.ts | 160 ++++ .../promptConcat/modifiers/defineModifier.ts | 301 ++++++++ .../promptConcat/modifiers/dynamicPosition.ts | 96 +++ .../promptConcat/modifiers/fullReplacement.ts | 145 ++++ .../promptConcat/modifiers/index.ts | 34 + .../promptConcat/promptConcat.ts | 6 +- .../promptConcat/promptConcatSchema/index.ts | 7 +- .../{plugin.ts => tools.ts} | 10 +- .../promptConcat/responseConcat.ts | 4 +- .../fullReplacementPlugin.duration.test.ts | 12 +- .../__tests__/messageManagementPlugin.test.ts | 4 +- .../tools/__tests__/wikiSearchPlugin.test.ts | 10 +- .../agentInstance/tools/defineTool.ts | 677 +++++++++++++++++ src/services/agentInstance/tools/index.ts | 207 ++---- .../agentInstance/tools/messageManagement.ts | 269 ------- src/services/agentInstance/tools/prompt.ts | 298 -------- .../agentInstance/tools/wikiOperation.ts | 488 ++++--------- .../agentInstance/tools/wikiSearch.ts | 687 +++--------------- .../agentInstance/tools/workspacesList.ts | 164 ++--- .../__tests__/schemaToToolContent.test.ts | 37 +- 32 files changed, 2178 insertions(+), 1796 deletions(-) create mode 100644 .github/mcp.json create mode 100644 src/__tests__/__mocks__/services-i18n.ts create mode 100644 src/services/agentInstance/promptConcat/infrastructure/agentStatus.ts create mode 100644 src/services/agentInstance/promptConcat/infrastructure/index.ts create mode 100644 src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts create mode 100644 src/services/agentInstance/promptConcat/infrastructure/streamingResponse.ts create mode 100644 src/services/agentInstance/promptConcat/modifiers/defineModifier.ts create mode 100644 src/services/agentInstance/promptConcat/modifiers/dynamicPosition.ts create mode 100644 src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts create mode 100644 src/services/agentInstance/promptConcat/modifiers/index.ts rename src/services/agentInstance/promptConcat/promptConcatSchema/{plugin.ts => tools.ts} (84%) create mode 100644 src/services/agentInstance/tools/defineTool.ts delete mode 100644 src/services/agentInstance/tools/messageManagement.ts delete mode 100644 src/services/agentInstance/tools/prompt.ts diff --git a/.github/mcp.json b/.github/mcp.json new file mode 100644 index 00000000..54cf13fd --- /dev/null +++ b/.github/mcp.json @@ -0,0 +1,12 @@ +{ + "servers": { + "chromedevtools/chrome-devtools-mcp": { + "type": "stdio", + "command": "pnpm dlx", + "args": [ + "chrome-devtools-mcp@latest", + "--browserUrl=http://localhost:9222" + ] + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index f4aeee26..a3b2cc9d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start:dev": "cross-env NODE_ENV=development electron-forge start", "clean": "pnpm run clean:cache && rimraf -- ./out ./logs ./userData-dev ./userData-test ./wiki-dev ./wiki-test ./node_modules/tiddlywiki/plugins/linonetwo", "clean:cache": "rimraf -- ./node_modules/.vite .vite", + "start:dev:mcp": "cross-env NODE_ENV=development DEBUG_WORKER=true electron-forge start --remote-debugging-port=9222", "start:dev:debug-worker": "cross-env NODE_ENV=development DEBUG_WORKER=true electron-forge start", "start:dev:debug-main": "cross-env NODE_ENV=development DEBUG_MAIN=true electron-forge start", "start:dev:debug-vite": "cross-env NODE_ENV=development DEBUG=electron-forge:* electron-forge start", diff --git a/src/__tests__/__mocks__/services-i18n.ts b/src/__tests__/__mocks__/services-i18n.ts new file mode 100644 index 00000000..c0c3e224 --- /dev/null +++ b/src/__tests__/__mocks__/services-i18n.ts @@ -0,0 +1,17 @@ +/** + * Mock for @services/libs/i18n + */ +import { vi } from 'vitest'; + +export const i18n = { + t: vi.fn((key: string) => { + // Return the key itself as fallback + return key; + }), + changeLanguage: vi.fn(), + language: 'en', +}; + +export const placeholder = { + t: (key: string) => key, +}; diff --git a/src/__tests__/setup-vitest.ts b/src/__tests__/setup-vitest.ts index 3dd37f20..79700836 100644 --- a/src/__tests__/setup-vitest.ts +++ b/src/__tests__/setup-vitest.ts @@ -12,6 +12,7 @@ import './__mocks__/services-container'; import { vi } from 'vitest'; vi.mock('react-i18next', () => import('./__mocks__/react-i18next')); vi.mock('@services/libs/log', () => import('./__mocks__/services-log')); +vi.mock('@services/libs/i18n', () => import('./__mocks__/services-i18n')); /** * Mock the `electron` module for testing diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx index 58e1b88a..dfc7e879 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/__tests__/PromptPreviewDialog.promptConcat.test.tsx @@ -279,16 +279,15 @@ describe('PromptPreviewDialog - Tool Information Rendering', () => { // Check for wiki search tool insertion (from wikiSearch plugin) let wikiSearchElement: IPrompt | undefined = childrenAfterBeforeTool.find((c: IPrompt) => { const body = `${c.caption ?? ''} ${c.text ?? ''}`; - return /Available Tools:/i.test(body) || /Tool ID:\s*wiki-search/i.test(body) || /wiki-search/i.test(body); + return /wiki-search/i.test(body); }); if (!wikiSearchElement) { - wikiSearchElement = findPromptNodeByText(result?.processedPrompts, /Available Tools:/i) || findPromptNodeByText(result?.processedPrompts, /Tool ID:\s*wiki-search/i) || - findPromptNodeByText(result?.processedPrompts, /wiki-search/i); + wikiSearchElement = findPromptNodeByText(result?.processedPrompts, /wiki-search/i); } expect(wikiSearchElement).toBeDefined(); const wikiSearchText = `${wikiSearchElement?.caption ?? ''} ${wikiSearchElement?.text ?? ''}`; - expect(wikiSearchText).toContain('Wiki search tool'); - expect(wikiSearchText).toContain('## wiki-search'); + // Check that wiki-search tool is mentioned in the content (either as tool ID or in description) + expect(wikiSearchText.toLowerCase()).toContain('wiki-search'); // Verify the order: before-tool -> workspaces -> wiki-operation -> wiki-search -> post-tool const postToolElement: IPrompt | undefined = toolsSection?.children?.find((c: IPrompt) => c.id === 'default-post-tool'); diff --git a/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.test.ts b/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.test.ts index 03d7eb11..233a16de 100644 --- a/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.test.ts +++ b/src/services/agentInstance/agentFrameworks/__tests__/taskAgent.test.ts @@ -29,7 +29,7 @@ let testStreamResponses: Array<{ status: string; content: string; requestId: str // Import plugin components for direct testing import type { IPromptConcatTool } from '@services/agentInstance/promptConcat/promptConcatSchema'; import type { IDatabaseService } from '@services/database/interface'; -import { createAgentFrameworkHooks, createHooksWithTools, initializeToolSystem, PromptConcatHookContext } from '../../tools/index'; +import { createAgentFrameworkHooks, createHooksWithPlugins, initializePluginSystem, PromptConcatHookContext } from '../../tools/index'; import { wikiSearchTool } from '../../tools/wikiSearch'; import { basicPromptConcatHandler } from '../taskAgent'; import type { AgentFrameworkContext } from '../utilities/type'; @@ -42,7 +42,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { const { container } = await import('@services/container'); // Ensure built-in tool registry includes all built-in tools - await initializeToolSystem(); + await initializePluginSystem(); // Prepare a mock DataSource/repository so AgentInstanceService.initialize() can run const mockRepo = { @@ -127,8 +127,8 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { ], }; - // Create hooks and register tools as defined in agentFrameworkConfig - const { hooks: promptHooks } = await createHooksWithTools(agentFrameworkConfig); + // Create hooks and register plugins as defined in agentFrameworkConfig + const { hooks: promptHooks } = await createHooksWithPlugins(agentFrameworkConfig); // First run workspacesList tool to inject available workspaces (if present) const workspacesPlugin = agentFrameworkConfig.plugins?.find(p => p.toolId === 'workspacesList'); if (workspacesPlugin) { @@ -200,7 +200,7 @@ describe('WikiSearch Plugin Integration & YieldNextRound Mechanism', () => { }; // Use hooks registered with all plugins import { AgentFrameworkConfig } - const { hooks: responseHooks } = await createHooksWithTools(agentFrameworkConfig); + const { hooks: responseHooks } = await createHooksWithPlugins(agentFrameworkConfig); // Execute the response complete hook await responseHooks.responseComplete.promise(responseContext); // reuse containerForAssert from above assertions diff --git a/src/services/agentInstance/agentFrameworks/taskAgent.ts b/src/services/agentInstance/agentFrameworks/taskAgent.ts index c4eb3cfe..8c02d228 100644 --- a/src/services/agentInstance/agentFrameworks/taskAgent.ts +++ b/src/services/agentInstance/agentFrameworks/taskAgent.ts @@ -5,10 +5,10 @@ import serviceIdentifier from '@services/serviceIdentifier'; import { merge } from 'lodash'; import type { AgentInstanceLatestStatus, AgentInstanceMessage, IAgentInstanceService } from '../interface'; import { AgentFrameworkConfig, AgentPromptDescription, AiAPIConfig } from '../promptConcat/promptConcatSchema'; -import type { IPromptConcatTool } from '../promptConcat/promptConcatSchema/plugin'; +import type { IPromptConcatTool } from '../promptConcat/promptConcatSchema/tools'; import { responseConcat } from '../promptConcat/responseConcat'; import { getFinalPromptResult } from '../promptConcat/utilities'; -import { createHooksWithTools } from '../tools'; +import { createHooksWithPlugins } from '../tools'; import { YieldNextRoundTarget } from '../tools/types'; import { canceled, completed, error, working } from './utilities/statusUtilities'; import { AgentFrameworkContext } from './utilities/type'; @@ -32,7 +32,7 @@ export async function* basicPromptConcatHandler(context: AgentFrameworkContext) let currentRequestId: string | undefined; const lastUserMessage: AgentInstanceMessage | undefined = context.agent.messages[context.agent.messages.length - 1]; // Create and register handler hooks based on framework config - const { hooks: agentFrameworkHooks, toolConfigs } = await createHooksWithTools(context.agentDef.agentFrameworkConfig || {}); + const { hooks: agentFrameworkHooks, pluginConfigs } = await createHooksWithPlugins(context.agentDef.agentFrameworkConfig || {}); // Log the start of handler execution with context information logger.debug('Starting prompt handler execution', { @@ -175,7 +175,7 @@ export async function* basicPromptConcatHandler(context: AgentFrameworkContext) response, requestId: currentRequestId, isFinal: true, - toolConfig: (toolConfigs.length > 0 ? toolConfigs[0] : {}) as IPromptConcatTool, // First config for compatibility + toolConfig: (pluginConfigs.length > 0 ? pluginConfigs[0] : {}) as IPromptConcatTool, // First config for compatibility agentFrameworkConfig: context.agentDef.agentFrameworkConfig, // Pass complete config for tool access actions: undefined as { yieldNextRoundTo?: 'self' | 'human'; newUserMessage?: string } | undefined, }; diff --git a/src/services/agentInstance/index.ts b/src/services/agentInstance/index.ts index b6314e02..34b87155 100644 --- a/src/services/agentInstance/index.ts +++ b/src/services/agentInstance/index.ts @@ -10,7 +10,7 @@ import type { AgentFramework, AgentFrameworkContext } from '@services/agentInsta import { promptConcatStream, PromptConcatStreamState } from '@services/agentInstance/promptConcat/promptConcat'; import type { AgentPromptDescription } from '@services/agentInstance/promptConcat/promptConcatSchema'; import { getPromptConcatAgentFrameworkConfigJsonSchema } from '@services/agentInstance/promptConcat/promptConcatSchema/jsonSchema'; -import { createHooksWithTools, initializeToolSystem } from '@services/agentInstance/tools'; +import { createHooksWithPlugins, initializePluginSystem } from '@services/agentInstance/tools'; import type { IDatabaseService } from '@services/database/interface'; import { AgentInstanceEntity, AgentInstanceMessageEntity } from '@services/database/schema/agent'; import { logger } from '@services/libs/log'; @@ -65,7 +65,7 @@ export class AgentInstanceService implements IAgentInstanceService { public async initializeFrameworks(): Promise { try { // Register tools to global registry once during initialization - await initializeToolSystem(); + await initializePluginSystem(); logger.debug('AgentInstance Tool system initialized and tools registered to global registry'); // Register built-in frameworks @@ -379,8 +379,8 @@ export class AgentInstanceService implements IAgentInstanceService { isCancelled: () => cancelToken.value, }; - // Create fresh hooks for this framework execution and register tools based on frameworkConfig - const { hooks: frameworkHooks } = await createHooksWithTools(agentDefinition.agentFrameworkConfig || {}); + // Create fresh hooks for this framework execution and register plugins based on frameworkConfig + const { hooks: frameworkHooks } = await createHooksWithPlugins(agentDefinition.agentFrameworkConfig || {}); // Trigger userMessageReceived hook with the configured tools await frameworkHooks.userMessageReceived.promise({ diff --git a/src/services/agentInstance/promptConcat/Readme.md b/src/services/agentInstance/promptConcat/Readme.md index 8e8178f5..7c92a1ec 100644 --- a/src/services/agentInstance/promptConcat/Readme.md +++ b/src/services/agentInstance/promptConcat/Readme.md @@ -14,43 +14,99 @@ The `promptConcat` function uses a tapable hooks-based tool system. Built-in too - `processPrompts`: Modifies prompt tree during processing - `finalizePrompts`: Final processing before LLM call - `postProcess`: Handles response processing + - `responseComplete`: Called when AI response is done (for tool execution) 2. **Built-in Tools**: - `fullReplacement`: Replaces content from various sources - `dynamicPosition`: Inserts content at specific positions - - `retrievalAugmentedGeneration`: Retrieves content from wiki/external sources + - `wikiSearch`: Search wiki content with filter or vector search + - `wikiOperation`: Create, update, delete tiddlers + - `workspacesList`: Inject available workspaces into prompts - `modelContextProtocol`: Integrates with external MCP servers - - `toolCalling`: Processes function calls in responses 3. **Tool Registration**: - - Tools are registered by `toolId` field in the `plugins` array + - Tools created with `registerToolDefinition` are auto-registered - Each tool instance has its own configuration parameters - - Built-in tools are auto-registered on system initialization -### Tool Lifecycle +### Adding New Tools (New API) -1. **Registration**: Tools are registered during initialization -2. **Configuration**: Tools are loaded based on `agentFrameworkConfig.plugins` array -3. **Execution**: Hooks execute tools in registration order -4. **Error Handling**: Individual tool failures don't stop the pipeline +Use the `registerToolDefinition` function for a declarative, low-boilerplate approach: -### Adding New Tools +```typescript +import { z } from 'zod/v4'; +import { registerToolDefinition } from './defineTool'; -1. Create tool function in `tools/` directory -2. Register in `tools/index.ts` -3. Add `toolId` to schema enum -4. Add parameter schema if needed +// 1. Define config schema (user-configurable in UI) +const MyToolConfigSchema = z.object({ + targetId: z.string(), + enabled: z.boolean().optional().default(true), +}); -Each tool receives a hooks object and registers handlers for specific hook points. Tools can modify prompt trees, inject content, process responses, and trigger additional LLM calls. +// 2. Define LLM-callable tool schema (injected into prompts) +const MyLLMToolSchema = z.object({ + query: z.string(), + limit: z.number().optional().default(10), +}).meta({ + title: 'my-tool', // Tool name for LLM + description: 'Search for something', + examples: [{ query: 'example', limit: 5 }], +}); -### Example Tool Structure +// 3. Register the tool +const myToolDef = registerToolDefinition({ + toolId: 'myTool', + displayName: 'My Tool', + description: 'Does something useful', + configSchema: MyToolConfigSchema, + llmToolSchemas: { + 'my-tool': MyLLMToolSchema, + }, + + // Called during prompt processing + onProcessPrompts({ config, injectToolList, injectContent }) { + // Inject tool description into prompts + injectToolList({ + targetId: config.targetId, + position: 'after', + }); + }, + + // Called when AI response is complete + async onResponseComplete({ toolCall, executeToolCall }) { + if (toolCall?.toolId !== 'my-tool') return; + + await executeToolCall('my-tool', async (params) => { + // Execute the tool and return result + const result = await doSomething(params.query, params.limit); + return { success: true, data: result }; + }); + }, +}); + +export const myTool = myToolDef.tool; +``` + +### Handler Context Utilities + +The `defineTool` API provides helpful utilities: + +- `findPrompt(id)` - Find a prompt by ID in the tree +- `injectToolList(options)` - Inject LLM tool schemas at a position +- `injectContent(options)` - Inject arbitrary content +- `executeToolCall(toolName, executor)` - Execute and handle tool results +- `addToolResult(options)` - Manually add a tool result message +- `yieldToSelf()` - Signal the agent should continue with another round + +### Legacy Tool Structure + +For more complex scenarios, you can still use the raw tapable hooks: ```typescript export const myTool: PromptConcatTool = (hooks) => { hooks.processPrompts.tapAsync('myTool', async (context, callback) => { - const { tool, prompts, messages } = context; + const { toolConfig, prompts, messages } = context; // Tool logic here - callback(null, context); + callback(); }); }; ``` diff --git a/src/services/agentInstance/promptConcat/infrastructure/agentStatus.ts b/src/services/agentInstance/promptConcat/infrastructure/agentStatus.ts new file mode 100644 index 00000000..80fab114 --- /dev/null +++ b/src/services/agentInstance/promptConcat/infrastructure/agentStatus.ts @@ -0,0 +1,48 @@ +/** + * Agent Status Infrastructure + * + * Handles agent status updates and persistence. + * This is core infrastructure, not a user-configurable plugin. + */ +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IAgentInstanceService } from '../../interface'; +import type { AgentStatusContext, PromptConcatHooks } from '../../tools/types'; + +/** + * Register agent status handlers to hooks + */ +export function registerAgentStatus(hooks: PromptConcatHooks): void { + // Handle agent status persistence + hooks.agentStatusChanged.tapAsync('agentStatus', async (context: AgentStatusContext, callback) => { + try { + const { agentFrameworkContext, status } = context; + + // Get the agent instance service to update status + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + // Update agent status in database + await agentInstanceService.updateAgent(agentFrameworkContext.agent.id, { + status, + }); + + // Update the agent object for immediate use + agentFrameworkContext.agent.status = status; + + logger.debug('Agent status updated in database', { + agentId: agentFrameworkContext.agent.id, + state: status.state, + }); + + callback(); + } catch (error) { + logger.error('Agent status error in agentStatusChanged', { + error, + agentId: context.agentFrameworkContext.agent.id, + status: context.status, + }); + callback(); + } + }); +} diff --git a/src/services/agentInstance/promptConcat/infrastructure/index.ts b/src/services/agentInstance/promptConcat/infrastructure/index.ts new file mode 100644 index 00000000..45bddb91 --- /dev/null +++ b/src/services/agentInstance/promptConcat/infrastructure/index.ts @@ -0,0 +1,24 @@ +/** + * Core Infrastructure + * + * Core infrastructure components that are always active, not user-configurable. + * These handle message persistence, streaming updates, agent status, and UI sync. + */ +import type { PromptConcatHooks } from '../../tools/types'; +import { registerAgentStatus } from './agentStatus'; +import { registerMessagePersistence } from './messagePersistence'; +import { registerStreamingResponse } from './streamingResponse'; + +export { registerAgentStatus } from './agentStatus'; +export { registerMessagePersistence } from './messagePersistence'; +export { registerStreamingResponse } from './streamingResponse'; + +/** + * Register all core infrastructure to hooks. + * This should be called once when creating hooks for an agent. + */ +export function registerCoreInfrastructure(hooks: PromptConcatHooks): void { + registerMessagePersistence(hooks); + registerStreamingResponse(hooks); + registerAgentStatus(hooks); +} diff --git a/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts b/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts new file mode 100644 index 00000000..fe7a8b7d --- /dev/null +++ b/src/services/agentInstance/promptConcat/infrastructure/messagePersistence.ts @@ -0,0 +1,128 @@ +/** + * Message Persistence Infrastructure + * + * Handles persisting messages to the database. + * This is core infrastructure, not a user-configurable plugin. + */ +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IAgentInstanceService } from '../../interface'; +import type { AIResponseContext, PromptConcatHooks, ToolExecutionContext, UserMessageContext } from '../../tools/types'; +import { createAgentMessage } from '../../utilities'; + +/** + * Register message persistence handlers to hooks + */ +export function registerMessagePersistence(hooks: PromptConcatHooks): void { + // Handle user message persistence + hooks.userMessageReceived.tapAsync('messagePersistence', async (context: UserMessageContext, callback) => { + try { + const { agentFrameworkContext, content, messageId } = context; + + // Create user message using the helper function + const userMessage = createAgentMessage(messageId, agentFrameworkContext.agent.id, { + role: 'user', + content: content.text, + contentType: 'text/plain', + metadata: content.file ? { file: content.file } : undefined, + duration: undefined, // User messages persist indefinitely by default + }); + + // Add message to the agent's message array for immediate use + agentFrameworkContext.agent.messages.push(userMessage); + + // Get the agent instance service to access repositories + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + // Save user message to database + await agentInstanceService.saveUserMessage(userMessage); + + logger.debug('User message persisted to database', { + messageId, + agentId: agentFrameworkContext.agent.id, + contentLength: content.text.length, + }); + + callback(); + } catch (error) { + logger.error('Message persistence error in userMessageReceived', { + error, + messageId: context.messageId, + agentId: context.agentFrameworkContext.agent.id, + }); + callback(); + } + }); + + // Handle AI response completion persistence + hooks.responseComplete.tapAsync('messagePersistence', async (context: AIResponseContext, callback) => { + try { + const { agentFrameworkContext, response } = context; + + if (response.status === 'done' && response.content) { + // Find the AI message that needs to be persisted + const aiMessage = agentFrameworkContext.agent.messages.find( + (message) => message.role === 'assistant' && message.metadata?.isComplete && !message.metadata?.isPersisted, + ); + + if (aiMessage) { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + await agentInstanceService.saveUserMessage(aiMessage); + aiMessage.metadata = { ...aiMessage.metadata, isPersisted: true }; + + logger.debug('AI response message persisted', { + messageId: aiMessage.id, + contentLength: response.content.length, + }); + } + } + + callback(); + } catch (error) { + logger.error('Message persistence error in responseComplete', { error }); + callback(); + } + }); + + // Handle tool result messages persistence + hooks.toolExecuted.tapAsync('messagePersistence', async (context: ToolExecutionContext, callback) => { + try { + const { agentFrameworkContext } = context; + + // Find newly added tool result messages that need to be persisted + const newToolResultMessages = agentFrameworkContext.agent.messages.filter( + (message) => message.metadata?.isToolResult && !message.metadata.isPersisted, + ); + + if (newToolResultMessages.length === 0) { + callback(); + return; + } + + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + for (const message of newToolResultMessages) { + try { + await agentInstanceService.saveUserMessage(message); + message.metadata = { ...message.metadata, isPersisted: true }; + + logger.debug('Tool result message persisted', { + messageId: message.id, + toolId: message.metadata.toolId, + }); + } catch (serviceError) { + logger.error('Failed to persist tool result message', { + error: serviceError, + messageId: message.id, + }); + } + } + + callback(); + } catch (error) { + logger.error('Message persistence error in toolExecuted', { error }); + callback(); + } + }); +} diff --git a/src/services/agentInstance/promptConcat/infrastructure/streamingResponse.ts b/src/services/agentInstance/promptConcat/infrastructure/streamingResponse.ts new file mode 100644 index 00000000..e4d29514 --- /dev/null +++ b/src/services/agentInstance/promptConcat/infrastructure/streamingResponse.ts @@ -0,0 +1,160 @@ +/** + * Streaming Response Infrastructure + * + * Handles streaming AI responses: creating/updating messages during streaming + * and finalizing them when complete. + * This is core infrastructure, not a user-configurable plugin. + */ +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IAgentInstanceService } from '../../interface'; +import type { AIResponseContext, PromptConcatHooks } from '../../tools/types'; + +/** + * Register streaming response handlers to hooks + */ +export function registerStreamingResponse(hooks: PromptConcatHooks): void { + // Handle AI response updates during streaming + hooks.responseUpdate.tapAsync('streamingResponse', async (context: AIResponseContext, callback) => { + try { + const { agentFrameworkContext, response } = context; + + if (response.status === 'update' && response.content) { + // Find or create AI response message in agent's message array + let aiMessage = agentFrameworkContext.agent.messages.find( + (message) => message.role === 'assistant' && !message.metadata?.isComplete, + ); + + if (!aiMessage) { + // Create new AI message for streaming updates + const now = new Date(); + aiMessage = { + id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + agentId: agentFrameworkContext.agent.id, + role: 'assistant', + content: response.content, + created: now, + modified: now, + metadata: { isComplete: false }, + duration: undefined, + }; + agentFrameworkContext.agent.messages.push(aiMessage); + + // Persist immediately so DB timestamp reflects conversation order + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + await agentInstanceService.saveUserMessage(aiMessage); + aiMessage.metadata = { ...aiMessage.metadata, isPersisted: true }; + } catch (persistError) { + logger.warn('Failed to persist initial streaming AI message', { + error: persistError, + messageId: aiMessage.id, + }); + } + } else { + // Update existing message content + aiMessage.content = response.content; + aiMessage.modified = new Date(); + } + + // Update UI using the agent instance service + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + agentInstanceService.debounceUpdateMessage(aiMessage, agentFrameworkContext.agent.id); + } catch (serviceError) { + logger.warn('Failed to update UI for streaming message', { + error: serviceError, + messageId: aiMessage.id, + }); + } + } + } catch (error) { + logger.error('Streaming response error in responseUpdate', { error }); + } finally { + callback(); + } + }); + + // Handle AI response completion + hooks.responseComplete.tapAsync('streamingResponse', async (context: AIResponseContext, callback) => { + try { + const { agentFrameworkContext, response } = context; + + if (response.status === 'done' && response.content) { + // Find and finalize AI response message + let aiMessage = agentFrameworkContext.agent.messages.find( + (message) => message.role === 'assistant' && !message.metadata?.isComplete && !message.metadata?.isToolResult, + ); + + if (aiMessage) { + // Mark as complete and update final content + aiMessage.content = response.content; + aiMessage.modified = new Date(); + aiMessage.metadata = { ...aiMessage.metadata, isComplete: true }; + } else { + // Create final message if streaming message wasn't found + const nowFinal = new Date(); + aiMessage = { + id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + agentId: agentFrameworkContext.agent.id, + role: 'assistant', + content: response.content, + created: nowFinal, + modified: nowFinal, + metadata: { isComplete: true }, + duration: undefined, + }; + agentFrameworkContext.agent.messages.push(aiMessage); + } + + // Final UI update + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + agentInstanceService.debounceUpdateMessage(aiMessage, agentFrameworkContext.agent.id); + } catch (serviceError) { + logger.warn('Failed to update UI for completed message', { + error: serviceError, + messageId: aiMessage.id, + }); + } + + logger.debug('AI response message completed', { + messageId: aiMessage.id, + finalContentLength: response.content.length, + }); + } + + callback(); + } catch (error) { + logger.error('Streaming response error in responseComplete', { error }); + callback(); + } + }); + + // Handle tool result UI updates + hooks.toolExecuted.tapAsync('streamingResponse-ui', async (context, callback) => { + try { + const { agentFrameworkContext } = context; + + // Find tool result messages that need UI update + const messagesNeedingUiUpdate = agentFrameworkContext.agent.messages.filter( + (message) => message.metadata?.isToolResult && !message.metadata?.uiUpdated, + ); + + if (messagesNeedingUiUpdate.length > 0) { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + + for (const message of messagesNeedingUiUpdate) { + agentInstanceService.debounceUpdateMessage(message, agentFrameworkContext.agent.id); + message.metadata = { ...message.metadata, uiUpdated: true }; + } + } + + callback(); + } catch (error) { + logger.error('Streaming response error in toolExecuted UI update', { error }); + callback(); + } + }); +} diff --git a/src/services/agentInstance/promptConcat/modifiers/defineModifier.ts b/src/services/agentInstance/promptConcat/modifiers/defineModifier.ts new file mode 100644 index 00000000..27cf8105 --- /dev/null +++ b/src/services/agentInstance/promptConcat/modifiers/defineModifier.ts @@ -0,0 +1,301 @@ +/** + * Modifier Definition Framework + * + * Provides a declarative API for defining prompt modifiers with minimal boilerplate. + * Modifiers only transform the prompt tree - they don't involve LLM tool calling. + * + * Unlike LLM tools, modifiers: + * - Only work with processPrompts and postProcess hooks + * - Don't inject tool descriptions or handle tool calls + * - Are focused on prompt tree transformations + */ +import { logger } from '@services/libs/log'; +import type { z } from 'zod/v4'; +import type { AgentInstanceMessage } from '../../interface'; +import type { PostProcessContext, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool } from '../../tools/types'; +import { findPromptById } from '../promptConcat'; +import type { IPrompt } from '../promptConcatSchema'; + +/** + * Modifier definition configuration + */ +export interface ModifierDefinition { + /** Unique modifier identifier */ + modifierId: string; + + /** Display name for UI */ + displayName: string; + + /** Description of what this modifier does */ + description: string; + + /** Schema for modifier configuration parameters */ + configSchema: TConfigSchema; + + /** + * Called during prompt processing phase. + * Use this to modify the prompt tree. + */ + onProcessPrompts?: (context: ModifierHandlerContext) => Promise | void; + + /** + * Called during post-processing phase. + * Use this to transform LLM responses. + */ + onPostProcess?: (context: PostProcessModifierContext) => Promise | void; +} + +/** + * Context passed to prompt processing handlers + */ +export interface ModifierHandlerContext { + /** The parsed configuration for this modifier instance */ + config: z.infer; + + /** Full modifier configuration object (includes extra fields like content, caption) */ + modifierConfig: PromptConcatHookContext['toolConfig']; + + /** Current prompt tree (mutable) */ + prompts: IPrompt[]; + + /** Message history */ + messages: AgentInstanceMessage[]; + + /** Agent framework context */ + agentFrameworkContext: PromptConcatHookContext['agentFrameworkContext']; + + /** Utility: Find a prompt by ID */ + findPrompt: (id: string) => ReturnType; + + /** Utility: Insert content at a position */ + insertContent: (options: InsertContentOptions) => void; + + /** Utility: Replace prompt content */ + replaceContent: (targetId: string, content: string | IPrompt[]) => boolean; +} + +/** + * Context passed to post-process handlers + */ +export interface PostProcessModifierContext extends Omit, 'prompts'> { + /** LLM response text */ + llmResponse: string; + + /** Processed responses array (mutable) */ + responses: PostProcessContext['responses']; +} + +/** + * Options for inserting content + */ +export interface InsertContentOptions { + /** Target prompt ID */ + targetId: string; + + /** Position: 'before'/'after' as sibling, 'child' adds to children */ + position: 'before' | 'after' | 'child'; + + /** Content to insert (string or prompt object) */ + content: string | IPrompt; + + /** Optional caption */ + caption?: string; + + /** Optional ID for the new prompt */ + id?: string; +} + +/** + * Create a modifier from a definition. + */ +export function defineModifier( + definition: ModifierDefinition, +): { + modifier: PromptConcatTool; + modifierId: string; + configSchema: TConfigSchema; + displayName: string; + description: string; +} { + const { modifierId, configSchema, onProcessPrompts, onPostProcess } = definition; + + // The parameter key in config (e.g., 'fullReplacementParam' for 'fullReplacement') + const parameterKey = `${modifierId}Param`; + + const modifier: PromptConcatTool = (hooks: PromptConcatHooks) => { + // Register processPrompts handler + if (onProcessPrompts) { + hooks.processPrompts.tapAsync(`${modifierId}-processPrompts`, async (context, callback) => { + try { + const { toolConfig, prompts, messages, agentFrameworkContext } = context; + + // Skip if this config doesn't match our modifierId + if (toolConfig.toolId !== modifierId) { + callback(); + return; + } + + // Get the typed config + const rawConfig = (toolConfig as Record)[parameterKey]; + if (!rawConfig) { + callback(); + return; + } + + // Parse and validate config + const config = configSchema.parse(rawConfig) as z.infer; + + // Build handler context with utilities + const handlerContext: ModifierHandlerContext = { + config, + modifierConfig: toolConfig, + prompts, + messages, + agentFrameworkContext, + + findPrompt: (id: string) => findPromptById(prompts, id), + + insertContent: (options: InsertContentOptions) => { + const target = findPromptById(prompts, options.targetId); + if (!target) { + logger.warn('Target prompt not found for content insertion', { + targetId: options.targetId, + modifierId, + }); + return; + } + + const newPrompt: IPrompt = typeof options.content === 'string' + ? { + id: options.id ?? `${modifierId}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + caption: options.caption ?? 'Inserted Content', + text: options.content, + } + : options.content; + + if (options.position === 'child') { + if (!target.prompt.children) { + target.prompt.children = []; + } + target.prompt.children.push(newPrompt); + } else if (options.position === 'before') { + target.parent.splice(target.index, 0, newPrompt); + } else { + target.parent.splice(target.index + 1, 0, newPrompt); + } + }, + + replaceContent: (targetId: string, content: string | IPrompt[]) => { + const target = findPromptById(prompts, targetId); + if (!target) { + logger.warn('Target prompt not found for replacement', { targetId, modifierId }); + return false; + } + + if (typeof content === 'string') { + target.prompt.text = content; + delete target.prompt.children; + } else { + delete target.prompt.text; + target.prompt.children = content; + } + return true; + }, + }; + + await onProcessPrompts(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${modifierId} processPrompts handler`, { error }); + callback(); + } + }); + } + + // Register postProcess handler + if (onPostProcess) { + hooks.postProcess.tapAsync(`${modifierId}-postProcess`, async (context, callback) => { + try { + const { toolConfig, messages, agentFrameworkContext, llmResponse, responses } = context; + + if (toolConfig.toolId !== modifierId) { + callback(); + return; + } + + const rawConfig = (toolConfig as Record)[parameterKey]; + if (!rawConfig) { + callback(); + return; + } + + const config = configSchema.parse(rawConfig) as z.infer; + + const handlerContext: PostProcessModifierContext = { + config, + modifierConfig: toolConfig, + messages, + agentFrameworkContext, + llmResponse, + responses, + + findPrompt: () => undefined, // Not available in postProcess + + insertContent: () => { + logger.warn('insertContent is not available in postProcess phase'); + }, + + replaceContent: () => { + logger.warn('replaceContent is not available in postProcess phase'); + return false; + }, + }; + + await onPostProcess(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${modifierId} postProcess handler`, { error }); + callback(); + } + }); + } + }; + + return { + modifier, + modifierId, + configSchema, + displayName: definition.displayName, + description: definition.description, + }; +} + +/** + * Registry for modifiers + */ +const modifierRegistry = new Map>(); + +/** + * Register a modifier definition + */ +export function registerModifier( + definition: ModifierDefinition, +): ReturnType> { + const modifierDefinition = defineModifier(definition); + modifierRegistry.set(modifierDefinition.modifierId, modifierDefinition as ReturnType); + return modifierDefinition; +} + +/** + * Get all registered modifiers + */ +export function getAllModifiers(): Map> { + return modifierRegistry; +} + +/** + * Get a modifier by ID + */ +export function getModifier(modifierId: string): ReturnType | undefined { + return modifierRegistry.get(modifierId); +} diff --git a/src/services/agentInstance/promptConcat/modifiers/dynamicPosition.ts b/src/services/agentInstance/promptConcat/modifiers/dynamicPosition.ts new file mode 100644 index 00000000..8daebff8 --- /dev/null +++ b/src/services/agentInstance/promptConcat/modifiers/dynamicPosition.ts @@ -0,0 +1,96 @@ +/** + * Dynamic Position Modifier + * + * Inserts content at a specific position relative to a target element. + */ +import { logger } from '@services/libs/log'; +import { identity } from 'lodash'; +import { z } from 'zod/v4'; +import type { IPrompt } from '../promptConcatSchema'; +import { registerModifier } from './defineModifier'; + +const t = identity; + +/** + * Dynamic Position Parameter Schema + */ +export const DynamicPositionParameterSchema = z.object({ + targetId: z.string().meta({ + title: t('Schema.Position.TargetIdTitle'), + description: t('Schema.Position.TargetId'), + }), + position: z.enum(['before', 'after', 'relative']).meta({ + title: t('Schema.Position.TypeTitle'), + description: t('Schema.Position.Type'), + }), +}).meta({ + title: t('Schema.Position.Title'), + description: t('Schema.Position.Description'), +}); + +export type DynamicPositionParameter = z.infer; + +export function getDynamicPositionParameterSchema() { + return DynamicPositionParameterSchema; +} + +/** + * Dynamic Position Modifier Definition + */ +const dynamicPositionDefinition = registerModifier({ + modifierId: 'dynamicPosition', + displayName: 'Dynamic Position', + description: 'Insert content at a specific position relative to a target element', + configSchema: DynamicPositionParameterSchema, + + onProcessPrompts({ config, modifierConfig, findPrompt }) { + // dynamicPosition requires content from modifierConfig + if (!modifierConfig.content) { + return; + } + + const { targetId, position } = config; + const found = findPrompt(targetId); + + if (!found) { + logger.warn('Target prompt not found for dynamicPosition', { + targetId, + modifierId: modifierConfig.id, + }); + return; + } + + const newPart: IPrompt = { + id: `dynamic-${modifierConfig.id}-${Date.now()}`, + caption: modifierConfig.caption ?? 'Dynamic Content', + text: modifierConfig.content, + }; + + switch (position) { + case 'before': + found.parent.splice(found.index, 0, newPart); + break; + case 'after': + found.parent.splice(found.index + 1, 0, newPart); + break; + case 'relative': + if (!found.prompt.children) { + found.prompt.children = []; + } + found.prompt.children.push(newPart); + break; + default: + logger.warn(`Unknown position: ${position as string}`); + return; + } + + logger.debug('Dynamic position insertion completed', { + targetId, + position, + contentLength: modifierConfig.content.length, + modifierId: modifierConfig.id, + }); + }, +}); + +export const dynamicPositionModifier = dynamicPositionDefinition.modifier; diff --git a/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts b/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts new file mode 100644 index 00000000..4552d33e --- /dev/null +++ b/src/services/agentInstance/promptConcat/modifiers/fullReplacement.ts @@ -0,0 +1,145 @@ +/** + * Full Replacement Modifier + * + * Replaces target prompt content with content from specified source. + * Supports: historyOfSession, llmResponse + */ +import { logger } from '@services/libs/log'; +import { cloneDeep, identity } from 'lodash'; +import { z } from 'zod/v4'; +import type { AgentResponse } from '../../tools/types'; +import { filterMessagesByDuration } from '../../utilities/messageDurationFilter'; +import { normalizeRole } from '../../utilities/normalizeRole'; +import type { IPrompt } from '../promptConcatSchema'; +import { registerModifier } from './defineModifier'; + +const t = identity; + +/** + * Full Replacement Parameter Schema + */ +export const FullReplacementParameterSchema = z.object({ + targetId: z.string().meta({ + title: t('Schema.FullReplacement.TargetIdTitle'), + description: t('Schema.FullReplacement.TargetId'), + }), + sourceType: z.enum(['historyOfSession', 'llmResponse']).meta({ + title: t('Schema.FullReplacement.SourceTypeTitle'), + description: t('Schema.FullReplacement.SourceType'), + }), +}).meta({ + title: t('Schema.FullReplacement.Title'), + description: t('Schema.FullReplacement.Description'), +}); + +export type FullReplacementParameter = z.infer; + +export function getFullReplacementParameterSchema() { + return FullReplacementParameterSchema; +} + +/** + * Full Replacement Modifier Definition + */ +const fullReplacementDefinition = registerModifier({ + modifierId: 'fullReplacement', + displayName: 'Full Replacement', + description: 'Replace target content with content from specified source', + configSchema: FullReplacementParameterSchema, + + onProcessPrompts({ config, modifierConfig, findPrompt, messages }) { + const { targetId, sourceType } = config; + const found = findPrompt(targetId); + + if (!found) { + logger.warn('Target prompt not found for fullReplacement', { + targetId, + modifierId: modifierConfig.id, + }); + return; + } + + // Only handle historyOfSession in processPrompts phase + if (sourceType !== 'historyOfSession') { + return; + } + + // Get all messages except the last user message being processed + const messagesCopy = cloneDeep(messages); + + // Find and remove the last user message (which is being processed in this round) + let lastUserMessageIndex = -1; + for (let index = messagesCopy.length - 1; index >= 0; index--) { + if (messagesCopy[index].role === 'user') { + lastUserMessageIndex = index; + break; + } + } + + if (lastUserMessageIndex >= 0) { + messagesCopy.splice(lastUserMessageIndex, 1); + logger.debug('Removed current user message from history', { + removedMessageId: messages[lastUserMessageIndex].id, + remainingMessages: messagesCopy.length, + }); + } + + // Apply duration filtering to exclude expired messages + const filteredHistory = filterMessagesByDuration(messagesCopy); + + if (filteredHistory.length > 0) { + found.prompt.children = []; + filteredHistory.forEach((message, index: number) => { + type PromptRole = NonNullable; + const role: PromptRole = normalizeRole(message.role); + delete found.prompt.text; + found.prompt.children!.push({ + id: `history-${index}`, + caption: `History message ${index + 1}`, + role, + text: message.content, + }); + }); + } else { + found.prompt.text = '无聊天历史。'; + } + + logger.debug('Full replacement completed in prompt phase', { targetId, sourceType }); + }, + + onPostProcess({ config, modifierConfig, llmResponse, responses }) { + const { targetId, sourceType } = config; + + if (sourceType !== 'llmResponse') { + return; + } + + if (!responses) { + logger.warn('No responses available in postProcess phase', { + targetId, + modifierId: modifierConfig.id, + }); + return; + } + + const found = responses.find((r: AgentResponse) => r.id === targetId); + + if (!found) { + logger.warn('Full replacement target not found in responses', { + targetId, + modifierId: modifierConfig.id, + }); + return; + } + + found.text = llmResponse; + + logger.debug('Full replacement completed in response phase', { + targetId, + sourceType, + modifierId: modifierConfig.id, + }); + }, +}); + +export const fullReplacementModifier = fullReplacementDefinition.modifier; diff --git a/src/services/agentInstance/promptConcat/modifiers/index.ts b/src/services/agentInstance/promptConcat/modifiers/index.ts new file mode 100644 index 00000000..f23d2d36 --- /dev/null +++ b/src/services/agentInstance/promptConcat/modifiers/index.ts @@ -0,0 +1,34 @@ +/** + * Prompt Modifiers + * + * Modifiers transform the prompt tree without involving LLM tool calling. + * They work in the processPrompts and postProcess phases only. + */ + +// Re-export defineModifier API +export { defineModifier, getAllModifiers, getModifier, registerModifier } from './defineModifier'; +export type { InsertContentOptions, ModifierDefinition, ModifierHandlerContext, PostProcessModifierContext } from './defineModifier'; + +// Export modifiers +export { fullReplacementModifier, FullReplacementParameterSchema, getFullReplacementParameterSchema } from './fullReplacement'; +export type { FullReplacementParameter } from './fullReplacement'; + +export { dynamicPositionModifier, DynamicPositionParameterSchema, getDynamicPositionParameterSchema } from './dynamicPosition'; +export type { DynamicPositionParameter } from './dynamicPosition'; + +// Import for registration side effects +import { getAllModifiers } from './defineModifier'; +import './fullReplacement'; +import './dynamicPosition'; + +/** + * Get all registered modifier functions as a map + */ +export function getModifierFunctions(): Map void> { + const modifiers = getAllModifiers(); + const result = new Map void>(); + for (const [id, definition] of modifiers) { + result.set(id, definition.modifier); + } + return result; +} diff --git a/src/services/agentInstance/promptConcat/promptConcat.ts b/src/services/agentInstance/promptConcat/promptConcat.ts index 83758a21..48ae9656 100644 --- a/src/services/agentInstance/promptConcat/promptConcat.ts +++ b/src/services/agentInstance/promptConcat/promptConcat.ts @@ -18,9 +18,9 @@ import { ModelMessage } from 'ai'; import { cloneDeep } from 'lodash'; import { AgentFrameworkContext } from '../agentFrameworks/utilities/type'; import { AgentInstanceMessage } from '../interface'; -import { builtInTools, createAgentFrameworkHooks, PromptConcatHookContext } from '../tools'; +import { createAgentFrameworkHooks, pluginRegistry, PromptConcatHookContext } from '../tools'; import type { AgentPromptDescription, IPrompt } from './promptConcatSchema'; -import type { IPromptConcatTool } from './promptConcatSchema/plugin'; +import type { IPromptConcatTool } from './promptConcatSchema/tools'; /** * Context type specific for prompt concatenation operations @@ -216,7 +216,7 @@ export async function* promptConcatStream( const hooks = createAgentFrameworkHooks(); // Register tools that match the configuration for (const tool of toolConfigs) { - const builtInTool = builtInTools.get(tool.toolId); + const builtInTool = pluginRegistry.get(tool.toolId); if (builtInTool) { builtInTool(hooks); logger.debug('Registered tool', { diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts index 08071d34..e9ed15b9 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/index.ts @@ -131,14 +131,9 @@ export type DefaultAgents = z.infer>; export type AgentPromptDescription = z.infer>; export type AiAPIConfig = z.infer; export type AgentFrameworkConfig = z.infer>; -// Backward compat aliases - deprecated, use AgentFrameworkConfig directly -export type HandlerConfig = AgentFrameworkConfig; // Re-export all schemas and types export * from './modelParameters'; -export * from './plugin'; export * from './prompts'; export * from './response'; - -// Export IPromptConcatTool as IPromptConcatPlugin for backward compatibility -export type { IPromptConcatTool as IPromptConcatPlugin } from './plugin'; +export * from './tools'; diff --git a/src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts b/src/services/agentInstance/promptConcat/promptConcatSchema/tools.ts similarity index 84% rename from src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts rename to src/services/agentInstance/promptConcat/promptConcatSchema/tools.ts index 57d6c643..7dea3a0e 100644 --- a/src/services/agentInstance/promptConcat/promptConcatSchema/plugin.ts +++ b/src/services/agentInstance/promptConcat/promptConcatSchema/tools.ts @@ -1,12 +1,14 @@ // Import parameter types from plugin files +// Modifiers +import type { DynamicPositionParameter, FullReplacementParameter } from '../modifiers'; +// LLM Tools import type { ModelContextProtocolParameter } from '@services/agentInstance/tools/modelContextProtocol'; -import type { DynamicPositionParameter, FullReplacementParameter } from '@services/agentInstance/tools/prompt'; import type { WikiOperationParameter } from '@services/agentInstance/tools/wikiOperation'; import type { WikiSearchParameter } from '@services/agentInstance/tools/wikiSearch'; import type { WorkspacesListParameter } from '@services/agentInstance/tools/workspacesList'; /** - * Type definition for prompt concat tool + * Type definition for prompt concat plugin (both modifiers and LLM tools) * This includes all possible parameter fields for type safety */ export type IPromptConcatTool = { @@ -16,9 +18,11 @@ export type IPromptConcatTool = { forbidOverrides?: boolean; toolId: string; - // Tool-specific parameters + // Modifier parameters fullReplacementParam?: FullReplacementParameter; dynamicPositionParam?: DynamicPositionParameter; + + // LLM Tool parameters wikiOperationParam?: WikiOperationParameter; wikiSearchParam?: WikiSearchParameter; workspacesListParam?: WorkspacesListParameter; diff --git a/src/services/agentInstance/promptConcat/responseConcat.ts b/src/services/agentInstance/promptConcat/responseConcat.ts index 8eaa6afb..7ca358d9 100644 --- a/src/services/agentInstance/promptConcat/responseConcat.ts +++ b/src/services/agentInstance/promptConcat/responseConcat.ts @@ -8,7 +8,7 @@ import { logger } from '@services/libs/log'; import { cloneDeep } from 'lodash'; import { AgentFrameworkContext } from '../agentFrameworks/utilities/type'; import { AgentInstanceMessage } from '../interface'; -import { builtInTools, createAgentFrameworkHooks } from '../tools'; +import { createAgentFrameworkHooks, pluginRegistry } from '../tools'; import { AgentResponse, PostProcessContext, YieldNextRoundTarget } from '../tools/types'; import type { IPromptConcatTool } from './promptConcatSchema'; import { AgentFrameworkConfig, AgentPromptDescription } from './promptConcatSchema'; @@ -47,7 +47,7 @@ export async function responseConcat( const hooks = createAgentFrameworkHooks(); // Register all tools from configuration for (const tool of toolConfigs) { - const builtInTool = builtInTools.get(tool.toolId); + const builtInTool = pluginRegistry.get(tool.toolId); if (builtInTool) { builtInTool(hooks); } else { diff --git a/src/services/agentInstance/tools/__tests__/fullReplacementPlugin.duration.test.ts b/src/services/agentInstance/tools/__tests__/fullReplacementPlugin.duration.test.ts index f3ef08bf..d43bd592 100644 --- a/src/services/agentInstance/tools/__tests__/fullReplacementPlugin.duration.test.ts +++ b/src/services/agentInstance/tools/__tests__/fullReplacementPlugin.duration.test.ts @@ -10,8 +10,8 @@ import type { IPrompt } from '../../promptConcat/promptConcatSchema/prompts'; import { cloneDeep } from 'lodash'; import defaultAgents from '../../agentFrameworks/taskAgents.json'; +import { fullReplacementModifier } from '../../promptConcat/modifiers/fullReplacement'; import { createAgentFrameworkHooks, PromptConcatHookContext } from '../index'; -import { fullReplacementTool } from '../prompt'; // Use the real agent config const exampleAgent = defaultAgents[0]; @@ -113,7 +113,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { }; const hooks = createAgentFrameworkHooks(); - fullReplacementTool(hooks); + fullReplacementModifier(hooks); // Execute the processPrompts hook await hooks.processPrompts.promise(context); @@ -126,8 +126,8 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { const targetPrompt = historyPrompt!.children?.find(child => child.id === targetId); expect(targetPrompt).toBeDefined(); - // The fullReplacementTool puts filtered messages in children array - // Note: fullReplacementTool removes the last message (current user message) + // The fullReplacementModifier puts filtered messages in children array + // Note: fullReplacementModifier removes the last message (current user message) const children = (targetPrompt as unknown as { children?: IPrompt[] }).children || []; expect(children.length).toBe(2); // Only non-expired messages (user1, ai-response), excluding last user message @@ -201,7 +201,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { }; const hooks = createAgentFrameworkHooks(); - fullReplacementTool(hooks); + fullReplacementModifier(hooks); await hooks.processPrompts.promise(context); @@ -283,7 +283,7 @@ describe('Full Replacement Plugin - Duration Mechanism', () => { }; const hooks = createAgentFrameworkHooks(); - fullReplacementTool(hooks); + fullReplacementModifier(hooks); await hooks.processPrompts.promise(context); diff --git a/src/services/agentInstance/tools/__tests__/messageManagementPlugin.test.ts b/src/services/agentInstance/tools/__tests__/messageManagementPlugin.test.ts index a02e50d8..de1c210a 100644 --- a/src/services/agentInstance/tools/__tests__/messageManagementPlugin.test.ts +++ b/src/services/agentInstance/tools/__tests__/messageManagementPlugin.test.ts @@ -10,8 +10,8 @@ import { DataSource } from 'typeorm'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import defaultAgents from '../../agentFrameworks/taskAgents.json'; import type { AgentInstanceMessage, IAgentInstanceService } from '../../interface'; +import { registerCoreInfrastructure } from '../../promptConcat/infrastructure'; import { createAgentFrameworkHooks } from '../index'; -import { messageManagementTool } from '../messageManagement'; import type { ToolExecutionContext, UserMessageContext } from '../types'; // Use the real agent config from taskAgents.json @@ -70,7 +70,7 @@ describe('Message Management Plugin - Real Database Integration', () => { // Initialize plugin hooks = createAgentFrameworkHooks(); - messageManagementTool(hooks); + registerCoreInfrastructure(hooks); }); afterEach(async () => { diff --git a/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts b/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts index 3260bcee..cdd4e0d3 100644 --- a/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts +++ b/src/services/agentInstance/tools/__tests__/wikiSearchPlugin.test.ts @@ -19,8 +19,8 @@ import type { IPrompt } from '@services/agentInstance/promptConcat/promptConcatS import type { IPromptConcatTool } from '@services/agentInstance/promptConcat/promptConcatSchema'; import { cloneDeep } from 'lodash'; import defaultAgents from '../../agentFrameworks/taskAgents.json'; +import { registerCoreInfrastructure } from '../../promptConcat/infrastructure'; import { createAgentFrameworkHooks, PromptConcatHookContext } from '../index'; -import { messageManagementTool } from '../messageManagement'; import { wikiSearchTool } from '../wikiSearch'; // Mock i18n @@ -321,7 +321,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { expect(toolResultMessage.content).toContain('Tool: wiki-search'); expect(toolResultMessage.content).toContain('Important Note 1'); expect(toolResultMessage.metadata?.isToolResult).toBe(true); - expect(toolResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially + // Note: isPersisted may be true due to immediate async persistence in new defineTool API expect(toolResultMessage.duration).toBe(1); // Tool result uses configurable toolResultDuration (default 1) // Check that previous user message is unchanged @@ -406,7 +406,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { expect(errorResultMessage.content).toContain('工作空间名称或ID'); expect(errorResultMessage.metadata?.isToolResult).toBe(true); expect(errorResultMessage.metadata?.isError).toBe(true); - expect(errorResultMessage.metadata?.isPersisted).toBe(false); // Should be false initially + // Note: isPersisted may be true due to immediate async persistence in new defineTool API expect(errorResultMessage.duration).toBe(1); // Now uses configurable toolResultDuration (default 1) }); @@ -891,7 +891,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { const hooks = createAgentFrameworkHooks(); wikiSearchTool(hooks); - messageManagementTool(hooks); + registerCoreInfrastructure(hooks); await hooks.responseComplete.promise(context); @@ -901,7 +901,7 @@ describe('Wiki Search Plugin - Comprehensive Tests', () => { const toolResultMessage = agentFrameworkContext.agent.messages[1] as AgentInstanceMessage; expect(toolResultMessage.metadata?.isToolResult).toBe(true); - expect(toolResultMessage.metadata?.isPersisted).toBe(true); // Should be true after messageManagementTool processing + expect(toolResultMessage.metadata?.isPersisted).toBe(true); // Should be true after infrastructure processing }); it('should prevent regression: tool result not filtered in second round', async () => { diff --git a/src/services/agentInstance/tools/defineTool.ts b/src/services/agentInstance/tools/defineTool.ts new file mode 100644 index 00000000..f9e91728 --- /dev/null +++ b/src/services/agentInstance/tools/defineTool.ts @@ -0,0 +1,677 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-type-conversion */ +/** + * Tool Definition Framework + * + * Provides a declarative API for defining LLM agent tools with minimal boilerplate. + * Tools are defined using a configuration object that specifies: + * - Schema for configuration parameters (shown in UI for users to configure) + * - Schema for LLM-callable tool parameters (injected into prompts) + * - Hook handlers for different lifecycle events + * + * This replaces the verbose tapAsync pattern with a cleaner functional approach. + */ +import { type ToolCallingMatch } from '@services/agentDefinition/interface'; +import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { z } from 'zod/v4'; +import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; +import { findPromptById } from '../promptConcat/promptConcat'; +import type { IPrompt } from '../promptConcat/promptConcatSchema'; +import { schemaToToolContent } from '../utilities/schemaToToolContent'; +import type { AIResponseContext, PostProcessContext, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool } from './types'; + +/** + * Tool definition configuration + */ +export interface ToolDefinition< + TConfigSchema extends z.ZodType = z.ZodType, + TLLMToolSchemas extends Record = Record, +> { + /** Unique tool identifier - must match the toolId used in agent configuration */ + toolId: string; + + /** Display name for UI */ + displayName: string; + + /** Description of what this tool does */ + description: string; + + /** Schema for tool configuration parameters (user-configurable in UI) */ + configSchema: TConfigSchema; + + /** + * Optional schemas for LLM-callable tools. + * Each key is the tool name (e.g., 'wiki-search'), value is the parameter schema. + * The schema's title meta will be used as the tool name in prompts. + */ + llmToolSchemas?: TLLMToolSchemas; + + /** + * Called during prompt processing phase. + * Use this to inject tool descriptions, modify prompts, etc. + */ + onProcessPrompts?: (context: ToolHandlerContext) => Promise | void; + + /** + * Called after LLM generates a response. + * Use this to parse tool calls, execute tools, etc. + */ + onResponseComplete?: (context: ResponseHandlerContext) => Promise | void; + + /** + * Called during post-processing phase. + * Use this to transform LLM responses, etc. + */ + onPostProcess?: (context: PostProcessHandlerContext) => Promise | void; +} + +/** + * Context passed to prompt processing handlers + */ +export interface ToolHandlerContext { + /** The parsed configuration for this tool instance */ + config: z.infer; + + /** Full tool configuration object */ + toolConfig: PromptConcatHookContext['toolConfig']; + + /** Current prompt tree (mutable) */ + prompts: IPrompt[]; + + /** Message history */ + messages: AgentInstanceMessage[]; + + /** Agent framework context */ + agentFrameworkContext: PromptConcatHookContext['agentFrameworkContext']; + + /** Utility: Find a prompt by ID */ + findPrompt: (id: string) => ReturnType; + + /** Utility: Inject a tool list at a target position */ + injectToolList: (options: InjectToolListOptions) => void; + + /** Utility: Inject content at a target position */ + injectContent: (options: InjectContentOptions) => void; +} + +/** + * Context passed to response handlers + */ +export interface ResponseHandlerContext< + TConfigSchema extends z.ZodType, + TLLMToolSchemas extends Record, +> extends Omit, 'prompts' | 'config'> { + /** The parsed configuration for this tool instance (may be undefined if no config provided) */ + config: z.infer | undefined; + + /** AI response content */ + response: AIResponseContext['response']; + + /** Parsed tool call from response (if any) */ + toolCall: ToolCallingMatch | null; + + /** Full agent framework config for accessing other tool configs */ + agentFrameworkConfig: AIResponseContext['agentFrameworkConfig']; + + /** Utility: Execute a tool call and handle the result */ + executeToolCall: ( + toolName: TToolName, + executor: (parameters: z.infer) => Promise, + ) => Promise; + + /** Utility: Add a tool result message */ + addToolResult: (options: AddToolResultOptions) => void; + + /** Utility: Signal that the agent should continue with another round */ + yieldToSelf: () => void; + + /** Raw hooks for advanced usage */ + hooks: PromptConcatHooks; + + /** Request ID for tracking */ + requestId?: string; +} + +/** + * Context passed to post-process handlers + */ +export interface PostProcessHandlerContext extends Omit, never> { + /** LLM response text */ + llmResponse: string; + + /** Processed responses array (mutable) */ + responses: PostProcessContext['responses']; +} + +/** + * Options for injecting tool list into prompts + */ +export interface InjectToolListOptions { + /** Target prompt ID to inject relative to */ + targetId: string; + + /** Position relative to target: 'before'/'after' inserts as sibling, 'child' adds to children */ + position: 'before' | 'after' | 'child'; + + /** Tool schemas to inject (will use all llmToolSchemas if not specified) */ + toolSchemas?: z.ZodType[]; + + /** Optional caption for the injected prompt */ + caption?: string; +} + +/** + * Options for injecting content into prompts + */ +export interface InjectContentOptions { + /** Target prompt ID to inject relative to */ + targetId: string; + + /** Position relative to target */ + position: 'before' | 'after' | 'child'; + + /** Content to inject */ + content: string; + + /** Caption for the injected prompt */ + caption?: string; + + /** Optional ID for the injected prompt */ + id?: string; +} + +/** + * Options for adding tool result messages + */ +export interface AddToolResultOptions { + /** Tool name */ + toolName: string; + + /** Tool parameters */ + parameters: unknown; + + /** Result content */ + result: string; + + /** Whether this is an error result */ + isError?: boolean; + + /** How many rounds this result should be visible */ + duration?: number; +} + +/** + * Result from tool execution + */ +export interface ToolExecutionResult { + success: boolean; + data?: string; + error?: string; + metadata?: Record; +} + +/** + * Create a tool from a definition. + * Returns both the tool function and metadata for registration. + */ +export function defineTool< + TConfigSchema extends z.ZodType, + TLLMToolSchemas extends Record = Record, +>(definition: ToolDefinition): { + tool: PromptConcatTool; + toolId: string; + configSchema: TConfigSchema; + llmToolSchemas: TLLMToolSchemas | undefined; + displayName: string; + description: string; +} { + const { toolId, configSchema, llmToolSchemas, onProcessPrompts, onResponseComplete, onPostProcess } = definition; + + // The parameter key in toolConfig (e.g., 'wikiSearchParam' for 'wikiSearch') + const parameterKey = `${toolId}Param`; + + const tool: PromptConcatTool = (hooks) => { + // Register processPrompts handler + if (onProcessPrompts) { + hooks.processPrompts.tapAsync(`${toolId}-processPrompts`, async (context, callback) => { + try { + const { toolConfig, prompts, messages, agentFrameworkContext } = context; + + // Skip if this tool config doesn't match our toolId + if (toolConfig.toolId !== toolId) { + callback(); + return; + } + + // Get the typed config + const rawConfig = (toolConfig as Record)[parameterKey]; + if (!rawConfig) { + callback(); + return; + } + + // Parse and validate config + const config = configSchema.parse(rawConfig) as z.infer; + + // Build handler context with utilities + const handlerContext: ToolHandlerContext = { + config, + toolConfig, + prompts, + messages, + agentFrameworkContext, + + findPrompt: (id: string) => findPromptById(prompts, id), + + injectToolList: (options: InjectToolListOptions) => { + const target = findPromptById(prompts, options.targetId); + if (!target) { + logger.warn(`Target prompt not found for tool list injection`, { + targetId: options.targetId, + toolId, + }); + return; + } + + // Generate tool content from schemas + const schemas = options.toolSchemas ?? (llmToolSchemas ? Object.values(llmToolSchemas) : []); + const toolContent = schemas.map((schema) => schemaToToolContent(schema)).join('\n\n'); + + const toolPrompt: IPrompt = { + id: `${toolId}-tool-list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + text: toolContent, + caption: options.caption ?? `${definition.displayName} Tools`, + enabled: true, + }; + + if (options.position === 'child') { + // Add to target's children + if (!target.prompt.children) { + target.prompt.children = []; + } + target.prompt.children.push(toolPrompt); + } else if (options.position === 'before') { + target.parent.splice(target.index, 0, toolPrompt); + } else { + target.parent.splice(target.index + 1, 0, toolPrompt); + } + + logger.debug(`Tool list injected`, { + targetId: options.targetId, + position: options.position, + toolId, + }); + }, + + injectContent: (options: InjectContentOptions) => { + const target = findPromptById(prompts, options.targetId); + if (!target) { + logger.warn(`Target prompt not found for content injection`, { + targetId: options.targetId, + toolId, + }); + return; + } + + const contentPrompt: IPrompt = { + id: options.id ?? `${toolId}-content-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + text: options.content, + caption: options.caption ?? 'Injected Content', + enabled: true, + }; + + if (options.position === 'child') { + if (!target.prompt.children) { + target.prompt.children = []; + } + target.prompt.children.push(contentPrompt); + } else if (options.position === 'before') { + target.parent.splice(target.index, 0, contentPrompt); + } else { + target.parent.splice(target.index + 1, 0, contentPrompt); + } + + logger.debug(`Content injected`, { + targetId: options.targetId, + position: options.position, + toolId, + }); + }, + }; + + await onProcessPrompts(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${toolId} processPrompts handler`, { error }); + callback(); + } + }); + } + + // Register responseComplete handler + if (onResponseComplete) { + hooks.responseComplete.tapAsync(`${toolId}-responseComplete`, async (context, callback) => { + try { + const { agentFrameworkContext, response, agentFrameworkConfig, requestId, toolConfig: directToolConfig } = context as AIResponseContext & { + toolConfig?: PromptConcatHookContext['toolConfig']; + }; + + // Find our tool's config - first try agentFrameworkConfig.plugins, then fall back to direct toolConfig + let ourToolConfig = agentFrameworkConfig?.plugins?.find( + (p: { toolId: string }) => p.toolId === toolId, + ); + + // Fall back to direct toolConfig if provided (for backward compatibility with tests) + if (!ourToolConfig && directToolConfig?.toolId === toolId) { + ourToolConfig = directToolConfig; + } + + // Skip if this tool is not configured for this agent + if (!ourToolConfig) { + callback(); + return; + } + + // Skip if response is not complete + if (response.status !== 'done' || !response.content) { + callback(); + return; + } + + // Parse tool call from response + const toolMatch = matchToolCalling(response.content); + const toolCall = toolMatch.found ? toolMatch : null; + + // Try to parse config (may be empty for tools that only handle LLM tool calls) + const rawConfig = (ourToolConfig as Record)[parameterKey]; + let config: z.infer | undefined; + if (rawConfig) { + try { + config = configSchema.parse(rawConfig) as z.infer; + } catch (parseError) { + logger.warn(`Failed to parse config for ${toolId}`, { parseError }); + } + } + + // Build handler context + const handlerContext: ResponseHandlerContext = { + config, + toolConfig: ourToolConfig as PromptConcatHookContext['toolConfig'], + messages: agentFrameworkContext.agent.messages, + agentFrameworkContext, + response, + toolCall, + agentFrameworkConfig, + hooks, + requestId, + + findPrompt: () => undefined, // Not available in response phase + + injectToolList: () => { + logger.warn('injectToolList is not available in response phase'); + }, + + injectContent: () => { + logger.warn('injectContent is not available in response phase'); + }, + + executeToolCall: async ( + toolName: TToolName, + executor: (parameters: z.infer) => Promise, + ): Promise => { + if (!toolCall || toolCall.toolId !== toolName) { + return false; + } + + const toolSchema = llmToolSchemas?.[toolName]; + if (!toolSchema) { + logger.error(`No schema found for tool: ${String(toolName)}`); + return false; + } + + try { + // Validate parameters + const validatedParameters = toolSchema.parse(toolCall.parameters); + + // Execute the tool + const result = await executor(validatedParameters); + + // Add result message + const toolResultDuration = (config as { toolResultDuration?: number } | undefined)?.toolResultDuration ?? 1; + handlerContext.addToolResult({ + toolName: toolName, + parameters: validatedParameters, + result: result.success ? (result.data ?? 'Success') : (result.error ?? 'Unknown error'), + isError: !result.success, + duration: toolResultDuration, + }); + + // Set up next round + handlerContext.yieldToSelf(); + + // Signal tool execution to other plugins + await hooks.toolExecuted.promise({ + agentFrameworkContext, + toolResult: result, + toolInfo: { + toolId: String(toolName), + parameters: validatedParameters as Record, + originalText: toolCall.originalText, + }, + requestId, + }); + + return true; + } catch (error) { + logger.error(`Tool execution failed: ${String(toolName)}`, { error }); + + // Add error result + handlerContext.addToolResult({ + toolName: toolName, + parameters: toolCall.parameters, + result: error instanceof Error ? error.message : String(error), + isError: true, + duration: 2, + }); + + handlerContext.yieldToSelf(); + + await hooks.toolExecuted.promise({ + agentFrameworkContext, + toolResult: { + success: false, + error: error instanceof Error ? error.message : String(error), + }, + toolInfo: { + toolId: toolName, + parameters: toolCall.parameters || {}, + }, + }); + + return true; + } + }, + + addToolResult: (options: AddToolResultOptions) => { + const now = new Date(); + const toolResultText = ` +Tool: ${options.toolName} +Parameters: ${JSON.stringify(options.parameters)} +${options.isError ? 'Error' : 'Result'}: ${options.result} +`; + + const toolResultMessage: AgentInstanceMessage = { + id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + agentId: agentFrameworkContext.agent.id, + role: 'tool', + content: toolResultText, + created: now, + modified: now, + duration: options.duration ?? 1, + metadata: { + isToolResult: true, + isError: options.isError ?? false, + toolId: options.toolName, + toolParameters: options.parameters, + isPersisted: false, + isComplete: true, + }, + }; + + agentFrameworkContext.agent.messages.push(toolResultMessage); + + // Persist immediately + void (async () => { + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + await agentInstanceService.saveUserMessage(toolResultMessage); + toolResultMessage.metadata = { ...toolResultMessage.metadata, isPersisted: true }; + } catch (error) { + logger.warn('Failed to persist tool result', { error, messageId: toolResultMessage.id }); + } + })(); + + logger.debug('Tool result added', { + toolName: options.toolName, + isError: options.isError, + messageId: toolResultMessage.id, + }); + }, + + yieldToSelf: () => { + if (!context.actions) { + context.actions = {}; + } + context.actions.yieldNextRoundTo = 'self'; + + // Also set duration on the AI message containing the tool call and update UI immediately + const aiMessages = agentFrameworkContext.agent.messages.filter((m) => m.role === 'assistant'); + if (aiMessages.length > 0) { + const latestAiMessage = aiMessages[aiMessages.length - 1]; + // Only update if this message matches the current response (contains the tool call) + if (latestAiMessage.content === response.content) { + latestAiMessage.duration = 1; + latestAiMessage.metadata = { + ...latestAiMessage.metadata, + containsToolCall: true, + toolId: toolCall?.toolId, + }; + + // Persist and update UI immediately (no debounce delay) + void (async () => { + try { + const agentInstanceService = container.get(serviceIdentifier.AgentInstance); + if (!latestAiMessage.created) latestAiMessage.created = new Date(); + await agentInstanceService.saveUserMessage(latestAiMessage); + latestAiMessage.metadata = { ...latestAiMessage.metadata, isPersisted: true }; + // Update UI with no delay + agentInstanceService.debounceUpdateMessage(latestAiMessage, agentFrameworkContext.agent.id, 0); + } catch (error) { + logger.warn('Failed to persist AI message with tool call', { error, messageId: latestAiMessage.id }); + } + })(); + } + } + }, + }; + + await onResponseComplete(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${toolId} responseComplete handler`, { error }); + callback(); + } + }); + } + + // Register postProcess handler + if (onPostProcess) { + hooks.postProcess.tapAsync(`${toolId}-postProcess`, async (context, callback) => { + try { + const { toolConfig, prompts, messages, agentFrameworkContext, llmResponse, responses } = context; + + if (toolConfig.toolId !== toolId) { + callback(); + return; + } + + const rawConfig = (toolConfig as Record)[parameterKey]; + if (!rawConfig) { + callback(); + return; + } + + const config = configSchema.parse(rawConfig) as z.infer; + + const handlerContext: PostProcessHandlerContext = { + config, + toolConfig, + prompts, + messages, + agentFrameworkContext, + llmResponse, + responses, + + findPrompt: (id: string) => findPromptById(prompts, id), + + injectToolList: () => { + logger.warn('injectToolList is not recommended in postProcess phase'); + }, + + injectContent: () => { + logger.warn('injectContent is not recommended in postProcess phase'); + }, + }; + + await onPostProcess(handlerContext); + callback(); + } catch (error) { + logger.error(`Error in ${toolId} postProcess handler`, { error }); + callback(); + } + }); + } + }; + + return { + tool, + toolId, + configSchema, + llmToolSchemas, + displayName: definition.displayName, + description: definition.description, + }; +} + +/** + * Registry for tools created with defineTool + */ +const toolRegistry = new Map>(); + +/** + * Register a tool definition + */ +export function registerToolDefinition< + TConfigSchema extends z.ZodType, + TLLMToolSchemas extends Record, +>(definition: ToolDefinition): ReturnType> { + const toolDefinition = defineTool(definition); + toolRegistry.set(toolDefinition.toolId, toolDefinition as ReturnType); + return toolDefinition; +} + +/** + * Get all registered tool definitions + */ +export function getAllToolDefinitions(): Map> { + return toolRegistry; +} + +/** + * Get a tool definition by ID + */ +export function getToolDefinition(toolId: string): ReturnType | undefined { + return toolRegistry.get(toolId); +} diff --git a/src/services/agentInstance/tools/index.ts b/src/services/agentInstance/tools/index.ts index 6c4abbd6..d3a0b5a0 100644 --- a/src/services/agentInstance/tools/index.ts +++ b/src/services/agentInstance/tools/index.ts @@ -1,20 +1,41 @@ +/** + * Agent Framework Plugin System + * + * This module provides a unified registration and hook system for: + * 1. Modifiers - Transform the prompt tree (fullReplacement, dynamicPosition) + * 2. LLM Tools - Inject tool descriptions and handle AI tool calls (wikiSearch, wikiOperation, etc.) + * 3. Core Infrastructure - Message persistence, streaming, status (always enabled) + * + * All plugins are configured via the `plugins` array in agentFrameworkConfig. + * Each plugin has a `toolId` that identifies it and a corresponding `xxxParam` object for configuration. + */ import { logger } from '@services/libs/log'; import { AsyncSeriesHook, AsyncSeriesWaterfallHook } from 'tapable'; + +import { registerCoreInfrastructure } from '../promptConcat/infrastructure'; +import { getAllModifiers } from '../promptConcat/modifiers'; +import { getAllToolDefinitions } from './defineTool'; import { registerToolParameterSchema } from './schemaRegistry'; -import { AgentResponse, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool, ResponseHookContext } from './types'; +import { PromptConcatHooks, PromptConcatTool } from './types'; // Re-export types for convenience -export type { AgentResponse, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool, ResponseHookContext }; -// Backward compatibility aliases -export type { PromptConcatTool as PromptConcatPlugin }; +export type { AgentResponse, PostProcessContext, PromptConcatHookContext, PromptConcatHooks, PromptConcatTool, ResponseHookContext } from './types'; + +// Re-export defineTool API for LLM tools +export { defineTool, getAllToolDefinitions, registerToolDefinition } from './defineTool'; +export type { ResponseHandlerContext, ToolDefinition, ToolExecutionResult, ToolHandlerContext } from './defineTool'; + +// Re-export modifier API +export { defineModifier, getAllModifiers, registerModifier } from '../promptConcat/modifiers'; +export type { InsertContentOptions, ModifierDefinition, ModifierHandlerContext } from '../promptConcat/modifiers'; /** - * Registry for built-in framework tools + * Registry for all plugins (modifiers + LLM tools) */ -export const builtInTools = new Map(); +export const pluginRegistry = new Map(); /** - * Create unified hooks instance for the complete agent framework tool system + * Create unified hooks instance for the agent framework */ export function createAgentFrameworkHooks(): PromptConcatHooks { return { @@ -32,159 +53,87 @@ export function createAgentFrameworkHooks(): PromptConcatHooks { } /** - * Get all available tools + * Register plugins to hooks based on framework configuration */ -async function getAllTools() { - const [ - promptToolsModule, - wikiSearchModule, - wikiOperationModule, - workspacesListModule, - messageManagementModule, - ] = await Promise.all([ - import('./prompt'), - import('./wikiSearch'), - import('./wikiOperation'), - import('./workspacesList'), - import('./messageManagement'), - ]); - - return { - messageManagement: messageManagementModule.messageManagementTool, - fullReplacement: promptToolsModule.fullReplacementTool, - wikiSearch: wikiSearchModule.wikiSearchTool, - wikiOperation: wikiOperationModule.wikiOperationTool, - workspacesList: workspacesListModule.workspacesListTool, - }; -} - -/** - * Register tools to hooks based on framework configuration - * @param hooks - The hooks instance to register tools to - * @param agentFrameworkConfig - The framework configuration containing tool settings - */ -export async function registerToolsToHooksFromConfig( +export async function registerPluginsToHooks( hooks: PromptConcatHooks, agentFrameworkConfig: { plugins?: Array<{ toolId: string; [key: string]: unknown }> }, ): Promise { - // Always register core tools that are needed for basic functionality - const messageManagementModule = await import('./messageManagement'); - messageManagementModule.messageManagementTool(hooks); - logger.debug('Registered messageManagementTool to hooks'); + // Always register core infrastructure first (message persistence, streaming, status) + registerCoreInfrastructure(hooks); + logger.debug('Registered core infrastructure to hooks'); - // Register tools based on framework configuration + // Register plugins based on framework configuration if (agentFrameworkConfig.plugins) { - for (const toolConfig of agentFrameworkConfig.plugins) { - const { toolId } = toolConfig; + for (const pluginConfig of agentFrameworkConfig.plugins) { + const { toolId } = pluginConfig; - // Get tool from global registry (supports both built-in and dynamic tools) - const tool = builtInTools.get(toolId); - if (tool) { - tool(hooks); - logger.debug(`Registered tool ${toolId} to hooks`); + const plugin = pluginRegistry.get(toolId); + if (plugin) { + plugin(hooks); + logger.debug(`Registered plugin ${toolId} to hooks`); } else { - logger.warn(`Tool not found in registry: ${toolId}`); + logger.warn(`Plugin not found in registry: ${toolId}`); } } } } /** - * Initialize tool system - register all built-in tools to global registry + * Initialize plugin system - register all built-in modifiers and LLM tools * This should be called once during service initialization */ -export async function initializeToolSystem(): Promise { - // Import tool schemas and register them - const [ - promptToolsModule, - wikiSearchModule, - wikiOperationModule, - workspacesListModule, - modelContextProtocolModule, - ] = await Promise.all([ - import('./prompt'), +export async function initializePluginSystem(): Promise { + // Import all plugin modules to trigger registration + await Promise.all([ + // LLM Tools import('./wikiSearch'), import('./wikiOperation'), import('./workspacesList'), import('./modelContextProtocol'), + // Modifiers (imported via modifiers/index.ts) + import('../promptConcat/modifiers'), ]); - // Register tool parameter schemas - registerToolParameterSchema( - 'fullReplacement', - promptToolsModule.getFullReplacementParameterSchema(), - { - displayName: 'Full Replacement', - description: 'Replace target content with content from specified source', - }, - ); + // Register modifiers from the modifier registry + const modifiers = getAllModifiers(); + for (const [modifierId, modifierDefinition] of modifiers) { + pluginRegistry.set(modifierId, modifierDefinition.modifier); + registerToolParameterSchema(modifierId, modifierDefinition.configSchema, { + displayName: modifierDefinition.displayName, + description: modifierDefinition.description, + }); + logger.debug(`Registered modifier: ${modifierId}`); + } - registerToolParameterSchema( - 'dynamicPosition', - promptToolsModule.getDynamicPositionParameterSchema(), - { - displayName: 'Dynamic Position', - description: 'Insert content at a specific position relative to a target element', - }, - ); + // Register LLM tools from the tool registry + const llmTools = getAllToolDefinitions(); + for (const [toolId, toolDefinition] of llmTools) { + pluginRegistry.set(toolId, toolDefinition.tool); + registerToolParameterSchema(toolId, toolDefinition.configSchema, { + displayName: toolDefinition.displayName, + description: toolDefinition.description, + }); + logger.debug(`Registered LLM tool: ${toolId}`); + } - registerToolParameterSchema( - 'wikiSearch', - wikiSearchModule.getWikiSearchParameterSchema(), - { - displayName: 'Wiki Search', - description: 'Search content in wiki workspaces and manage vector embeddings', - }, - ); - - registerToolParameterSchema( - 'wikiOperation', - wikiOperationModule.getWikiOperationParameterSchema(), - { - displayName: 'Wiki Operation', - description: 'Perform operations on wiki workspaces (create, update, delete tiddlers)', - }, - ); - - registerToolParameterSchema( - 'workspacesList', - workspacesListModule.getWorkspacesListParameterSchema(), - { - displayName: 'Workspaces List', - description: 'Inject available wiki workspaces list into prompts', - }, - ); - - registerToolParameterSchema( - 'modelContextProtocol', - modelContextProtocolModule.getModelContextProtocolParameterSchema(), - { - displayName: 'Model Context Protocol', - description: 'MCP (Model Context Protocol) integration', - }, - ); - - const tools = await getAllTools(); - // Register all built-in tools to global registry for discovery - builtInTools.set('messageManagement', tools.messageManagement); - builtInTools.set('fullReplacement', tools.fullReplacement); - builtInTools.set('wikiSearch', tools.wikiSearch); - builtInTools.set('wikiOperation', tools.wikiOperation); - builtInTools.set('workspacesList', tools.workspacesList); - logger.debug('All built-in tools and schemas registered successfully'); + logger.debug('Plugin system initialized', { + totalPlugins: pluginRegistry.size, + modifiers: modifiers.size, + llmTools: llmTools.size, + }); } /** - * Create hooks and register tools based on framework configuration - * This creates a new hooks instance and registers tools for that specific context + * Create hooks and register plugins based on framework configuration */ -export async function createHooksWithTools( +export async function createHooksWithPlugins( agentFrameworkConfig: { plugins?: Array<{ toolId: string; [key: string]: unknown }> }, -): Promise<{ hooks: PromptConcatHooks; toolConfigs: Array<{ toolId: string; [key: string]: unknown }> }> { +): Promise<{ hooks: PromptConcatHooks; pluginConfigs: Array<{ toolId: string; [key: string]: unknown }> }> { const hooks = createAgentFrameworkHooks(); - await registerToolsToHooksFromConfig(hooks, agentFrameworkConfig); + await registerPluginsToHooks(hooks, agentFrameworkConfig); return { hooks, - toolConfigs: agentFrameworkConfig.plugins || [], + pluginConfigs: agentFrameworkConfig.plugins ?? [], }; } diff --git a/src/services/agentInstance/tools/messageManagement.ts b/src/services/agentInstance/tools/messageManagement.ts deleted file mode 100644 index 305887db..00000000 --- a/src/services/agentInstance/tools/messageManagement.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Message management plugin - * Unified plugin for handling message persistence, streaming updates, and UI synchronization - * Combines functionality from persistencePlugin and aiResponseHistoryPlugin - */ -import { container } from '@services/container'; -import { logger } from '@services/libs/log'; -import serviceIdentifier from '@services/serviceIdentifier'; -import type { IAgentInstanceService } from '../interface'; -import { createAgentMessage } from '../utilities'; -import type { AgentStatusContext, AIResponseContext, PromptConcatTool, ToolExecutionContext, UserMessageContext } from './types'; - -/** - * Message management plugin - * Handles all message-related operations: persistence, streaming, UI updates, and duration-based filtering - */ -export const messageManagementTool: PromptConcatTool = (hooks) => { - // Handle user message persistence - hooks.userMessageReceived.tapAsync('messageManagementTool', async (context: UserMessageContext, callback) => { - try { - const { agentFrameworkContext, content, messageId } = context; - - // Create user message using the helper function - const userMessage = createAgentMessage(messageId, agentFrameworkContext.agent.id, { - role: 'user', - content: content.text, - contentType: 'text/plain', - metadata: content.file ? { file: content.file } : undefined, - duration: undefined, // User messages persist indefinitely by default - }); - - // Add message to the agent's message array for immediate use (do this before persistence so plugins see it) - agentFrameworkContext.agent.messages.push(userMessage); - - // Get the agent instance service to access repositories - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - - // Save user message to database (if persistence fails, we still keep the in-memory message) - await agentInstanceService.saveUserMessage(userMessage); - - logger.debug('User message persisted to database', { - messageId, - agentId: agentFrameworkContext.agent.id, - contentLength: content.text.length, - }); - - callback(); - } catch (error) { - logger.error('Message management plugin error in userMessageReceived', { - error, - messageId: context.messageId, - agentId: context.agentFrameworkContext.agent.id, - }); - callback(); - } - }); - - // Handle agent status persistence - hooks.agentStatusChanged.tapAsync('messageManagementTool', async (context: AgentStatusContext, callback) => { - try { - const { agentFrameworkContext, status } = context; - - // Get the agent instance service to update status - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - - // Update agent status in database - await agentInstanceService.updateAgent(agentFrameworkContext.agent.id, { - status, - }); - - // Update the agent object for immediate use - agentFrameworkContext.agent.status = status; - - logger.debug('Agent status updated in database', { - agentId: agentFrameworkContext.agent.id, - state: status.state, - }); - - callback(); - } catch (error) { - logger.error('Message management plugin error in agentStatusChanged', { - error, - agentId: context.agentFrameworkContext.agent.id, - status: context.status, - }); - callback(); - } - }); - - // Handle AI response updates during streaming - hooks.responseUpdate.tapAsync('messageManagementTool', async (context: AIResponseContext, callback) => { - try { - const { agentFrameworkContext, response } = context; - - if (response.status === 'update' && response.content) { - // Find or create AI response message in agent's message array - let aiMessage = agentFrameworkContext.agent.messages.find( - (message) => message.role === 'assistant' && !message.metadata?.isComplete, - ); - - if (!aiMessage) { - // Create new AI message for streaming updates - const now = new Date(); - aiMessage = { - id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'assistant', - content: response.content, - created: now, - modified: now, - metadata: { isComplete: false }, - duration: undefined, // AI responses persist indefinitely by default - }; - agentFrameworkContext.agent.messages.push(aiMessage); - // Persist immediately so DB timestamp reflects conversation order - try { - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - await agentInstanceService.saveUserMessage(aiMessage); - aiMessage.metadata = { ...aiMessage.metadata, isPersisted: true }; - } catch (persistError) { - logger.warn('Failed to persist initial streaming AI message', { - error: persistError, - messageId: aiMessage.id, - }); - } - } else { - // Update existing message content - aiMessage.content = response.content; - aiMessage.modified = new Date(); - } - - // Update UI using the agent instance service - try { - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - agentInstanceService.debounceUpdateMessage(aiMessage, agentFrameworkContext.agent.id); - } catch (serviceError) { - logger.warn('Failed to update UI for streaming message', { - error: serviceError, - messageId: aiMessage.id, - }); - } - } - } catch (error) { - logger.error('Message management plugin error in responseUpdate', { - error, - }); - } finally { - callback(); - } - }); - - // Handle AI response completion - hooks.responseComplete.tapAsync('messageManagementTool', async (context: AIResponseContext, callback) => { - try { - const { agentFrameworkContext, response } = context; - - if (response.status === 'done' && response.content) { - // Find and finalize AI response message - let aiMessage = agentFrameworkContext.agent.messages.find( - (message) => message.role === 'assistant' && !message.metadata?.isComplete && !message.metadata?.isToolResult, - ); - - if (aiMessage) { - // Mark as complete and update final content - aiMessage.content = response.content; - aiMessage.modified = new Date(); - aiMessage.metadata = { ...aiMessage.metadata, isComplete: true }; - } else { - // Create final message if streaming message wasn't found - const nowFinal = new Date(); - aiMessage = { - id: `ai-response-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'assistant', - content: response.content, - created: nowFinal, - modified: nowFinal, - metadata: { - isComplete: true, - }, - duration: undefined, // Default duration for AI responses - }; - agentFrameworkContext.agent.messages.push(aiMessage); - } - - // Get the agent instance service for persistence and UI updates - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - - // Save final AI message to database using the same method as user messages - await agentInstanceService.saveUserMessage(aiMessage); - - // Final UI update - try { - agentInstanceService.debounceUpdateMessage(aiMessage, agentFrameworkContext.agent.id); - } catch (serviceError) { - logger.warn('Failed to update UI for completed message', { - error: serviceError, - messageId: aiMessage.id, - }); - } - - logger.debug('AI response message completed and persisted', { - messageId: aiMessage.id, - finalContentLength: response.content.length, - }); - } - - callback(); - } catch (error) { - logger.error('Message management plugin error in responseComplete', { - error, - }); - callback(); - } - }); - - // Handle tool result messages persistence and UI updates - hooks.toolExecuted.tapAsync('messageManagementTool', async (context: ToolExecutionContext, callback) => { - try { - const { agentFrameworkContext } = context; - - // Find newly added tool result messages that need to be persisted - const newToolResultMessages = agentFrameworkContext.agent.messages.filter( - (message) => message.metadata?.isToolResult && !message.metadata.isPersisted, - ); - - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - - // Save tool result messages to database and update UI - for (const message of newToolResultMessages) { - try { - // Save to database using the same method as user messages - await agentInstanceService.saveUserMessage(message); - - // Update UI - agentInstanceService.debounceUpdateMessage(message, agentFrameworkContext.agent.id); - - // Mark as persisted to avoid duplicate saves - message.metadata = { ...message.metadata, isPersisted: true, uiUpdated: true }; - - logger.debug('Tool result message persisted to database', { - messageId: message.id, - toolId: message.metadata.toolId, - duration: message.duration, - }); - } catch (serviceError) { - logger.error('Failed to persist tool result message', { - error: serviceError, - messageId: message.id, - }); - } - } - - if (newToolResultMessages.length > 0) { - logger.debug('Tool result messages processed', { - count: newToolResultMessages.length, - messageIds: newToolResultMessages.map(m => m.id), - }); - } - - callback(); - } catch (error) { - logger.error('Message management plugin error in toolExecuted', { - error, - }); - callback(); - } - }); -}; diff --git a/src/services/agentInstance/tools/prompt.ts b/src/services/agentInstance/tools/prompt.ts deleted file mode 100644 index 0ec5ecba..00000000 --- a/src/services/agentInstance/tools/prompt.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * Built-in plugins for prompt concatenation - */ -import { identity } from 'lodash'; -import { z } from 'zod/v4'; - -import { logger } from '@services/libs/log'; -import { cloneDeep } from 'lodash'; -import { findPromptById } from '../promptConcat/promptConcat'; -import type { IPrompt } from '../promptConcat/promptConcatSchema'; -import { filterMessagesByDuration } from '../utilities/messageDurationFilter'; -import { normalizeRole } from '../utilities/normalizeRole'; -import { AgentResponse, PromptConcatTool, ResponseHookContext } from './types'; - -const t = identity; - -/** - * Full Replacement Parameter Schema - * Configuration parameters for the full replacement plugin - */ -export const FullReplacementParameterSchema = z.object({ - targetId: z.string().meta({ - title: t('Schema.FullReplacement.TargetIdTitle'), - description: t('Schema.FullReplacement.TargetId'), - }), - sourceType: z.enum(['historyOfSession', 'llmResponse']).meta({ - title: t('Schema.FullReplacement.SourceTypeTitle'), - description: t('Schema.FullReplacement.SourceType'), - }), -}).meta({ - title: t('Schema.FullReplacement.Title'), - description: t('Schema.FullReplacement.Description'), -}); - -/** - * Dynamic Position Parameter Schema - * Configuration parameters for the dynamic position plugin - */ -export const DynamicPositionParameterSchema = z.object({ - targetId: z.string().meta({ - title: t('Schema.Position.TargetIdTitle'), - description: t('Schema.Position.TargetId'), - }), - position: z.enum(['before', 'after', 'relative']).meta({ - title: t('Schema.Position.TypeTitle'), - description: t('Schema.Position.Type'), - }), -}).meta({ - title: t('Schema.Position.Title'), - description: t('Schema.Position.Description'), -}); - -/** - * Type definitions - */ -export type FullReplacementParameter = z.infer; -export type DynamicPositionParameter = z.infer; - -/** - * Get the full replacement parameter schema - * @returns The schema for full replacement parameters - */ -export function getFullReplacementParameterSchema() { - return FullReplacementParameterSchema; -} - -/** - * Get the dynamic position parameter schema - * @returns The schema for dynamic position parameters - */ -export function getDynamicPositionParameterSchema() { - return DynamicPositionParameterSchema; -} - -/** - * Full replacement plugin - * Replaces target content with content from specified source - */ -export const fullReplacementTool: PromptConcatTool = (hooks) => { - // Normalize an AgentInstanceMessage role to Prompt role - hooks.processPrompts.tapAsync('fullReplacementTool', async (context, callback) => { - const { toolConfig, prompts, messages } = context; - - if (toolConfig.toolId !== 'fullReplacement' || !toolConfig.fullReplacementParam) { - callback(); - return; - } - - const fullReplacementConfig = toolConfig.fullReplacementParam; - if (!fullReplacementConfig) { - callback(); - return; - } - - const { targetId, sourceType } = fullReplacementConfig; - const found = findPromptById(prompts, targetId); - - if (!found) { - logger.warn('Target prompt not found for fullReplacement', { - targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Get all messages except the last user message being processed - // We need to find and exclude only the current user message being processed, not just the last message - const messagesCopy = cloneDeep(messages); - - // Find the last user message (which is the one being processed in this round) - let lastUserMessageIndex = -1; - for (let index = messagesCopy.length - 1; index >= 0; index--) { - if (messagesCopy[index].role === 'user') { - lastUserMessageIndex = index; - break; - } - } - - // Remove only the last user message if found (this is the current message being processed) - if (lastUserMessageIndex >= 0) { - messagesCopy.splice(lastUserMessageIndex, 1); - logger.debug('Removed current user message from history', { - removedMessageId: messages[lastUserMessageIndex].id, - remainingMessages: messagesCopy.length, - }); - } else { - logger.debug('No user message found to remove from history', { - totalMessages: messagesCopy.length, - messageRoles: messagesCopy.map(m => m.role), - }); - } - - // Apply duration filtering to exclude expired messages from AI context - const filteredHistory = filterMessagesByDuration(messagesCopy); - - switch (sourceType) { - case 'historyOfSession': - if (filteredHistory.length > 0) { - // Insert filtered history messages as Prompt children (full Prompt type) - found.prompt.children = []; - filteredHistory.forEach((message, index: number) => { - // Map AgentInstanceMessage role to Prompt role via normalizeRole - type PromptRole = NonNullable; - const role: PromptRole = normalizeRole(message.role); - delete found.prompt.text; - found.prompt.children!.push({ - id: `history-${index}`, - caption: `History message ${index + 1}`, - role, - text: message.content, - }); - }); - } else { - found.prompt.text = '无聊天历史。'; - } - break; - case 'llmResponse': - // This is handled in response phase - break; - default: - logger.warn(`Unknown sourceType: ${sourceType as string}`); - callback(); - return; - } - - logger.debug('Full replacement completed in prompt phase', { - targetId, - sourceType, - }); - - callback(); - }); - - // Handle response phase for llmResponse source type - hooks.postProcess.tapAsync('fullReplacementTool', async (context, callback) => { - const responseContext = context as ResponseHookContext; - const { toolConfig, llmResponse, responses } = responseContext; - - if (toolConfig.toolId !== 'fullReplacement' || !toolConfig.fullReplacementParam) { - callback(); - return; - } - - const fullReplacementParameter = toolConfig.fullReplacementParam; - if (!fullReplacementParameter) { - callback(); - return; - } - - const { targetId, sourceType } = fullReplacementParameter; - - // Only handle llmResponse in response phase - if (sourceType !== 'llmResponse') { - callback(); - return; - } - - // Find the target response by ID - const found = responses.find((r: AgentResponse) => r.id === targetId); - - if (!found) { - logger.warn('Full replacement target not found in responses', { - targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Replace target content with LLM response - logger.debug('Replacing target with LLM response', { - targetId, - responseLength: llmResponse.length, - toolId: toolConfig.id, - }); - - found.text = llmResponse; - - logger.debug('Full replacement completed in response phase', { - targetId, - sourceType, - toolId: toolConfig.id, - }); - - callback(); - }); -}; - -/** - * Dynamic position plugin - * Inserts content at a specific position relative to a target element - */ -export const dynamicPositionTool: PromptConcatTool = (hooks) => { - hooks.processPrompts.tapAsync('dynamicPositionTool', async (context, callback) => { - const { toolConfig, prompts } = context; - - if (toolConfig.toolId !== 'dynamicPosition' || !toolConfig.dynamicPositionParam || !toolConfig.content) { - callback(); - return; - } - - const dynamicPositionConfig = toolConfig.dynamicPositionParam; - if (!dynamicPositionConfig) { - callback(); - return; - } - - const { targetId, position } = dynamicPositionConfig; - const found = findPromptById(prompts, targetId); - - if (!found) { - logger.warn('Target prompt not found for dynamicPosition', { - targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Create new prompt part - const newPart: IPrompt = { - id: `dynamic-${toolConfig.id}-${Date.now()}`, - caption: toolConfig.caption || 'Dynamic Content', - text: toolConfig.content, - }; - - // Insert based on position - switch (position) { - case 'before': - found.parent.splice(found.index, 0, newPart); - break; - case 'after': - found.parent.splice(found.index + 1, 0, newPart); - break; - case 'relative': - // Simplified implementation, only adds to target's children - if (!found.prompt.children) { - found.prompt.children = []; - } - found.prompt.children.push(newPart); - break; - default: - logger.warn(`Unknown position: ${position as string}`); - callback(); - return; - } - - logger.debug('Dynamic position insertion completed', { - targetId, - position, - contentLength: toolConfig.content.length, - toolId: toolConfig.id, - }); - - callback(); - }); -}; diff --git a/src/services/agentInstance/tools/wikiOperation.ts b/src/services/agentInstance/tools/wikiOperation.ts index 21602c3d..47b1b0f1 100644 --- a/src/services/agentInstance/tools/wikiOperation.ts +++ b/src/services/agentInstance/tools/wikiOperation.ts @@ -1,10 +1,9 @@ /** - * Wiki Operation plugin + * Wiki Operation Tool * Handles wiki operation tool list injection, tool calling detection and response processing * Supports creating, updating, and deleting tiddlers in wiki workspaces */ import { WikiChannel } from '@/constants/channels'; -import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; import { container } from '@services/container'; import { i18n } from '@services/libs/i18n'; import { t } from '@services/libs/i18n/placeholder'; @@ -13,14 +12,10 @@ import serviceIdentifier from '@services/serviceIdentifier'; import type { IWikiService } from '@services/wiki/interface'; import type { IWorkspaceService } from '@services/workspaces/interface'; import { z } from 'zod/v4'; -import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; -import { findPromptById } from '../promptConcat/promptConcat'; -import { schemaToToolContent } from '../utilities/schemaToToolContent'; -import type { PromptConcatTool } from './types'; +import { registerToolDefinition, type ToolExecutionResult } from './defineTool'; /** - * Wiki Operation Parameter Schema - * Configuration parameters for the wiki operation plugin + * Wiki Operation Config Schema (user-configurable in UI) */ export const WikiOperationParameterSchema = z.object({ toolListPosition: z.object({ @@ -45,23 +40,16 @@ export const WikiOperationParameterSchema = z.object({ description: t('Schema.WikiOperation.Description'), }); -/** - * Type definition for wiki operation parameters - */ export type WikiOperationParameter = z.infer; -/** - * Get the wiki operation parameter schema - * @returns The schema for wiki operation parameters - */ export function getWikiOperationParameterSchema() { return WikiOperationParameterSchema; } /** - * Parameter schema for Wiki operation tool + * LLM-callable tool schema for wiki operations */ -const WikiOperationToolParameterSchema = z.object({ +const WikiOperationToolSchema = z.object({ workspaceName: z.string().meta({ title: t('Schema.WikiOperation.Tool.Parameters.workspaceName.Title'), description: t('Schema.WikiOperation.Tool.Parameters.workspaceName.Description'), @@ -86,347 +74,141 @@ const WikiOperationToolParameterSchema = z.object({ title: t('Schema.WikiOperation.Tool.Parameters.options.Title'), description: t('Schema.WikiOperation.Tool.Parameters.options.Description'), }), -}) - .meta({ - title: 'wiki-operation', - description: '在Wiki工作空间中执行操作(添加、删除或设置Tiddler文本)', - examples: [ - { workspaceName: '我的知识库', operation: WikiChannel.addTiddler, title: '示例笔记', text: '示例内容', extraMeta: '{}', options: '{}' }, - { workspaceName: '我的知识库', operation: WikiChannel.setTiddlerText, title: '现有笔记', text: '更新后的内容', extraMeta: '{}', options: '{}' }, - { workspaceName: '我的知识库', operation: WikiChannel.deleteTiddler, title: '要删除的笔记', extraMeta: '{}', options: '{}' }, - ], - }); +}).meta({ + title: 'wiki-operation', + description: '在Wiki工作空间中执行操作(添加、删除或设置Tiddler文本)', + examples: [ + { workspaceName: '我的知识库', operation: WikiChannel.addTiddler, title: '示例笔记', text: '示例内容', extraMeta: '{}', options: '{}' }, + { workspaceName: '我的知识库', operation: WikiChannel.setTiddlerText, title: '现有笔记', text: '更新后的内容', extraMeta: '{}', options: '{}' }, + { workspaceName: '我的知识库', operation: WikiChannel.deleteTiddler, title: '要删除的笔记', extraMeta: '{}', options: '{}' }, + ], +}); + +type WikiOperationToolParameters = z.infer; /** - * Wiki Operation plugin - Prompt processing - * Handles tool list injection for wiki operation functionality + * Execute wiki operation */ -export const wikiOperationTool: PromptConcatTool = (hooks) => { - // First tapAsync: Tool list injection - hooks.processPrompts.tapAsync('wikiOperationTool-toolList', async (context, callback) => { - const { toolConfig, prompts } = context; +async function executeWikiOperation(parameters: WikiOperationToolParameters): Promise { + const { workspaceName, operation, title, text, extraMeta, options: optionsString } = parameters; - if (toolConfig.toolId !== 'wikiOperation' || !toolConfig.wikiOperationParam) { - callback(); + try { + const workspaceService = container.get(serviceIdentifier.Workspace); + const wikiService = container.get(serviceIdentifier.Wiki); + + // Look up workspace + const workspaces = await workspaceService.getWorkspacesAsList(); + const targetWorkspace = workspaces.find((ws) => ws.name === workspaceName || ws.id === workspaceName); + + if (!targetWorkspace) { + return { + success: false, + error: i18n.t('Tool.WikiOperation.Error.WorkspaceNotFound', { + workspaceName, + availableWorkspaces: workspaces.map((w) => `${w.name} (${w.id})`).join(', '), + }), + }; + } + + const workspaceID = targetWorkspace.id; + + if (!(await workspaceService.exists(workspaceID))) { + return { + success: false, + error: i18n.t('Tool.WikiOperation.Error.WorkspaceNotExist', { workspaceID }), + }; + } + + const options = JSON.parse(optionsString || '{}') as Record; + + logger.debug('Executing wiki operation', { workspaceID, workspaceName, operation, title }); + + let result: string; + + switch (operation) { + case WikiChannel.addTiddler: { + await wikiService.wikiOperationInServer(WikiChannel.addTiddler, workspaceID, [ + title, + text || '', + extraMeta || '{}', + JSON.stringify({ withDate: true, ...options }), + ]); + result = i18n.t('Tool.WikiOperation.Success.Added', { title, workspaceName }); + break; + } + + case WikiChannel.deleteTiddler: { + await wikiService.wikiOperationInServer(WikiChannel.deleteTiddler, workspaceID, [title]); + result = i18n.t('Tool.WikiOperation.Success.Deleted', { title, workspaceName }); + break; + } + + case WikiChannel.setTiddlerText: { + await wikiService.wikiOperationInServer(WikiChannel.setTiddlerText, workspaceID, [title, text || '']); + result = i18n.t('Tool.WikiOperation.Success.Updated', { title, workspaceName }); + break; + } + + default: { + const exhaustiveCheck: never = operation; + return { success: false, error: `Unsupported operation: ${String(exhaustiveCheck)}` }; + } + } + + return { + success: true, + data: result, + metadata: { workspaceID, workspaceName, operation, title }, + }; + } catch (error) { + logger.error('Wiki operation failed', { error, params: parameters }); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Wiki Operation Tool Definition + */ +const wikiOperationDefinition = registerToolDefinition({ + toolId: 'wikiOperation', + displayName: 'Wiki Operation', + description: 'Perform operations on wiki workspaces (create, update, delete tiddlers)', + configSchema: WikiOperationParameterSchema, + llmToolSchemas: { + 'wiki-operation': WikiOperationToolSchema, + }, + + onProcessPrompts({ config, toolConfig, injectToolList }) { + const toolListPosition = config.toolListPosition; + if (!toolListPosition?.targetId) return; + + injectToolList({ + targetId: toolListPosition.targetId, + position: 'child', // Add as child of target prompt + caption: 'Wiki Operation Tool', + }); + + logger.debug('Wiki operation tool list injected', { + targetId: toolListPosition.targetId, + position: toolListPosition.position, + toolId: toolConfig.id, + }); + }, + + async onResponseComplete({ toolCall, executeToolCall, agentFrameworkContext }) { + if (!toolCall || toolCall.toolId !== 'wiki-operation') return; + + // Check cancellation + if (agentFrameworkContext.isCancelled()) { + logger.debug('Wiki operation cancelled', { agentId: agentFrameworkContext.agent.id }); return; } - const wikiOperationParameter = toolConfig.wikiOperationParam; + await executeToolCall('wiki-operation', executeWikiOperation); + }, +}); - try { - // Handle tool list injection if toolListPosition is configured - const toolListPosition = wikiOperationParameter.toolListPosition; - if (toolListPosition?.targetId) { - const toolListTarget = findPromptById(prompts, toolListPosition.targetId); - if (!toolListTarget) { - logger.warn('Tool list target prompt not found', { - targetId: toolListPosition.targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Get available wikis - now handled by workspacesListPlugin - // The workspaces list will be injected separately by workspacesListPlugin - - // Build tool content using shared utility (schema contains title/examples meta) - const wikiOperationToolContent = schemaToToolContent(WikiOperationToolParameterSchema); - - // Insert the tool content based on position - if (toolListPosition.position === 'after') { - if (!toolListTarget.prompt.children) { - toolListTarget.prompt.children = []; - } - const insertIndex = toolListTarget.prompt.children.length; - toolListTarget.prompt.children.splice(insertIndex, 0, { - id: `wiki-operation-tool-${toolConfig.id}`, - caption: 'Wiki Operation Tool', - text: wikiOperationToolContent, - }); - } else if (toolListPosition.position === 'before') { - if (!toolListTarget.prompt.children) { - toolListTarget.prompt.children = []; - } - toolListTarget.prompt.children.unshift({ - id: `wiki-operation-tool-${toolConfig.id}`, - caption: 'Wiki Operation Tool', - text: wikiOperationToolContent, - }); - } else { - // Default to appending text - toolListTarget.prompt.text = (toolListTarget.prompt.text || '') + wikiOperationToolContent; - } - - logger.debug('Wiki operation tool list injected', { - targetId: toolListPosition.targetId, - position: toolListPosition.position, - toolId: toolConfig.id, - }); - } - - callback(); - } catch (error) { - logger.error('Error in wiki operation tool list injection', { - error, - toolId: toolConfig.id, - }); - callback(); - } - }); - - // 2. Tool execution when AI response is complete - hooks.responseComplete.tapAsync('wikiOperationTool-handler', async (context, callback) => { - try { - const { agentFrameworkContext, response, agentFrameworkConfig } = context; - - // Find this plugin's configuration import { AgentFrameworkConfig } - const wikiOperationToolConfig = agentFrameworkConfig?.plugins?.find((p: { toolId: string; [key: string]: unknown }) => p.toolId === 'wikiOperation'); - const wikiOperationParameter = wikiOperationToolConfig?.wikiOperationParam as { toolResultDuration?: number } | undefined; - const toolResultDuration = wikiOperationParameter?.toolResultDuration || 1; // Default to 1 round - - if (response.status !== 'done' || !response.content) { - callback(); - return; - } - - // Check for wiki operation tool calls in the AI response - const toolMatch = matchToolCalling(response.content); - - if (!toolMatch.found || toolMatch.toolId !== 'wiki-operation') { - callback(); - return; - } - - logger.debug('Wiki operation tool call detected', { - toolId: toolMatch.toolId, - agentId: agentFrameworkContext.agent.id, - }); - - // Set duration=1 for the AI message containing the tool call - // Find the most recent AI message (should be the one containing the tool call) - const aiMessages = agentFrameworkContext.agent.messages.filter((message: AgentInstanceMessage) => message.role === 'assistant'); - if (aiMessages.length > 0) { - const latestAiMessage = aiMessages[aiMessages.length - 1]; - latestAiMessage.duration = toolResultDuration; - logger.debug('Set AI message duration for tool call', { - messageId: latestAiMessage.id, - duration: toolResultDuration, - agentId: agentFrameworkContext.agent.id, - }); - } - - // Execute the wiki operation tool call - try { - logger.debug('Parsing wiki operation tool parameters', { - toolMatch: toolMatch.parameters, - agentId: agentFrameworkContext.agent.id, - }); - - // Use parameters returned by matchToolCalling directly. Let zod schema validate. - const validatedParameters = WikiOperationToolParameterSchema.parse(toolMatch.parameters as Record); - const { workspaceName, operation, title, text, extraMeta, options: optionsString } = validatedParameters; - const options = JSON.parse(optionsString || '{}') as Record; - // Get workspace service - const workspaceService = container.get(serviceIdentifier.Workspace); - const wikiService = container.get(serviceIdentifier.Wiki); - - // Look up workspace ID from workspace name or ID - const workspaces = await workspaceService.getWorkspacesAsList(); - const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName); - if (!targetWorkspace) { - throw new Error( - i18n.t('Tool.WikiOperation.Error.WorkspaceNotFound', { - workspaceName, - availableWorkspaces: workspaces.map(w => `${w.name} (${w.id})`).join(', '), - }) || `Workspace with name or ID "${workspaceName}" does not exist. Available workspaces: ${workspaces.map(w => `${w.name} (${w.id})`).join(', ')}`, - ); - } - const workspaceID = targetWorkspace.id; - - if (!await workspaceService.exists(workspaceID)) { - throw new Error(i18n.t('Tool.WikiOperation.Error.WorkspaceNotExist', { workspaceID })); - } - - logger.debug('Executing wiki operation', { - workspaceID, - workspaceName, - operation, - title, - agentId: agentFrameworkContext.agent.id, - }); - - let result: string; - - // Execute the appropriate wiki operation directly - switch (operation) { - case WikiChannel.addTiddler: { - await wikiService.wikiOperationInServer(WikiChannel.addTiddler, workspaceID, [ - title, - text || '', - extraMeta || '{}', - JSON.stringify({ withDate: true, ...options }), - ]); - result = i18n.t('Tool.WikiOperation.Success.Added', { title, workspaceName }); - break; - } - - case WikiChannel.deleteTiddler: { - await wikiService.wikiOperationInServer(WikiChannel.deleteTiddler, workspaceID, [title]); - result = i18n.t('Tool.WikiOperation.Success.Deleted', { title, workspaceName }); - break; - } - - case WikiChannel.setTiddlerText: { - await wikiService.wikiOperationInServer(WikiChannel.setTiddlerText, workspaceID, [title, text || '']); - result = i18n.t('Tool.WikiOperation.Success.Updated', { title, workspaceName }); - break; - } - - default: { - const exhaustiveCheck: never = operation; - throw new Error(`Unsupported operation: ${String(exhaustiveCheck)}`); - } - } - - logger.debug('Wiki operation tool execution completed successfully', { - workspaceID, - operation, - title, - agentId: agentFrameworkContext.agent.id, - }); - - // Format the tool result for display - const toolResultText = `\nTool: wiki-operation\nParameters: ${JSON.stringify(validatedParameters)}\nResult: ${result}\n`; - - // Set up actions to continue the conversation with tool results - if (!context.actions) { - context.actions = {}; - } - context.actions.yieldNextRoundTo = 'self'; - - logger.debug('Wiki operation setting yieldNextRoundTo=self', { - toolId: 'wiki-operation', - agentId: agentFrameworkContext.agent.id, - messageCount: agentFrameworkContext.agent.messages.length, - toolResultPreview: toolResultText.slice(0, 200), - }); - - // Immediately add the tool result message to history BEFORE calling toolExecuted - const toolResultTime = new Date(); - const toolResultMessage: AgentInstanceMessage = { - id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'tool', // Tool result message - content: toolResultText, - modified: toolResultTime, - duration: toolResultDuration, // Use configurable duration - default 1 round for tool results - metadata: { - isToolResult: true, - isError: false, - toolId: 'wiki-operation', - toolParameters: validatedParameters, - isPersisted: false, // Required by messageManagementPlugin to identify new tool results - isComplete: true, // Mark as complete to prevent messageManagementPlugin from overwriting content - artificialOrder: Date.now() + 10, // Additional ordering hint - }, - }; - agentFrameworkContext.agent.messages.push(toolResultMessage); - - // Persist tool result immediately so DB ordering matches in-memory order - try { - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - await agentInstanceService.saveUserMessage(toolResultMessage); - toolResultMessage.metadata = { ...toolResultMessage.metadata, isPersisted: true }; - } catch (persistError) { - logger.warn('Failed to persist tool result immediately in wikiOperationPlugin', { - error: persistError, - messageId: toolResultMessage.id, - }); - } - - // Signal that tool was executed AFTER adding and persisting the message - await hooks.toolExecuted.promise({ - agentFrameworkContext, - toolResult: { - success: true, - data: result, - metadata: { toolCount: 1 }, - }, - toolInfo: { - toolId: 'wiki-operation', - parameters: validatedParameters, - originalText: toolMatch.originalText || '', - }, - requestId: context.requestId, - }); - - logger.debug('Wiki operation tool execution completed', { - toolResultText, - actions: context.actions, - toolResultMessageId: toolResultMessage.id, - aiMessageDuration: aiMessages[aiMessages.length - 1]?.duration, - }); - } catch (error) { - logger.error('Wiki operation tool execution failed', { - error, - agentId: agentFrameworkContext.agent.id, - toolParameters: toolMatch.parameters, - }); - - // Set up error response for next round - if (!context.actions) { - context.actions = {}; - } - context.actions.yieldNextRoundTo = 'self'; - const errorMessage = ` -Tool: wiki-operation -Error: ${error instanceof Error ? error.message : String(error)} -`; - - // Add error message to history BEFORE calling toolExecuted - // Use the current time; order will be determined by save order - const errorResultTime = new Date(); - const errorResultMessage: AgentInstanceMessage = { - id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'tool', // Tool error message - content: errorMessage, - modified: errorResultTime, - duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation - metadata: { - isToolResult: true, - isError: true, - toolId: 'wiki-operation', - isPersisted: false, // Required by messageManagementPlugin to identify new tool results - isComplete: true, // Mark as complete to prevent messageManagementPlugin from overwriting content - }, - }; - agentFrameworkContext.agent.messages.push(errorResultMessage); - - // Signal that tool was executed (with error) AFTER adding the message - await hooks.toolExecuted.promise({ - agentFrameworkContext, - toolResult: { - success: false, - error: error instanceof Error ? error.message : String(error), - }, - toolInfo: { - toolId: 'wiki-operation', - parameters: toolMatch.parameters || {}, - }, - }); - - logger.debug('Wiki operation tool execution failed but error result added', { - errorResultMessageId: errorResultMessage.id, - aiMessageDuration: aiMessages[aiMessages.length - 1]?.duration, - }); - } - - callback(); - } catch (error) { - logger.error('Error in wiki operation plugin response handler', { error }); - callback(); - } - }); -}; +export const wikiOperationTool = wikiOperationDefinition.tool; diff --git a/src/services/agentInstance/tools/wikiSearch.ts b/src/services/agentInstance/tools/wikiSearch.ts index a91a4e6e..28a58305 100644 --- a/src/services/agentInstance/tools/wikiSearch.ts +++ b/src/services/agentInstance/tools/wikiSearch.ts @@ -1,9 +1,8 @@ /** - * Wiki Search plugin + * Wiki Search Tool * Handles wiki search tool list injection, tool calling detection and response processing */ import { WikiChannel } from '@/constants/channels'; -import { matchToolCalling } from '@services/agentDefinition/responsePatternUtility'; import { container } from '@services/container'; import { i18n } from '@services/libs/i18n'; import { t } from '@services/libs/i18n/placeholder'; @@ -14,30 +13,13 @@ import type { IWikiEmbeddingService } from '@services/wikiEmbedding/interface'; import type { IWorkspaceService } from '@services/workspaces/interface'; import type { ITiddlerFields } from 'tiddlywiki'; import { z } from 'zod/v4'; -import type { AgentInstanceMessage, IAgentInstanceService } from '../interface'; -import { findPromptById } from '../promptConcat/promptConcat'; import type { AiAPIConfig } from '../promptConcat/promptConcatSchema'; -import type { IPrompt } from '../promptConcat/promptConcatSchema'; -import { schemaToToolContent } from '../utilities/schemaToToolContent'; -import type { PromptConcatTool } from './types'; +import { registerToolDefinition, type ToolExecutionResult } from './defineTool'; /** - * Wiki Search Parameter Schema - * Configuration parameters for the wiki search plugin + * Wiki Search Config Schema (user-configurable in UI) */ export const WikiSearchParameterSchema = z.object({ - position: z.enum(['relative', 'absolute', 'before', 'after']).meta({ - title: t('Schema.Position.TypeTitle'), - description: t('Schema.Position.Type'), - }), - targetId: z.string().meta({ - title: t('Schema.Position.TargetIdTitle'), - description: t('Schema.Position.TargetId'), - }), - bottom: z.number().optional().meta({ - title: t('Schema.Position.BottomTitle'), - description: t('Schema.Position.Bottom'), - }), sourceType: z.enum(['wiki']).meta({ title: t('Schema.WikiSearch.SourceTypeTitle'), description: t('Schema.WikiSearch.SourceType'), @@ -64,23 +46,16 @@ export const WikiSearchParameterSchema = z.object({ description: t('Schema.WikiSearch.Description'), }); -/** - * Type definition for wiki search parameters - */ export type WikiSearchParameter = z.infer; -/** - * Get the wiki search parameter schema - * @returns The schema for wiki search parameters - */ export function getWikiSearchParameterSchema() { return WikiSearchParameterSchema; } /** - * Parameter schema for Wiki search tool + * LLM-callable tool schema for wiki search */ -const WikiSearchToolParameterSchema = z.object({ +const WikiSearchToolSchema = z.object({ workspaceName: z.string().meta({ title: t('Schema.WikiSearch.Tool.Parameters.workspaceName.Title'), description: t('Schema.WikiSearch.Tool.Parameters.workspaceName.Description'), @@ -114,12 +89,12 @@ const WikiSearchToolParameterSchema = z.object({ ], }); -type WikiSearchToolParameter = z.infer; +type WikiSearchToolParameters = z.infer; /** - * Parameter schema for Wiki update embeddings tool + * LLM-callable tool schema for updating embeddings */ -const WikiUpdateEmbeddingsToolParameterSchema = z.object({ +const WikiUpdateEmbeddingsToolSchema = z.object({ workspaceName: z.string().meta({ title: t('Schema.WikiSearch.Tool.UpdateEmbeddings.workspaceName.Title'), description: t('Schema.WikiSearch.Tool.UpdateEmbeddings.workspaceName.Description'), @@ -128,160 +103,93 @@ const WikiUpdateEmbeddingsToolParameterSchema = z.object({ title: t('Schema.WikiSearch.Tool.UpdateEmbeddings.forceUpdate.Title'), description: t('Schema.WikiSearch.Tool.UpdateEmbeddings.forceUpdate.Description'), }), -}) - .meta({ - title: 'wiki-update-embeddings', - description: t('Schema.WikiSearch.Tool.UpdateEmbeddings.Description'), - examples: [ - { workspaceName: '我的知识库', forceUpdate: false }, - { workspaceName: 'wiki', forceUpdate: true }, - ], - }); +}).meta({ + title: 'wiki-update-embeddings', + description: t('Schema.WikiSearch.Tool.UpdateEmbeddings.Description'), + examples: [ + { workspaceName: '我的知识库', forceUpdate: false }, + { workspaceName: 'wiki', forceUpdate: true }, + ], +}); -type WikiUpdateEmbeddingsToolParameter = z.infer; +type WikiUpdateEmbeddingsToolParameters = z.infer; /** - * Execute wiki search tool + * Execute wiki search */ -async function executeWikiSearchTool( - parameters: WikiSearchToolParameter, - context?: { agentId?: string; messageId?: string; config?: AiAPIConfig }, -): Promise<{ success: boolean; data?: string; error?: string; metadata?: Record }> { - try { - const { workspaceName, searchType = 'filter', filter, query, limit = 10, threshold = 0.7 } = parameters; +async function executeWikiSearch( + parameters: WikiSearchToolParameters, + aiConfig?: AiAPIConfig, +): Promise { + const { workspaceName, searchType = 'filter', filter, query, limit = 10, threshold = 0.7 } = parameters; - // Get workspace service + try { const workspaceService = container.get(serviceIdentifier.Workspace); const wikiService = container.get(serviceIdentifier.Wiki); - // Look up workspace ID from workspace name or ID + // Look up workspace const workspaces = await workspaceService.getWorkspacesAsList(); - const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName); + const targetWorkspace = workspaces.find((ws) => ws.name === workspaceName || ws.id === workspaceName); if (!targetWorkspace) { return { success: false, error: i18n.t('Tool.WikiSearch.Error.WorkspaceNotFound', { workspaceName, - availableWorkspaces: workspaces.map(w => `${w.name} (${w.id})`).join(', '), + availableWorkspaces: workspaces.map((w) => `${w.name} (${w.id})`).join(', '), }), }; } const workspaceID = targetWorkspace.id; - if (!await workspaceService.exists(workspaceID)) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.Error.WorkspaceNotExist', { workspaceID }), - }; + if (!(await workspaceService.exists(workspaceID))) { + return { success: false, error: i18n.t('Tool.WikiSearch.Error.WorkspaceNotExist', { workspaceID }) }; } - logger.debug('Executing wiki search', { - workspaceID, - workspaceName, - searchType, - filter, - query, - agentId: context?.agentId, - }); + logger.debug('Executing wiki search', { workspaceID, workspaceName, searchType, filter, query }); - // Execute search based on type - let results: Array<{ title: string; text?: string; fields?: ITiddlerFields; similarity?: number }> = []; - let searchMetadata: Record = { - workspaceID, - workspaceName, - searchType, - }; + const results: Array<{ title: string; text?: string; fields?: ITiddlerFields; similarity?: number }> = []; + let searchMetadata: Record = { workspaceID, workspaceName, searchType }; if (searchType === 'vector') { - // Vector search if (!query) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.Error.VectorSearchRequiresQuery'), - }; + return { success: false, error: i18n.t('Tool.WikiSearch.Error.VectorSearchRequiresQuery') }; } - - if (!context?.config) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.Error.VectorSearchRequiresConfig'), - }; + if (!aiConfig) { + return { success: false, error: i18n.t('Tool.WikiSearch.Error.VectorSearchRequiresConfig') }; } const wikiEmbeddingService = container.get(serviceIdentifier.WikiEmbedding); try { - const vectorResults = await wikiEmbeddingService.searchSimilar( - workspaceID, - query, - context.config, - limit, - threshold, - ); + const vectorResults = await wikiEmbeddingService.searchSimilar(workspaceID, query, aiConfig, limit, threshold); if (vectorResults.length === 0) { return { success: true, data: i18n.t('Tool.WikiSearch.Success.NoVectorResults', { query, workspaceName, threshold }), - metadata: { - ...searchMetadata, - query, - limit, - threshold, - resultCount: 0, - }, + metadata: { ...searchMetadata, query, limit, threshold, resultCount: 0 }, }; } - // Convert vector search results to standard format - results = vectorResults.map(vr => ({ - title: vr.record.tiddlerTitle, - text: '', // Vector search returns chunks, full text needs separate retrieval - similarity: vr.similarity, - })); - - // Retrieve full tiddler content for vector results - const fullContentResults: typeof results = []; - for (const result of results) { + // Get full content for results + for (const vr of vectorResults) { try { - const tiddlerFields = await wikiService.wikiOperationInServer( - WikiChannel.getTiddlersAsJson, - workspaceID, - [result.title], - ); - if (tiddlerFields.length > 0) { - fullContentResults.push({ - ...result, - text: tiddlerFields[0].text, - fields: tiddlerFields[0], - }); - } else { - fullContentResults.push(result); - } - } catch (error) { - logger.warn(`Error retrieving full tiddler content for ${result.title}`, { - error, + const tiddlerFields = await wikiService.wikiOperationInServer(WikiChannel.getTiddlersAsJson, workspaceID, [vr.record.tiddlerTitle]); + results.push({ + title: vr.record.tiddlerTitle, + text: tiddlerFields[0]?.text, + fields: tiddlerFields[0], + similarity: vr.similarity, }); - fullContentResults.push(result); + } catch { + results.push({ title: vr.record.tiddlerTitle, similarity: vr.similarity }); } } - results = fullContentResults; - searchMetadata = { - ...searchMetadata, - query, - limit, - threshold, - resultCount: results.length, - }; + searchMetadata = { ...searchMetadata, query, limit, threshold, resultCount: results.length }; } catch (error) { - logger.error('Vector search failed', { - error, - workspaceID, - query, - }); return { success: false, error: i18n.t('Tool.WikiSearch.Error.VectorSearchFailed', { @@ -290,12 +198,9 @@ async function executeWikiSearchTool( }; } } else { - // Traditional filter search + // Filter search if (!filter) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.Error.FilterSearchRequiresFilter'), - }; + return { success: false, error: i18n.t('Tool.WikiSearch.Error.FilterSearchRequiresFilter') }; } const tiddlerTitles = await wikiService.wikiOperationInServer(WikiChannel.runFilter, workspaceID, [filter]); @@ -304,56 +209,26 @@ async function executeWikiSearchTool( return { success: true, data: i18n.t('Tool.WikiSearch.Success.NoResults', { filter, workspaceName }), - metadata: { - ...searchMetadata, - filter, - resultCount: 0, - }, + metadata: { ...searchMetadata, filter, resultCount: 0 }, }; } - // Retrieve full tiddler content for each tiddler for (const title of tiddlerTitles) { try { const tiddlerFields = await wikiService.wikiOperationInServer(WikiChannel.getTiddlersAsJson, workspaceID, [title]); - if (tiddlerFields.length > 0) { - results.push({ - title, - text: tiddlerFields[0].text, - fields: tiddlerFields[0], - }); - } else { - results.push({ title }); - } - } catch (error) { - logger.warn(`Error retrieving tiddler content for ${title}`, { - error, - }); + results.push({ title, text: tiddlerFields[0]?.text, fields: tiddlerFields[0] }); + } catch { results.push({ title }); } } - searchMetadata = { - ...searchMetadata, - filter, - resultCount: tiddlerTitles.length, - returnedCount: results.length, - }; + searchMetadata = { ...searchMetadata, filter, resultCount: results.length }; } - // Format results as text with content - let content = ''; - if (searchType === 'vector') { - content = i18n.t('Tool.WikiSearch.Success.VectorCompleted', { - totalResults: results.length, - query, - }); - } else { - content = i18n.t('Tool.WikiSearch.Success.Completed', { - totalResults: results.length, - shownResults: results.length, - }) + '\n\n'; - } + // Format results + let content = searchType === 'vector' + ? i18n.t('Tool.WikiSearch.Success.VectorCompleted', { totalResults: results.length, query }) + : i18n.t('Tool.WikiSearch.Success.Completed', { totalResults: results.length, shownResults: results.length }) + '\n\n'; for (const result of results) { content += `**Tiddler: ${result.title}**`; @@ -361,26 +236,12 @@ async function executeWikiSearchTool( content += ` (Similarity: ${(result.similarity * 100).toFixed(1)}%)`; } content += '\n\n'; - if (result.text) { - content += '```tiddlywiki\n'; - content += result.text; - content += '\n```\n\n'; - } else { - content += '(Content not available)\n\n'; - } + content += result.text ? `\`\`\`tiddlywiki\n${result.text}\n\`\`\`\n\n` : '(Content not available)\n\n'; } - return { - success: true, - data: content, - metadata: searchMetadata, - }; + return { success: true, data: content, metadata: searchMetadata }; } catch (error) { - logger.error('Wiki search tool execution error', { - error, - parameters, - }); - + logger.error('Wiki search failed', { error, params: parameters }); return { success: false, error: i18n.t('Tool.WikiSearch.Error.ExecutionFailed', { @@ -391,90 +252,57 @@ async function executeWikiSearchTool( } /** - * Execute wiki update embeddings tool + * Execute wiki update embeddings */ -async function executeWikiUpdateEmbeddingsTool( - parameters: WikiUpdateEmbeddingsToolParameter, - context?: { agentId?: string; messageId?: string; aiConfig?: unknown }, -): Promise<{ success: boolean; data?: string; error?: string; metadata?: Record }> { - try { - const { workspaceName, forceUpdate = false } = parameters; +async function executeWikiUpdateEmbeddings( + parameters: WikiUpdateEmbeddingsToolParameters, + aiConfig?: AiAPIConfig, +): Promise { + const { workspaceName, forceUpdate = false } = parameters; - // Get workspace service + try { const workspaceService = container.get(serviceIdentifier.Workspace); const wikiEmbeddingService = container.get(serviceIdentifier.WikiEmbedding); - // Look up workspace ID from workspace name or ID const workspaces = await workspaceService.getWorkspacesAsList(); - const targetWorkspace = workspaces.find(ws => ws.name === workspaceName || ws.id === workspaceName); + const targetWorkspace = workspaces.find((ws) => ws.name === workspaceName || ws.id === workspaceName); if (!targetWorkspace) { return { success: false, error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.WorkspaceNotFound', { workspaceName, - availableWorkspaces: workspaces.map(w => `${w.name} (${w.id})`).join(', '), + availableWorkspaces: workspaces.map((w) => `${w.name} (${w.id})`).join(', '), }), }; } const workspaceID = targetWorkspace.id; - if (!await workspaceService.exists(workspaceID)) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.WorkspaceNotExist', { workspaceID }), - }; + if (!(await workspaceService.exists(workspaceID))) { + return { success: false, error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.WorkspaceNotExist', { workspaceID }) }; } - // Check if AI config is available - if (!context?.aiConfig) { - return { - success: false, - error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.NoAIConfig'), - }; + if (!aiConfig) { + return { success: false, error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.NoAIConfig') }; } - logger.debug('Executing wiki embedding generation', { - workspaceID, - workspaceName, - forceUpdate, - agentId: context?.agentId, - }); + logger.debug('Executing wiki embedding generation', { workspaceID, workspaceName, forceUpdate }); - // Generate embeddings - await wikiEmbeddingService.generateEmbeddings( - workspaceID, - context.aiConfig as Parameters[1], - forceUpdate, - ); - - // Get stats after generation + await wikiEmbeddingService.generateEmbeddings(workspaceID, aiConfig, forceUpdate); const stats = await wikiEmbeddingService.getEmbeddingStats(workspaceID); - const result = i18n.t('Tool.WikiSearch.UpdateEmbeddings.Success.Generated', { - workspaceName, - totalEmbeddings: stats.totalEmbeddings, - totalNotes: stats.totalNotes, - }); - return { success: true, - data: result, - metadata: { - workspaceID, + data: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Success.Generated', { workspaceName, totalEmbeddings: stats.totalEmbeddings, totalNotes: stats.totalNotes, - forceUpdate, - }, + }), + metadata: { workspaceID, workspaceName, ...stats, forceUpdate }, }; } catch (error) { - logger.error('Wiki update embeddings tool execution error', { - error, - parameters, - }); - + logger.error('Wiki update embeddings failed', { error, params: parameters }); return { success: false, error: i18n.t('Tool.WikiSearch.UpdateEmbeddings.Error.ExecutionFailed', { @@ -485,325 +313,52 @@ async function executeWikiUpdateEmbeddingsTool( } /** - * Wiki Search plugin - Prompt processing - * Handles tool list injection for wiki search and update embeddings functionality + * Wiki Search Tool Definition */ -export const wikiSearchTool: PromptConcatTool = (hooks) => { - // First tapAsync: Tool list injection - hooks.processPrompts.tapAsync('wikiSearchTool-toolList', async (context, callback) => { - const { toolConfig, prompts } = context; +const wikiSearchDefinition = registerToolDefinition({ + toolId: 'wikiSearch', + displayName: 'Wiki Search', + description: 'Search content in wiki workspaces and manage vector embeddings', + configSchema: WikiSearchParameterSchema, + llmToolSchemas: { + 'wiki-search': WikiSearchToolSchema, + 'wiki-update-embeddings': WikiUpdateEmbeddingsToolSchema, + }, - if (toolConfig.toolId !== 'wikiSearch' || !toolConfig.wikiSearchParam) { - callback(); + onProcessPrompts({ config, toolConfig, injectToolList }) { + const toolListPosition = config.toolListPosition; + if (!toolListPosition?.targetId) return; + + injectToolList({ + targetId: toolListPosition.targetId, + position: 'child', // Add as child of target prompt + caption: 'Wiki search tool', + }); + + logger.debug('Wiki search tool list injected', { + targetId: toolListPosition.targetId, + position: toolListPosition.position, + toolId: toolConfig.id, + }); + }, + + async onResponseComplete({ toolCall, executeToolCall, agentFrameworkContext }) { + if (!toolCall) return; + + // Check cancellation + if (agentFrameworkContext.isCancelled()) { + logger.debug('Wiki search cancelled', { agentId: agentFrameworkContext.agent.id }); return; } - const wikiSearchParameter = toolConfig.wikiSearchParam; + const aiConfig = agentFrameworkContext.agent.aiApiConfig as AiAPIConfig | undefined; - try { - // Handle tool list injection if toolListPosition is configured - const toolListPosition = wikiSearchParameter.toolListPosition; - if (toolListPosition?.targetId) { - const toolListTarget = findPromptById(prompts, toolListPosition.targetId); - if (!toolListTarget) { - logger.warn('Tool list target prompt not found', { - targetId: toolListPosition.targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Get available wikis - now handled by workspacesListPlugin - // The workspaces list will be injected separately by workspacesListPlugin - - // Inject both wiki-search and wiki-update-embeddings tools - const wikiSearchToolContent = schemaToToolContent(WikiSearchToolParameterSchema); - const wikiUpdateEmbeddingsToolContent = schemaToToolContent(WikiUpdateEmbeddingsToolParameterSchema); - - // Combine both tools into one prompt - const combinedToolContent = `${wikiSearchToolContent}\n\n${wikiUpdateEmbeddingsToolContent}`; - - const toolPrompt: IPrompt = { - id: `wiki-tool-list-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - text: combinedToolContent, - tags: ['toolList', 'wikiSearch', 'wikiEmbedding'], - // Use singular caption to match test expectations - caption: 'Wiki search tool', - enabled: true, - }; - - // Insert at specified position - if (toolListPosition.position === 'before') { - toolListTarget.parent.splice(toolListTarget.index, 0, toolPrompt); - } else { - toolListTarget.parent.splice(toolListTarget.index + 1, 0, toolPrompt); - } - - logger.debug('Wiki tool list injected successfully', { - targetId: toolListPosition.targetId, - position: toolListPosition.position, - toolCount: 2, // wiki-search and wiki-update-embeddings - toolId: toolConfig.id, - }); - } - - callback(); - } catch (error) { - logger.error('Error in wiki search tool list injection', { - error, - toolId: toolConfig.id, - }); - callback(); + if (toolCall.toolId === 'wiki-search') { + await executeToolCall('wiki-search', (parameters) => executeWikiSearch(parameters, aiConfig)); + } else if (toolCall.toolId === 'wiki-update-embeddings') { + await executeToolCall('wiki-update-embeddings', (parameters) => executeWikiUpdateEmbeddings(parameters, aiConfig)); } - }); + }, +}); - // 2. Tool execution when AI response is complete - hooks.responseComplete.tapAsync('wikiSearchTool-handler', async (context, callback) => { - try { - const { agentFrameworkContext, response, agentFrameworkConfig } = context; - - // Find this plugin's configuration import { AgentFrameworkConfig } - const wikiSearchToolConfig = agentFrameworkConfig?.plugins?.find((p: { toolId: string; [key: string]: unknown }) => p.toolId === 'wikiSearch'); - const wikiSearchParameter = wikiSearchToolConfig?.wikiSearchParam as { toolResultDuration?: number } | undefined; - const toolResultDuration = wikiSearchParameter?.toolResultDuration || 1; // Default to 1 round - - if (response.status !== 'done' || !response.content) { - callback(); - return; - } - - // Check for wiki search or update embeddings tool calls in the AI response - const toolMatch = matchToolCalling(response.content); - - if (!toolMatch.found || (toolMatch.toolId !== 'wiki-search' && toolMatch.toolId !== 'wiki-update-embeddings')) { - callback(); - return; - } - - logger.debug('Wiki tool call detected', { - toolId: toolMatch.toolId, - agentId: agentFrameworkContext.agent.id, - }); - - // Set duration=1 for the AI message containing the tool call - // Find the most recent AI message (should be the one containing the tool call) - const aiMessages = agentFrameworkContext.agent.messages.filter((message: AgentInstanceMessage) => message.role === 'assistant'); - if (aiMessages.length > 0) { - const latestAiMessage = aiMessages[aiMessages.length - 1]; - if (latestAiMessage.content === response.content) { - latestAiMessage.duration = 1; - latestAiMessage.metadata = { - ...latestAiMessage.metadata, - containsToolCall: true, - toolId: toolMatch.toolId, - }; - - // Notify frontend about the duration change immediately (no debounce delay) - const agentInstanceService = container.get(serviceIdentifier.AgentInstance); - // Persist the AI message right away so DB ordering reflects this message before tool results - try { - if (!latestAiMessage.created) latestAiMessage.created = new Date(); - await agentInstanceService.saveUserMessage(latestAiMessage); - latestAiMessage.metadata = { ...latestAiMessage.metadata, isPersisted: true }; - } catch (error) { - logger.warn('Failed to persist AI message containing tool call immediately', { - error, - messageId: latestAiMessage.id, - }); - } - - // Also update UI immediately - agentInstanceService.debounceUpdateMessage(latestAiMessage, agentFrameworkContext.agent.id, 0); // No delay - - logger.debug('Set duration=1 for AI tool call message', { - messageId: latestAiMessage.id, - toolId: toolMatch.toolId, - }); - } - } - - // Execute the appropriate tool - try { - // Check if cancelled before starting tool execution - if (agentFrameworkContext.isCancelled()) { - logger.debug('Wiki tool cancelled before execution', { - toolId: toolMatch.toolId, - agentId: agentFrameworkContext.agent.id, - }); - callback(); - return; - } - - // Validate parameters and execute based on tool type - let result: { success: boolean; data?: string; error?: string; metadata?: Record }; - let validatedParameters: WikiSearchToolParameter | WikiUpdateEmbeddingsToolParameter; - - if (toolMatch.toolId === 'wiki-search') { - validatedParameters = WikiSearchToolParameterSchema.parse(toolMatch.parameters); - result = await executeWikiSearchTool( - validatedParameters, - { - agentId: agentFrameworkContext.agent.id, - messageId: agentFrameworkContext.agent.messages[agentFrameworkContext.agent.messages.length - 1]?.id, - config: agentFrameworkContext.agent.aiApiConfig as AiAPIConfig | undefined, - }, - ); - } else { - // wiki-update-embeddings - validatedParameters = WikiUpdateEmbeddingsToolParameterSchema.parse(toolMatch.parameters); - result = await executeWikiUpdateEmbeddingsTool( - validatedParameters, - { - agentId: agentFrameworkContext.agent.id, - messageId: agentFrameworkContext.agent.messages[agentFrameworkContext.agent.messages.length - 1]?.id, - aiConfig: agentFrameworkContext.agent.aiApiConfig, - }, - ); - } - - // Check if cancelled after tool execution - if (agentFrameworkContext.isCancelled()) { - logger.debug('Wiki tool cancelled after execution', { - toolId: toolMatch.toolId, - agentId: agentFrameworkContext.agent.id, - }); - callback(); - return; - } - - // Format the tool result for display - let toolResultText: string; - let isError = false; - - if (result.success && result.data) { - toolResultText = `\nTool: ${toolMatch.toolId}\nParameters: ${JSON.stringify(validatedParameters)}\nResult: ${result.data}\n`; - } else { - isError = true; - toolResultText = `\nTool: ${toolMatch.toolId}\nParameters: ${JSON.stringify(validatedParameters)}\nError: ${result.error}\n`; - } - - // Set up actions to continue the conversation with tool results - const responseContext = context; - if (!responseContext.actions) { - responseContext.actions = {}; - } - responseContext.actions.yieldNextRoundTo = 'self'; - - logger.debug('Wiki search setting yieldNextRoundTo=self', { - toolId: 'wiki-search', - agentId: agentFrameworkContext.agent.id, - messageCount: agentFrameworkContext.agent.messages.length, - toolResultPreview: toolResultText.slice(0, 200), - }); - - // Immediately add the tool result message to history BEFORE calling toolExecuted - const nowTool = new Date(); - const toolResultMessage: AgentInstanceMessage = { - id: `tool-result-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'tool', // Tool result message - content: toolResultText, - created: nowTool, - modified: nowTool, - duration: toolResultDuration, // Use configurable duration - default 1 round for tool results - metadata: { - isToolResult: true, - isError, - toolId: 'wiki-search', - toolParameters: validatedParameters, - isPersisted: false, // Required by messageManagementPlugin to identify new tool results - isComplete: true, // Mark as complete to prevent messageManagementPlugin from overwriting content - artificialOrder: Date.now() + 10, // Additional ordering hint - }, - }; - agentFrameworkContext.agent.messages.push(toolResultMessage); - - // Do not persist immediately here. Let messageManagementPlugin handle persistence - - // Signal that tool was executed AFTER adding and persisting the message - await hooks.toolExecuted.promise({ - agentFrameworkContext, - toolResult: { - success: true, - data: result.success ? result.data : result.error, - metadata: { toolCount: 1 }, - }, - toolInfo: { - toolId: 'wiki-search', - parameters: validatedParameters, - originalText: toolMatch.originalText, - }, - requestId: context.requestId, - }); - - logger.debug('Wiki search tool execution completed', { - toolResultText, - actions: responseContext.actions, - toolResultMessageId: toolResultMessage.id, - aiMessageDuration: aiMessages[aiMessages.length - 1]?.duration, - }); - } catch (error) { - logger.error('Wiki search tool execution failed', { - error, - toolCall: toolMatch, - }); - - // Set up error response for next round - const responseContext = context; - if (!responseContext.actions) { - responseContext.actions = {}; - } - responseContext.actions.yieldNextRoundTo = 'self'; - const errorMessage = ` -Tool: wiki-search -Error: ${error instanceof Error ? error.message : String(error)} -`; - - // Add error message to history BEFORE calling toolExecuted - // Use the current time; order will be determined by save order - const nowError = new Date(); - const errorResultMessage: AgentInstanceMessage = { - id: `tool-error-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - agentId: agentFrameworkContext.agent.id, - role: 'tool', // Tool error message - content: errorMessage, - created: nowError, - modified: nowError, - duration: 2, // Error messages are visible to AI for 2 rounds: immediate + next round to allow explanation - metadata: { - isToolResult: true, - isError: true, - toolId: 'wiki-search', - isPersisted: false, // Required by messageManagementPlugin to identify new tool results - isComplete: true, // Mark as complete to prevent messageManagementPlugin from overwriting content - }, - }; - agentFrameworkContext.agent.messages.push(errorResultMessage); - - // Do not persist immediately; let messageManagementPlugin handle it during toolExecuted - await hooks.toolExecuted.promise({ - agentFrameworkContext, - toolResult: { - success: false, - error: error instanceof Error ? error.message : String(error), - }, - toolInfo: { - toolId: 'wiki-search', - parameters: {}, - }, - }); - - logger.debug('Wiki search tool execution failed but error result added', { - errorResultMessageId: errorResultMessage.id, - aiMessageDuration: aiMessages[aiMessages.length - 1]?.duration, - }); - } - - callback(); - } catch (error) { - logger.error('Error in wiki search handler plugin', { error }); - callback(); - } - }); -}; +export const wikiSearchTool = wikiSearchDefinition.tool; diff --git a/src/services/agentInstance/tools/workspacesList.ts b/src/services/agentInstance/tools/workspacesList.ts index ed195968..65df91b1 100644 --- a/src/services/agentInstance/tools/workspacesList.ts +++ b/src/services/agentInstance/tools/workspacesList.ts @@ -1,15 +1,20 @@ /** - * Workspaces List plugin - * Handles injection of available wiki workspaces list into prompts + * Workspaces List Tool + * Injects available wiki workspaces list into prompts */ +import { container } from '@services/container'; +import { logger } from '@services/libs/log'; +import serviceIdentifier from '@services/serviceIdentifier'; +import type { IWorkspaceService } from '@services/workspaces/interface'; +import { isWikiWorkspace } from '@services/workspaces/interface'; import { identity } from 'lodash'; import { z } from 'zod/v4'; +import { registerToolDefinition } from './defineTool'; const t = identity; /** * Workspaces List Parameter Schema - * Configuration parameters for the workspaces list plugin */ export const WorkspacesListParameterSchema = z.object({ targetId: z.string().meta({ @@ -25,115 +30,72 @@ export const WorkspacesListParameterSchema = z.object({ description: t('Schema.WorkspacesList.Description'), }); -/** - * Type definition for workspaces list parameters - */ export type WorkspacesListParameter = z.infer; -/** - * Get the workspaces list parameter schema - * @returns The schema for workspaces list parameters - */ export function getWorkspacesListParameterSchema() { return WorkspacesListParameterSchema; } -import { container } from '@services/container'; -import { logger } from '@services/libs/log'; -import serviceIdentifier from '@services/serviceIdentifier'; -import type { IWorkspaceService } from '@services/workspaces/interface'; -import { isWikiWorkspace } from '@services/workspaces/interface'; - -import { findPromptById } from '../promptConcat/promptConcat'; -import type { PromptConcatTool } from './types'; - /** - * Workspaces List plugin - Prompt processing - * Handles injection of available wiki workspaces list + * Workspaces List Tool Definition */ -export const workspacesListTool: PromptConcatTool = (hooks) => { - // Tool list injection - hooks.processPrompts.tapAsync('workspacesListTool-injection', async (context, callback) => { - const { toolConfig, prompts } = context; +const workspacesListDefinition = registerToolDefinition({ + toolId: 'workspacesList', + displayName: 'Workspaces List', + description: 'Inject available wiki workspaces list into prompts', + configSchema: WorkspacesListParameterSchema, - if (toolConfig.toolId !== 'workspacesList' || !toolConfig.workspacesListParam) { - callback(); + async onProcessPrompts({ config, toolConfig, findPrompt }) { + if (!config.targetId) return; + + const target = findPrompt(config.targetId); + if (!target) { + logger.warn('Workspaces list target prompt not found', { + targetId: config.targetId, + toolId: toolConfig.id, + }); return; } - const workspacesListParameter = toolConfig.workspacesListParam; + // Get available wikis + const workspaceService = container.get(serviceIdentifier.Workspace); + const workspaces = await workspaceService.getWorkspacesAsList(); + const wikiWorkspaces = workspaces.filter(isWikiWorkspace); - try { - // Handle workspaces list injection if targetId is configured - if (workspacesListParameter?.targetId) { - const target = findPromptById(prompts, workspacesListParameter.targetId); - if (!target) { - logger.warn('Workspaces list target prompt not found', { - targetId: workspacesListParameter.targetId, - toolId: toolConfig.id, - }); - callback(); - return; - } - - // Get available wikis - const workspaceService = container.get(serviceIdentifier.Workspace); - const workspaces = await workspaceService.getWorkspacesAsList(); - const wikiWorkspaces = workspaces.filter(isWikiWorkspace); - - if (wikiWorkspaces.length > 0) { - // Use fixed list format for simplicity - const workspacesList = wikiWorkspaces - .map(workspace => `- ${workspace.name} (ID: ${workspace.id})`) - .join('\n'); - - const workspacesListContent = `Available Wiki Workspaces:\n${workspacesList}`; - - // Insert the workspaces list content based on position - if (workspacesListParameter.position === 'after') { - if (!target.prompt.children) { - target.prompt.children = []; - } - const insertIndex = target.prompt.children.length; - target.prompt.children.splice(insertIndex, 0, { - id: `workspaces-list-${toolConfig.id}`, - caption: 'Available Workspaces', - text: workspacesListContent, - }); - } else if (workspacesListParameter.position === 'before') { - if (!target.prompt.children) { - target.prompt.children = []; - } - target.prompt.children.unshift({ - id: `workspaces-list-${toolConfig.id}`, - caption: 'Available Workspaces', - text: workspacesListContent, - }); - } else { - // Default to appending text - target.prompt.text = (target.prompt.text || '') + '\n' + workspacesListContent; - } - - logger.debug('Workspaces list injected successfully', { - targetId: workspacesListParameter.targetId, - position: workspacesListParameter.position, - toolId: toolConfig.id, - workspaceCount: wikiWorkspaces.length, - }); - } else { - logger.debug('No wiki workspaces found to inject', { - toolId: toolConfig.id, - }); - } - } - - callback(); - } catch (error) { - logger.error('Error in workspaces list injection', { - error, - toolId: toolConfig.id, - }); - callback(); + if (wikiWorkspaces.length === 0) { + logger.debug('No wiki workspaces found to inject', { toolId: toolConfig.id }); + return; } - }); -}; + + const workspacesList = wikiWorkspaces + .map((workspace) => `- ${workspace.name} (ID: ${workspace.id})`) + .join('\n'); + const workspacesListContent = `Available Wiki Workspaces:\n${workspacesList}`; + + // Insert based on position + if (!target.prompt.children) { + target.prompt.children = []; + } + + const newPrompt = { + id: `workspaces-list-${toolConfig.id}`, + caption: 'Available Workspaces', + text: workspacesListContent, + }; + + if (config.position === 'before') { + target.prompt.children.unshift(newPrompt); + } else { + target.prompt.children.push(newPrompt); + } + + logger.debug('Workspaces list injected successfully', { + targetId: config.targetId, + position: config.position, + toolId: toolConfig.id, + workspaceCount: wikiWorkspaces.length, + }); + }, +}); + +export const workspacesListTool = workspacesListDefinition.tool; diff --git a/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts b/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts index d1446f0b..86e9a645 100644 --- a/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts +++ b/src/services/agentInstance/utilities/__tests__/schemaToToolContent.test.ts @@ -1,28 +1,31 @@ /** * Tests for schemaToToolContent utility */ -import { describe, expect, it, vi } from 'vitest'; +import { i18n } from '@services/libs/i18n'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { z } from 'zod/v4'; import { schemaToToolContent } from '../schemaToToolContent'; -// Mock i18n -vi.mock('@services/libs/i18n', () => ({ - i18n: { - t: vi.fn((key: string) => { - const translations: Record = { - 'Tool.Schema.Required': '必需', - 'Tool.Schema.Optional': '可选', - 'Tool.Schema.Description': '描述', - 'Tool.Schema.Parameters': '参数', - 'Tool.Schema.Examples': '使用示例', - }; - return translations[key] || key; - }), - }, -})); - describe('schemaToToolContent', () => { + beforeEach(() => { + // Setup i18n mock for each test + + vi.mocked(i18n.t).mockImplementation( + ((...args: unknown[]) => { + const key = String(args[0]); + const translations: Record = { + 'Tool.Schema.Required': '必需', + 'Tool.Schema.Optional': '可选', + 'Tool.Schema.Description': '描述', + 'Tool.Schema.Parameters': '参数', + 'Tool.Schema.Examples': '使用示例', + }; + return translations[key] ?? key; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + }); it('should generate tool content from schema with title and description', () => { const testSchema = z.object({ name: z.string().describe('The name parameter'),