mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
🐛 fix(page-agent): block tool calls when page editor is not mounted
scope is topic-bound not route-bound, so navigating from /agent/.../Page to /agent/... keeps scope==='page' and PageAgentIdentifier stayed in the injected plugin list. The LLM could still call initPage / modifyNodes / etc. against a stale editor reference, returning misleading success (e.g. nodeCount=0). Two layers of guard: - PageAgentExecutor wraps `invoke` and returns a structured PAGE_EDITOR_NOT_MOUNTED / kind: 'replan' result when the runtime editor is not mounted, pointing the LLM at lobe-agent-documents. - streamingExecutor drops PageAgentIdentifier from the tool set via the new `composeEnabledTools` pipeline when scope==='page' and the page-agent runtime is not ready. Also extract the tool-set composition (inject merge + runtime drops) out of the ~320-line internal_createAgentState into `mecha/toolSetComposer`, with unit tests.
This commit is contained in:
@@ -59,6 +59,29 @@ const getRuntimeDebugSnapshot = (runtime: EditorRuntime) => {
|
||||
return candidate.getDebugSnapshot?.();
|
||||
};
|
||||
|
||||
const PAGE_EDITOR_NOT_MOUNTED_MESSAGE =
|
||||
'Page editor is not currently mounted. This topic was started in the page editor, but the editor is not active in the current view. ' +
|
||||
'Do not retry initPage / editTitle / modifyNodes / replaceText / getPageContent here — they require a mounted editor. ' +
|
||||
'To read or modify the topic document, use lobe-agent-documents (readDocument / editDocument / modifyNodes / upsertDocumentByFilename).';
|
||||
|
||||
const buildEditorNotMountedResult = (
|
||||
runtime: EditorRuntime,
|
||||
apiName: string,
|
||||
): BuiltinToolResult => ({
|
||||
content: PAGE_EDITOR_NOT_MOUNTED_MESSAGE,
|
||||
error: {
|
||||
body: {
|
||||
apiName,
|
||||
code: 'PAGE_EDITOR_NOT_MOUNTED',
|
||||
kind: 'replan',
|
||||
runtime: getRuntimeDebugSnapshot(runtime),
|
||||
},
|
||||
message: PAGE_EDITOR_NOT_MOUNTED_MESSAGE,
|
||||
type: 'PageEditorNotMounted',
|
||||
},
|
||||
success: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Page Agent Executor
|
||||
*
|
||||
@@ -81,6 +104,22 @@ class PageAgentExecutor extends BaseExecutor<typeof PageAgentApiName> {
|
||||
constructor(runtime: EditorRuntime) {
|
||||
super();
|
||||
this.runtime = runtime;
|
||||
|
||||
// scope is topic-bound, not route-bound: navigating away from the page
|
||||
// editor keeps scope==='page' on the same topic, so without this guard
|
||||
// the LLM can still call page-agent APIs against a stale editor ref.
|
||||
const baseInvoke = this.invoke;
|
||||
this.invoke = async (apiName, params, ctx) => {
|
||||
if (this.hasApi(apiName) && !this.runtime.isReady()) {
|
||||
console.warn('[PageAgentToolCall] blocked: editor not mounted', {
|
||||
apiName,
|
||||
runtime: getRuntimeDebugSnapshot(this.runtime),
|
||||
});
|
||||
return buildEditorNotMountedResult(this.runtime, apiName);
|
||||
}
|
||||
|
||||
return baseInvoke(apiName, params, ctx);
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Initialize ====================
|
||||
|
||||
@@ -135,6 +135,17 @@ export class EditorRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
if (!this.editor) return false;
|
||||
|
||||
const inspectableEditor = this.editor as InspectableEditor;
|
||||
try {
|
||||
return !!inspectableEditor.getLexicalEditor?.();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current editor instance
|
||||
*/
|
||||
|
||||
@@ -23,3 +23,11 @@ export { resolveModelExtendParams } from './modelParamsResolver';
|
||||
// Memory management
|
||||
export type { TopicMemoryResolverContext } from './memoryManager';
|
||||
export { combineUserMemoryData, resolveTopicMemories, resolveUserPersona } from './memoryManager';
|
||||
|
||||
// Tool set composition
|
||||
export type {
|
||||
ComposedToolSet,
|
||||
ToolSetComposerContext,
|
||||
ToolSetComposerInput,
|
||||
} from './toolSetComposer';
|
||||
export { composeEnabledTools } from './toolSetComposer';
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { PageAgentIdentifier } from '@lobechat/builtin-tool-page-agent';
|
||||
import type { LobeToolManifest, ToolsGenerationResult } from '@lobechat/context-engine';
|
||||
import { generateToolsFromManifest } from '@lobechat/context-engine';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { composeEnabledTools } from './toolSetComposer';
|
||||
|
||||
const makeManifest = (identifier: string, apiName: string): LobeToolManifest => ({
|
||||
api: [
|
||||
{
|
||||
description: `${identifier}.${apiName}`,
|
||||
name: apiName,
|
||||
parameters: { properties: {}, type: 'object' },
|
||||
},
|
||||
],
|
||||
identifier,
|
||||
meta: { avatar: '🔧', description: identifier, title: identifier },
|
||||
systemRole: '',
|
||||
type: 'builtin',
|
||||
});
|
||||
|
||||
const makeToolsDetailed = (manifests: LobeToolManifest[]): ToolsGenerationResult => ({
|
||||
enabledManifests: manifests,
|
||||
enabledToolIds: manifests.map((m) => m.identifier),
|
||||
filteredTools: [],
|
||||
tools: manifests.length > 0 ? manifests.flatMap((m) => generateToolsFromManifest(m)) : undefined,
|
||||
});
|
||||
|
||||
const PAGE_AGENT_MANIFEST = makeManifest(PageAgentIdentifier, 'initPage');
|
||||
const OTHER_MANIFEST = makeManifest('lobe-agent-documents', 'readDocument');
|
||||
|
||||
describe('composeEnabledTools', () => {
|
||||
describe('mergeInjectedManifests', () => {
|
||||
it('returns base unchanged when no injection and no filter triggers', () => {
|
||||
const toolsDetailed = makeToolsDetailed([OTHER_MANIFEST]);
|
||||
|
||||
const result = composeEnabledTools({
|
||||
context: {},
|
||||
toolsDetailed,
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toEqual(['lobe-agent-documents']);
|
||||
expect(result.enabledManifests).toEqual([OTHER_MANIFEST]);
|
||||
expect(result.tools).toEqual(toolsDetailed.tools);
|
||||
});
|
||||
|
||||
it('dedupes injected manifests by identifier', () => {
|
||||
const duplicate = makeManifest('lobe-agent-documents', 'editDocument');
|
||||
|
||||
const result = composeEnabledTools({
|
||||
context: {},
|
||||
injectedManifests: [duplicate],
|
||||
toolsDetailed: makeToolsDetailed([OTHER_MANIFEST]),
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toEqual(['lobe-agent-documents']);
|
||||
expect(result.enabledManifests).toEqual([OTHER_MANIFEST]);
|
||||
expect(result.tools?.some((t) => t.function?.name?.includes('editDocument'))).toBe(false);
|
||||
});
|
||||
|
||||
it('appends new injected manifest and adds its tools', () => {
|
||||
const extra = makeManifest('lobe-calculator', 'calc');
|
||||
|
||||
const result = composeEnabledTools({
|
||||
context: {},
|
||||
injectedManifests: [extra],
|
||||
toolsDetailed: makeToolsDetailed([OTHER_MANIFEST]),
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toEqual(['lobe-agent-documents', 'lobe-calculator']);
|
||||
expect(result.enabledManifests).toEqual([OTHER_MANIFEST, extra]);
|
||||
expect(result.tools?.some((t) => t.function?.name?.startsWith('lobe-calculator____'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('produces a tools array when base has none but injection brings some', () => {
|
||||
const extra = makeManifest('lobe-calculator', 'calc');
|
||||
|
||||
const result = composeEnabledTools({
|
||||
context: {},
|
||||
injectedManifests: [extra],
|
||||
toolsDetailed: makeToolsDetailed([]),
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toEqual(['lobe-calculator']);
|
||||
expect(result.tools).toBeDefined();
|
||||
expect(result.tools).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dropPageAgentIfEditorNotMounted', () => {
|
||||
it('keeps PageAgent when scope is not page', () => {
|
||||
const result = composeEnabledTools({
|
||||
context: { isPageEditorReady: false, scope: undefined },
|
||||
toolsDetailed: makeToolsDetailed([PAGE_AGENT_MANIFEST, OTHER_MANIFEST]),
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toContain(PageAgentIdentifier);
|
||||
});
|
||||
|
||||
it('keeps PageAgent when scope is page and editor is ready', () => {
|
||||
const result = composeEnabledTools({
|
||||
context: { isPageEditorReady: true, scope: 'page' },
|
||||
toolsDetailed: makeToolsDetailed([PAGE_AGENT_MANIFEST, OTHER_MANIFEST]),
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toContain(PageAgentIdentifier);
|
||||
expect(result.enabledManifests).toContainEqual(PAGE_AGENT_MANIFEST);
|
||||
expect(
|
||||
result.tools?.some((t) => t.function?.name?.startsWith(`${PageAgentIdentifier}____`)),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('drops PageAgent from all three outputs when scope is page and editor is not ready', () => {
|
||||
const toolsDetailed = makeToolsDetailed([PAGE_AGENT_MANIFEST, OTHER_MANIFEST]);
|
||||
|
||||
const result = composeEnabledTools({
|
||||
context: { isPageEditorReady: false, scope: 'page' },
|
||||
toolsDetailed,
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toEqual(['lobe-agent-documents']);
|
||||
expect(result.enabledManifests).toEqual([OTHER_MANIFEST]);
|
||||
expect(
|
||||
result.tools?.every((t) => !t.function?.name?.startsWith(`${PageAgentIdentifier}____`)),
|
||||
).toBe(true);
|
||||
expect(result.tools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sets tools to undefined when dropping PageAgent leaves no tools', () => {
|
||||
const result = composeEnabledTools({
|
||||
context: { isPageEditorReady: false, scope: 'page' },
|
||||
toolsDetailed: makeToolsDetailed([PAGE_AGENT_MANIFEST]),
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toEqual([]);
|
||||
expect(result.enabledManifests).toEqual([]);
|
||||
expect(result.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a no-op when scope is page but PageAgent is not enabled', () => {
|
||||
const toolsDetailed = makeToolsDetailed([OTHER_MANIFEST]);
|
||||
|
||||
const result = composeEnabledTools({
|
||||
context: { isPageEditorReady: false, scope: 'page' },
|
||||
toolsDetailed,
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toEqual(['lobe-agent-documents']);
|
||||
expect(result.enabledManifests).toEqual([OTHER_MANIFEST]);
|
||||
expect(result.tools).toEqual(toolsDetailed.tools);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { PageAgentIdentifier } from '@lobechat/builtin-tool-page-agent';
|
||||
import type { LobeToolManifest, ToolsGenerationResult } from '@lobechat/context-engine';
|
||||
import { generateToolsFromManifest } from '@lobechat/context-engine';
|
||||
import debug from 'debug';
|
||||
|
||||
type UniformToolArray = NonNullable<ToolsGenerationResult['tools']>;
|
||||
type UniformTool = UniformToolArray[number];
|
||||
|
||||
const log = debug('lobe-mecha:tool-set-composer');
|
||||
|
||||
export interface ToolSetComposerContext {
|
||||
isPageEditorReady?: boolean;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface ToolSetComposerInput {
|
||||
context: ToolSetComposerContext;
|
||||
injectedManifests?: LobeToolManifest[];
|
||||
toolsDetailed: ToolsGenerationResult;
|
||||
}
|
||||
|
||||
export interface ComposedToolSet {
|
||||
enabledManifests: LobeToolManifest[];
|
||||
enabledToolIds: string[];
|
||||
tools?: UniformTool[];
|
||||
}
|
||||
|
||||
const mergeInjectedManifests = (
|
||||
base: ComposedToolSet,
|
||||
injectedManifests: LobeToolManifest[] | undefined,
|
||||
): ComposedToolSet => {
|
||||
if (!injectedManifests?.length) return base;
|
||||
|
||||
const existingIds = new Set(base.enabledToolIds);
|
||||
const newManifests = injectedManifests.filter((m) => !existingIds.has(m.identifier));
|
||||
if (newManifests.length === 0) return base;
|
||||
|
||||
const newTools = newManifests.flatMap((m) => generateToolsFromManifest(m));
|
||||
const mergedTools = base.tools
|
||||
? [...base.tools, ...newTools]
|
||||
: newTools.length > 0
|
||||
? newTools
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
enabledManifests: [...base.enabledManifests, ...newManifests],
|
||||
enabledToolIds: [...base.enabledToolIds, ...newManifests.map((m) => m.identifier)],
|
||||
tools: mergedTools,
|
||||
};
|
||||
};
|
||||
|
||||
// `scope` is bound to the topic, not the route: navigating away from the page
|
||||
// editor keeps `scope === 'page'` on the same topic. Without this drop the LLM
|
||||
// still sees page-agent tools and can call them against a stale editor ref.
|
||||
const dropPageAgentIfEditorNotMounted = (
|
||||
set: ComposedToolSet,
|
||||
context: ToolSetComposerContext,
|
||||
): ComposedToolSet => {
|
||||
if (context.scope !== 'page') return set;
|
||||
if (!set.enabledToolIds.includes(PageAgentIdentifier)) return set;
|
||||
if (context.isPageEditorReady) return set;
|
||||
|
||||
log('dropping %s: editor not mounted', PageAgentIdentifier);
|
||||
|
||||
const toolNamePrefix = `${PageAgentIdentifier}____`;
|
||||
const nextTools = set.tools?.filter((t) => !t.function?.name?.startsWith(toolNamePrefix));
|
||||
|
||||
return {
|
||||
enabledManifests: set.enabledManifests.filter((m) => m.identifier !== PageAgentIdentifier),
|
||||
enabledToolIds: set.enabledToolIds.filter((id) => id !== PageAgentIdentifier),
|
||||
tools: nextTools && nextTools.length > 0 ? nextTools : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const composeEnabledTools = ({
|
||||
toolsDetailed,
|
||||
injectedManifests,
|
||||
context,
|
||||
}: ToolSetComposerInput): ComposedToolSet => {
|
||||
const initial: ComposedToolSet = {
|
||||
enabledManifests: toolsDetailed.enabledManifests,
|
||||
enabledToolIds: toolsDetailed.enabledToolIds,
|
||||
tools: toolsDetailed.tools,
|
||||
};
|
||||
|
||||
return dropPageAgentIfEditorNotMounted(
|
||||
mergeInjectedManifests(initial, injectedManifests),
|
||||
context,
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import { createPathScopeAudit } from '@lobechat/builtin-tool-local-system';
|
||||
import { PageAgentIdentifier } from '@lobechat/builtin-tool-page-agent';
|
||||
import { manualModeExcludeToolIds } from '@lobechat/builtin-tools';
|
||||
import { isDesktop } from '@lobechat/const';
|
||||
import { generateToolsFromManifest, type ToolsEngine } from '@lobechat/context-engine';
|
||||
import { type ToolsEngine } from '@lobechat/context-engine';
|
||||
import { buildTaskDetailPrompt, buildTaskListPrompt } from '@lobechat/prompts';
|
||||
import {
|
||||
type ConversationContext,
|
||||
@@ -22,7 +22,7 @@ import { t } from 'i18next';
|
||||
|
||||
import { createAgentToolsEngine } from '@/helpers/toolEngineering';
|
||||
import { type ResolvedAgentConfig } from '@/services/chat/mecha';
|
||||
import { resolveAgentConfig } from '@/services/chat/mecha';
|
||||
import { composeEnabledTools, resolveAgentConfig } from '@/services/chat/mecha';
|
||||
import { localFileService } from '@/services/electron/localFileService';
|
||||
import { messageService } from '@/services/message';
|
||||
import { getAgentStoreState } from '@/store/agent';
|
||||
@@ -200,23 +200,14 @@ export class StreamingExecutorActionImpl {
|
||||
toolIds: mergedToolIds,
|
||||
});
|
||||
|
||||
// --- Merge injected manifests (generic, caller-driven) ---
|
||||
const injectedManifests = initialContext?.initialContext?.injectedManifests;
|
||||
const existingIdSet = new Set(toolsDetailed.enabledToolIds);
|
||||
// Skip manifests whose identifier is already enabled (dedup)
|
||||
const newInjected = injectedManifests?.filter((m) => !existingIdSet.has(m.identifier)) ?? [];
|
||||
|
||||
const enabledToolIds = [
|
||||
...toolsDetailed.enabledToolIds,
|
||||
...newInjected.map((m) => m.identifier),
|
||||
];
|
||||
const enabledManifests = [...toolsDetailed.enabledManifests, ...newInjected];
|
||||
const injectedTools = newInjected.flatMap((m) => generateToolsFromManifest(m));
|
||||
const tools = toolsDetailed.tools
|
||||
? [...toolsDetailed.tools, ...injectedTools]
|
||||
: injectedTools.length > 0
|
||||
? injectedTools
|
||||
: undefined;
|
||||
const { enabledToolIds, enabledManifests, tools } = composeEnabledTools({
|
||||
context: {
|
||||
isPageEditorReady: pageAgentRuntime.isReady(),
|
||||
scope,
|
||||
},
|
||||
injectedManifests: initialContext?.initialContext?.injectedManifests,
|
||||
toolsDetailed,
|
||||
});
|
||||
|
||||
// Use enabledManifests directly to avoid getEnabledPluginManifests adding default tools again
|
||||
const toolManifestMap = Object.fromEntries(
|
||||
|
||||
Reference in New Issue
Block a user