Compare commits

...

1 Commits

Author SHA1 Message Date
rdmclin2 e9df5bf9bd feat: support server side agent runtime activeTools 2026-03-23 20:25:03 +08:00
14 changed files with 184 additions and 0 deletions
@@ -127,6 +127,62 @@ describe('buildStepToolDelta', () => {
});
});
describe('activated tools (lobe-tools / lobe-skills)', () => {
it('should activate tools with manifest from allManifestMap', () => {
const delta = buildStepToolDelta({
activatedToolIds: ['web-search'],
allManifestMap: { 'web-search': mockSearchManifest },
operationManifestMap: {},
});
expect(delta.activatedTools).toHaveLength(1);
expect(delta.activatedTools[0]).toEqual({
id: 'web-search',
manifest: mockSearchManifest,
source: 'active_tools',
});
});
it('should skip tools already in operation set', () => {
const delta = buildStepToolDelta({
activatedToolIds: ['web-search'],
allManifestMap: { 'web-search': mockSearchManifest },
operationManifestMap: { 'web-search': mockSearchManifest },
});
expect(delta.activatedTools).toHaveLength(0);
});
it('should skip tools not found in allManifestMap', () => {
const delta = buildStepToolDelta({
activatedToolIds: ['unknown-tool'],
allManifestMap: { 'web-search': mockSearchManifest },
operationManifestMap: {},
});
expect(delta.activatedTools).toHaveLength(0);
});
it('should not activate when allManifestMap is not provided', () => {
const delta = buildStepToolDelta({
activatedToolIds: ['web-search'],
operationManifestMap: {},
});
expect(delta.activatedTools).toHaveLength(0);
});
it('should handle empty activatedToolIds', () => {
const delta = buildStepToolDelta({
activatedToolIds: [],
allManifestMap: { 'web-search': mockSearchManifest },
operationManifestMap: {},
});
expect(delta.activatedTools).toHaveLength(0);
});
});
describe('combined signals', () => {
it('should handle device + mentions + forceFinish together', () => {
const delta = buildStepToolDelta({
@@ -141,6 +197,25 @@ describe('buildStepToolDelta', () => {
expect(delta.deactivatedToolIds).toEqual(['*']);
});
it('should handle device + mentions + activated tools together', () => {
const delta = buildStepToolDelta({
activatedToolIds: ['web-search'],
activeDeviceId: 'device-123',
allManifestMap: { 'web-search': mockSearchManifest },
localSystemManifest: mockLocalSystemManifest,
mentionedToolIds: ['tool-a'],
operationManifestMap: {},
});
// local-system + tool-a (mention) + web-search (active_tools)
expect(delta.activatedTools).toHaveLength(3);
expect(delta.activatedTools.map((t) => t.source)).toEqual([
'device',
'mention',
'active_tools',
]);
});
it('should return empty delta when no signals', () => {
const delta = buildStepToolDelta({
operationManifestMap: {},
@@ -1,10 +1,20 @@
import type { LobeToolManifest, StepToolDelta } from './types';
export interface BuildStepToolDeltaParams {
/**
* Tool IDs activated via lobe-tools / lobe-skills during the conversation.
* These are cumulative — once activated, a tool stays active.
*/
activatedToolIds?: string[];
/**
* Currently active device ID (triggers local-system tool injection)
*/
activeDeviceId?: string;
/**
* Complete manifest map (including disabled tools) for looking up
* manifests of dynamically activated tools.
*/
allManifestMap?: Record<string, LobeToolManifest>;
/**
* Force finish flag — strips all tools for pure text output
*/
@@ -55,6 +65,18 @@ export function buildStepToolDelta(params: BuildStepToolDeltaParams): StepToolDe
}
}
// Tools activated via lobe-tools / lobe-skills
if (params.activatedToolIds?.length && params.allManifestMap) {
for (const id of params.activatedToolIds) {
if (!params.operationManifestMap[id]) {
const manifest = params.allManifestMap[id];
if (manifest) {
delta.activatedTools.push({ id, manifest, source: 'active_tools' });
}
}
}
}
// forceFinish → strip all tools
if (params.forceFinish) {
delta.deactivatedToolIds = ['*'];
@@ -165,6 +165,12 @@ export type ActivationSource = 'active_tools' | 'mention' | 'device' | 'discover
* Operation-level tool set: determined at createOperation time, immutable during execution.
*/
export interface OperationToolSet {
/**
* Complete manifest map including disabled tools.
* Used by buildStepToolDelta to look up manifests for dynamically activated tools
* (via lobe-tools / lobe-skills) that were not in the initial enabled set.
*/
allManifestMap?: Record<string, LobeToolManifest>;
enabledToolIds: string[];
manifestMap: Record<string, LobeToolManifest>;
sourceMap: Record<string, ToolSource>;
@@ -133,7 +133,9 @@ export const createRuntimeExecutors = (
};
const stepDelta = buildStepToolDelta({
activatedToolIds: state.metadata?.activatedToolIds,
activeDeviceId,
allManifestMap: operationToolSet.allManifestMap,
forceFinish: state.forceFinish,
localSystemManifest: LocalSystemManifest as unknown as LobeToolManifest,
operationManifestMap: operationToolSet.manifestMap,
@@ -113,6 +113,7 @@ vi.mock('@/server/modules/Mecha', () => ({
enabledToolIds: [],
filteredTools: [],
}),
getAllPluginManifests: vi.fn().mockReturnValue(new Map()),
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
}),
serverMessagesEngine: vi.fn().mockResolvedValue([]),
@@ -1,6 +1,7 @@
import type { AgentRuntimeContext, AgentState } from '@lobechat/agent-runtime';
import { AgentRuntime, findInMessages, GeneralChatAgent } from '@lobechat/agent-runtime';
import type { ISnapshotStore } from '@lobechat/agent-tracing';
import { LobeToolIdentifier } from '@lobechat/builtin-tool-tools';
import { dynamicInterventionAudits } from '@lobechat/builtin-tools/dynamicInterventionAudits';
import { AgentRuntimeErrorType, ChatErrorType, type ChatMessageError } from '@lobechat/types';
import debug from 'debug';
@@ -577,6 +578,21 @@ export class AgentRuntimeService {
}
}
// Pre-step computation: extract activated tool IDs from DB messages
// Tools activated via lobe-tools / lobe-skills are cumulative and persist across steps
if (currentState.metadata) {
const activatedToolIds = await this.computeActivatedToolIds(currentState);
if (activatedToolIds) {
currentState.metadata.activatedToolIds = activatedToolIds;
log(
'[%s][%d] Pre-step: activated tool IDs from messages: %o',
operationId,
stepIndex,
activatedToolIds,
);
}
}
// Execute step
const startAt = Date.now();
const stepResult = await runtime.step(currentState, currentContext);
@@ -1554,6 +1570,46 @@ export class AgentRuntimeService {
return undefined;
}
/**
* Extract activated tool IDs from DB messages at step boundary.
* Tools activated via lobe-tools are cumulative — once activated, they stay active.
*/
private async computeActivatedToolIds(state: any): Promise<string[] | undefined> {
try {
const dbMessages = await this.messageModel.query({
agentId: state.metadata?.agentId,
threadId: state.metadata?.threadId,
topicId: state.metadata?.topicId,
});
const ids = new Set<string>();
for (const msg of dbMessages) {
if (
msg.role === 'tool' &&
(msg as any).plugin?.identifier === LobeToolIdentifier &&
(msg as any).pluginState?.activatedTools
) {
const activatedTools = (msg as any).pluginState.activatedTools as Array<{
identifier?: string;
}>;
if (Array.isArray(activatedTools)) {
for (const tool of activatedTools) {
if (tool.identifier) {
ids.add(tool.identifier);
}
}
}
}
}
return ids.size > 0 ? [...ids] : undefined;
} catch (error) {
log('computeActivatedToolIds error: %O', error);
}
return undefined;
}
/**
* Handle human intervention logic
*/
@@ -9,6 +9,12 @@ import { type AgentHook } from './hooks/types';
// ==================== Operation Tool Set ====================
export interface OperationToolSet {
/**
* Complete manifest map including disabled tools.
* Used by buildStepToolDelta to look up manifests for dynamically activated tools
* (via lobe-tools / lobe-skills) that were not in the initial enabled set.
*/
allManifestMap?: Record<string, LobeToolManifest>;
enabledToolIds?: string[];
manifestMap: Record<string, LobeToolManifest>;
sourceMap?: Record<string, 'builtin' | 'plugin' | 'mcp' | 'klavis' | 'lobehubSkill'>;
@@ -82,6 +82,7 @@ vi.mock('@/server/services/file', () => ({
vi.mock('@/server/modules/Mecha', () => ({
createServerAgentToolsEngine: vi.fn().mockReturnValue({
generateToolsDetailed: vi.fn().mockReturnValue({ enabledToolIds: [], tools: [] }),
getAllPluginManifests: vi.fn().mockReturnValue(new Map()),
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
}),
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
@@ -8,6 +8,7 @@ const { mockCreateOperation, mockCreateServerAgentToolsEngine, mockMessageCreate
mockCreateOperation: vi.fn(),
mockCreateServerAgentToolsEngine: vi.fn().mockReturnValue({
generateToolsDetailed: vi.fn().mockReturnValue({ enabledToolIds: [], tools: [] }),
getAllPluginManifests: vi.fn().mockReturnValue(new Map()),
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
}),
mockMessageCreate: vi.fn(),
@@ -99,6 +99,7 @@ vi.mock('@/server/modules/Mecha', () => {
mockCreateServerAgentToolsEngine.mockReturnValue({
generateToolsDetailed: mockGenerateToolsDetailed,
getAllPluginManifests: vi.fn().mockReturnValue(new Map()),
getEnabledPluginManifests: mockGetEnabledPluginManifests,
});
@@ -100,6 +100,7 @@ vi.mock('@/server/services/file', () => ({
vi.mock('@/server/modules/Mecha', () => ({
createServerAgentToolsEngine: vi.fn().mockReturnValue({
generateToolsDetailed: vi.fn().mockReturnValue({ enabledToolIds: [], tools: [] }),
getAllPluginManifests: vi.fn().mockReturnValue(new Map()),
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
}),
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
@@ -115,6 +115,7 @@ vi.mock('@/server/services/file', () => ({
vi.mock('@/server/modules/Mecha', () => ({
createServerAgentToolsEngine: vi.fn().mockReturnValue({
generateToolsDetailed: vi.fn().mockReturnValue({ enabledToolIds: [], tools: [] }),
getAllPluginManifests: vi.fn().mockReturnValue(new Map()),
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
}),
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
@@ -96,6 +96,7 @@ vi.mock('@/server/services/klavis', () => ({
vi.mock('@/server/modules/Mecha', () => ({
createServerAgentToolsEngine: vi.fn().mockReturnValue({
generateToolsDetailed: vi.fn().mockReturnValue({ enabledToolIds: [], tools: [] }),
getAllPluginManifests: vi.fn().mockReturnValue(new Map()),
getEnabledPluginManifests: vi.fn().mockReturnValue(new Map()),
}),
serverMessagesEngine: vi.fn().mockResolvedValue([{ content: 'test', role: 'user' }]),
+10
View File
@@ -455,6 +455,15 @@ export class AiAgentService {
toolManifestMap[id] = manifest;
});
// Build complete manifest map (including disabled tools) for step-level dynamic activation.
// When lobe-tools / lobe-skills activate a tool mid-conversation, ToolResolver needs
// the manifest to inject it — even if it was initially disabled by enableChecker.
const allPluginManifests = toolsEngine.getAllPluginManifests();
const allManifestMap: Record<string, any> = {};
allPluginManifests.forEach((manifest, id) => {
allManifestMap[id] = manifest;
});
// Build toolSourceMap for routing tool execution
const toolSourceMap: Record<string, 'builtin' | 'plugin' | 'mcp' | 'klavis' | 'lobehubSkill'> =
{};
@@ -803,6 +812,7 @@ export class AiAgentService {
stepWebhook,
stream,
toolSet: {
allManifestMap,
enabledToolIds: toolsResult.enabledToolIds,
manifestMap: toolManifestMap,
sourceMap: toolSourceMap,