🐛 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:
Innei
2026-04-24 00:46:34 +08:00
parent b1adaeddbf
commit 01ef7bc142
6 changed files with 313 additions and 19 deletions
@@ -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
*/
+8
View File
@@ -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(