refactor: simplify tool writing

This commit is contained in:
linonetwo 2025-12-08 21:29:48 +08:00
parent b8a9bbcf3d
commit 46b95cd65b
32 changed files with 2178 additions and 1796 deletions

12
.github/mcp.json vendored Normal file
View file

@ -0,0 +1,12 @@
{
"servers": {
"chromedevtools/chrome-devtools-mcp": {
"type": "stdio",
"command": "pnpm dlx",
"args": [
"chrome-devtools-mcp@latest",
"--browserUrl=http://localhost:9222"
]
}
}
}

View file

@ -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",

View file

@ -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,
};

View file

@ -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

View file

@ -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');

View file

@ -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

View file

@ -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,
};

View file

@ -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<void> {
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({

View file

@ -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();
});
};
```

View file

@ -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<IAgentInstanceService>(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();
}
});
}

View file

@ -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);
}

View file

@ -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<IAgentInstanceService>(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<IAgentInstanceService>(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<IAgentInstanceService>(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();
}
});
}

View file

@ -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<IAgentInstanceService>(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<IAgentInstanceService>(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<IAgentInstanceService>(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<IAgentInstanceService>(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();
}
});
}

View file

@ -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<TConfigSchema extends z.ZodType = z.ZodType> {
/** 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<TConfigSchema>) => Promise<void> | void;
/**
* Called during post-processing phase.
* Use this to transform LLM responses.
*/
onPostProcess?: (context: PostProcessModifierContext<TConfigSchema>) => Promise<void> | void;
}
/**
* Context passed to prompt processing handlers
*/
export interface ModifierHandlerContext<TConfigSchema extends z.ZodType> {
/** The parsed configuration for this modifier instance */
config: z.infer<TConfigSchema>;
/** 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<typeof findPromptById>;
/** 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<TConfigSchema extends z.ZodType> extends Omit<ModifierHandlerContext<TConfigSchema>, '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<TConfigSchema extends z.ZodType>(
definition: ModifierDefinition<TConfigSchema>,
): {
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<string, unknown>)[parameterKey];
if (!rawConfig) {
callback();
return;
}
// Parse and validate config
const config = configSchema.parse(rawConfig) as z.infer<TConfigSchema>;
// Build handler context with utilities
const handlerContext: ModifierHandlerContext<TConfigSchema> = {
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<string, unknown>)[parameterKey];
if (!rawConfig) {
callback();
return;
}
const config = configSchema.parse(rawConfig) as z.infer<TConfigSchema>;
const handlerContext: PostProcessModifierContext<TConfigSchema> = {
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<string, ReturnType<typeof defineModifier>>();
/**
* Register a modifier definition
*/
export function registerModifier<TConfigSchema extends z.ZodType>(
definition: ModifierDefinition<TConfigSchema>,
): ReturnType<typeof defineModifier<TConfigSchema>> {
const modifierDefinition = defineModifier(definition);
modifierRegistry.set(modifierDefinition.modifierId, modifierDefinition as ReturnType<typeof defineModifier>);
return modifierDefinition;
}
/**
* Get all registered modifiers
*/
export function getAllModifiers(): Map<string, ReturnType<typeof defineModifier>> {
return modifierRegistry;
}
/**
* Get a modifier by ID
*/
export function getModifier(modifierId: string): ReturnType<typeof defineModifier> | undefined {
return modifierRegistry.get(modifierId);
}

View file

@ -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<typeof DynamicPositionParameterSchema>;
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;

View file

@ -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<typeof FullReplacementParameterSchema>;
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<IPrompt['role']>;
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;

View file

@ -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<string, (hooks: import('../../tools/types').PromptConcatHooks) => void> {
const modifiers = getAllModifiers();
const result = new Map<string, (hooks: import('../../tools/types').PromptConcatHooks) => void>();
for (const [id, definition] of modifiers) {
result.set(id, definition.modifier);
}
return result;
}

View file

@ -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', {

View file

@ -131,14 +131,9 @@ export type DefaultAgents = z.infer<ReturnType<typeof getDefaultAgentsSchema>>;
export type AgentPromptDescription = z.infer<ReturnType<typeof getAgentConfigSchema>>;
export type AiAPIConfig = z.infer<typeof AIConfigSchema>;
export type AgentFrameworkConfig = z.infer<ReturnType<typeof getFrameworkConfigSchema>>;
// 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';

View file

@ -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;

View file

@ -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 {

View file

@ -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);

View file

@ -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 () => {

View file

@ -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 () => {

View file

@ -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<string, z.ZodType> = Record<string, z.ZodType>,
> {
/** 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<TConfigSchema>) => Promise<void> | void;
/**
* Called after LLM generates a response.
* Use this to parse tool calls, execute tools, etc.
*/
onResponseComplete?: (context: ResponseHandlerContext<TConfigSchema, TLLMToolSchemas>) => Promise<void> | void;
/**
* Called during post-processing phase.
* Use this to transform LLM responses, etc.
*/
onPostProcess?: (context: PostProcessHandlerContext<TConfigSchema>) => Promise<void> | void;
}
/**
* Context passed to prompt processing handlers
*/
export interface ToolHandlerContext<TConfigSchema extends z.ZodType> {
/** The parsed configuration for this tool instance */
config: z.infer<TConfigSchema>;
/** 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<typeof findPromptById>;
/** 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<string, z.ZodType>,
> extends Omit<ToolHandlerContext<TConfigSchema>, 'prompts' | 'config'> {
/** The parsed configuration for this tool instance (may be undefined if no config provided) */
config: z.infer<TConfigSchema> | 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: <TToolName extends keyof TLLMToolSchemas>(
toolName: TToolName,
executor: (parameters: z.infer<TLLMToolSchemas[TToolName]>) => Promise<ToolExecutionResult>,
) => Promise<boolean>;
/** 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<TConfigSchema extends z.ZodType> extends Omit<ToolHandlerContext<TConfigSchema>, 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<string, unknown>;
}
/**
* 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<string, z.ZodType> = Record<string, z.ZodType>,
>(definition: ToolDefinition<TConfigSchema, TLLMToolSchemas>): {
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<string, unknown>)[parameterKey];
if (!rawConfig) {
callback();
return;
}
// Parse and validate config
const config = configSchema.parse(rawConfig) as z.infer<TConfigSchema>;
// Build handler context with utilities
const handlerContext: ToolHandlerContext<TConfigSchema> = {
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<string, unknown>)[parameterKey];
let config: z.infer<TConfigSchema> | undefined;
if (rawConfig) {
try {
config = configSchema.parse(rawConfig) as z.infer<TConfigSchema>;
} catch (parseError) {
logger.warn(`Failed to parse config for ${toolId}`, { parseError });
}
}
// Build handler context
const handlerContext: ResponseHandlerContext<TConfigSchema, TLLMToolSchemas> = {
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 <TToolName extends keyof TLLMToolSchemas>(
toolName: TToolName,
executor: (parameters: z.infer<TLLMToolSchemas[TToolName]>) => Promise<ToolExecutionResult>,
): Promise<boolean> => {
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<string, unknown>,
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 = `<functions_result>
Tool: ${options.toolName}
Parameters: ${JSON.stringify(options.parameters)}
${options.isError ? 'Error' : 'Result'}: ${options.result}
</functions_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<IAgentInstanceService>(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<IAgentInstanceService>(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<string, unknown>)[parameterKey];
if (!rawConfig) {
callback();
return;
}
const config = configSchema.parse(rawConfig) as z.infer<TConfigSchema>;
const handlerContext: PostProcessHandlerContext<TConfigSchema> = {
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<string, ReturnType<typeof defineTool>>();
/**
* Register a tool definition
*/
export function registerToolDefinition<
TConfigSchema extends z.ZodType,
TLLMToolSchemas extends Record<string, z.ZodType>,
>(definition: ToolDefinition<TConfigSchema, TLLMToolSchemas>): ReturnType<typeof defineTool<TConfigSchema, TLLMToolSchemas>> {
const toolDefinition = defineTool(definition);
toolRegistry.set(toolDefinition.toolId, toolDefinition as ReturnType<typeof defineTool>);
return toolDefinition;
}
/**
* Get all registered tool definitions
*/
export function getAllToolDefinitions(): Map<string, ReturnType<typeof defineTool>> {
return toolRegistry;
}
/**
* Get a tool definition by ID
*/
export function getToolDefinition(toolId: string): ReturnType<typeof defineTool> | undefined {
return toolRegistry.get(toolId);
}

View file

@ -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<string, PromptConcatTool>();
export const pluginRegistry = new Map<string, PromptConcatTool>();
/**
* 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<void> {
// 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<void> {
// Import tool schemas and register them
const [
promptToolsModule,
wikiSearchModule,
wikiOperationModule,
workspacesListModule,
modelContextProtocolModule,
] = await Promise.all([
import('./prompt'),
export async function initializePluginSystem(): Promise<void> {
// 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 ?? [],
};
}

View file

@ -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<IAgentInstanceService>(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<IAgentInstanceService>(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<IAgentInstanceService>(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<IAgentInstanceService>(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<IAgentInstanceService>(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<IAgentInstanceService>(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();
}
});
};

View file

@ -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<typeof FullReplacementParameterSchema>;
export type DynamicPositionParameter = z.infer<typeof DynamicPositionParameterSchema>;
/**
* 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<IPrompt['role']>;
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();
});
};

View file

@ -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<typeof WikiOperationParameterSchema>;
/**
* 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<typeof WikiOperationToolSchema>;
/**
* 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<ToolExecutionResult> {
const { workspaceName, operation, title, text, extraMeta, options: optionsString } = parameters;
if (toolConfig.toolId !== 'wikiOperation' || !toolConfig.wikiOperationParam) {
callback();
try {
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const wikiService = container.get<IWikiService>(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<string, unknown>;
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<string, unknown>);
const { workspaceName, operation, title, text, extraMeta, options: optionsString } = validatedParameters;
const options = JSON.parse(optionsString || '{}') as Record<string, unknown>;
// Get workspace service
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const wikiService = container.get<IWikiService>(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 = `<functions_result>\nTool: wiki-operation\nParameters: ${JSON.stringify(validatedParameters)}\nResult: ${result}\n</functions_result>`;
// 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<IAgentInstanceService>(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 = `<functions_result>
Tool: wiki-operation
Error: ${error instanceof Error ? error.message : String(error)}
</functions_result>`;
// 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;

View file

@ -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<typeof WikiSearchParameterSchema>;
/**
* 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<typeof WikiSearchToolParameterSchema>;
type WikiSearchToolParameters = z.infer<typeof WikiSearchToolSchema>;
/**
* 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<typeof WikiUpdateEmbeddingsToolParameterSchema>;
type WikiUpdateEmbeddingsToolParameters = z.infer<typeof WikiUpdateEmbeddingsToolSchema>;
/**
* 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<string, unknown> }> {
try {
const { workspaceName, searchType = 'filter', filter, query, limit = 10, threshold = 0.7 } = parameters;
async function executeWikiSearch(
parameters: WikiSearchToolParameters,
aiConfig?: AiAPIConfig,
): Promise<ToolExecutionResult> {
const { workspaceName, searchType = 'filter', filter, query, limit = 10, threshold = 0.7 } = parameters;
// Get workspace service
try {
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const wikiService = container.get<IWikiService>(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<string, unknown> = {
workspaceID,
workspaceName,
searchType,
};
const results: Array<{ title: string; text?: string; fields?: ITiddlerFields; similarity?: number }> = [];
let searchMetadata: Record<string, unknown> = { 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<IWikiEmbeddingService>(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<string, unknown> }> {
try {
const { workspaceName, forceUpdate = false } = parameters;
async function executeWikiUpdateEmbeddings(
parameters: WikiUpdateEmbeddingsToolParameters,
aiConfig?: AiAPIConfig,
): Promise<ToolExecutionResult> {
const { workspaceName, forceUpdate = false } = parameters;
// Get workspace service
try {
const workspaceService = container.get<IWorkspaceService>(serviceIdentifier.Workspace);
const wikiEmbeddingService = container.get<IWikiEmbeddingService>(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<IWikiEmbeddingService['generateEmbeddings']>[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<IAgentInstanceService>(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<string, unknown> };
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 = `<functions_result>\nTool: ${toolMatch.toolId}\nParameters: ${JSON.stringify(validatedParameters)}\nResult: ${result.data}\n</functions_result>`;
} else {
isError = true;
toolResultText = `<functions_result>\nTool: ${toolMatch.toolId}\nParameters: ${JSON.stringify(validatedParameters)}\nError: ${result.error}\n</functions_result>`;
}
// 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 = `<functions_result>
Tool: wiki-search
Error: ${error instanceof Error ? error.message : String(error)}
</functions_result>`;
// 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;

View file

@ -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<typeof WorkspacesListParameterSchema>;
/**
* 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<IWorkspaceService>(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<IWorkspaceService>(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;

View file

@ -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<string, string> = {
'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<string, string> = {
'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'),