feat(page-ai): add skill context and agent profile policy

This commit is contained in:
lix-2026
2026-05-29 21:57:29 +08:00
parent 631205ba2f
commit 49a0545148
18 changed files with 3478 additions and 259 deletions
+146 -49
View File
@@ -39,7 +39,15 @@ function debugLog(message) {
const toolContextStorage = new AsyncLocalStorage();
function isWriteMnoteTool(toolName) {
return !['mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch'].includes(toolName);
return ![
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.doc.fetch',
'mnote.page.get',
'mnote.block.fetch',
].includes(toolName);
}
function stableIdPart(value, fallback) {
@@ -60,6 +68,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
idempotencyKey,
dryRun,
workspaceId,
documentId,
sourceKind,
rootUri,
profile,
capabilityScope,
...args
} = rawArgs || {};
const effectiveSessionId =
@@ -69,6 +82,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`;
const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`;
const writeTool = isWriteMnoteTool(toolName);
const capabilities = context.mnoteCapabilities || {};
if (!args.contextRefs && capabilities.contextRefs) args.contextRefs = capabilities.contextRefs;
if (!args.agentId && capabilities.agentId) args.agentId = capabilities.agentId;
if (!args.aiAccessScope && capabilities.aiAccessScope) args.aiAccessScope = capabilities.aiAccessScope;
if (!args.allowedRoots && capabilities.allowedRoots) args.allowedRoots = capabilities.allowedRoots;
const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false;
const effectiveIdempotencyKey =
idempotencyKey ||
@@ -76,8 +94,11 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
return {
toolName,
args,
workspaceId: workspaceId || context.workspaceId || 'default',
documentId: args.documentId || context.documentId,
workspaceId: workspaceId || context.workspaceId || capabilities.workspaceId || 'default',
documentId: documentId || args.documentId || context.documentId || capabilities.documentId,
sourceKind: sourceKind || args.sourceKind || context.sourceKind || capabilities.sourceKind,
rootUri: rootUri || args.rootUri || context.rootUri || capabilities.rootUri,
profile: profile || args.profile || context.profile || capabilities.profile,
actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
sessionId: effectiveSessionId,
runId: effectiveRunId,
@@ -85,16 +106,17 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
traceId: effectiveTraceId,
dryRun: effectiveDryRun,
idempotencyKey: effectiveIdempotencyKey,
capabilityScope: capabilityScope || args.capabilityScope || context.capabilityScope || capabilities.capabilityScope,
};
}
if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
const payload = buildMnoteToolPayload(
'mnote.doc.markdown_edit',
'mnote.context.read_current_page',
{
workspaceId: 'ws_demo',
documentId: 'doc_1',
operations: [{ search: '旧', replace: '新' }],
format: 'markdown',
},
{
actorId: 'user_1',
@@ -123,6 +145,44 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) {
throw new Error('selftest expected identity fields outside args');
}
const contextualPayload = buildMnoteToolPayload(
'mnote.context.read_current_page',
{ format: 'markdown' },
{
actorId: 'user_1',
workspaceId: 'ws_local',
documentId: 'local-md:Current.md',
runId: 'run_local_1',
traceId: 'trace_local_1',
mnoteCapabilities: {
agentId: 'reasonix',
contextRefs: [{ kind: 'current_page' }],
sourceKind: 'local_folder',
rootUri: 'file:///tmp/mnote-local',
profile: 'reasonix',
aiAccessScope: {
permissionLevel: 'read_write',
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'write' }],
allowedResourceIds: ['local-md:Current.md'],
},
},
},
);
if (contextualPayload.sourceKind !== 'local_folder') {
throw new Error('selftest expected sourceKind inherited from mnoteCapabilities');
}
if (contextualPayload.rootUri !== 'file:///tmp/mnote-local') {
throw new Error('selftest expected rootUri inherited from mnoteCapabilities');
}
if (contextualPayload.profile !== 'reasonix') {
throw new Error('selftest expected profile inherited from mnoteCapabilities');
}
if (!Array.isArray(contextualPayload.args.contextRefs) || contextualPayload.args.contextRefs[0]?.kind !== 'current_page') {
throw new Error('selftest expected contextRefs inherited from mnoteCapabilities');
}
if (contextualPayload.args.aiAccessScope?.permissionLevel !== 'read_write') {
throw new Error('selftest expected aiAccessScope inherited from mnoteCapabilities');
}
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
@@ -328,15 +388,17 @@ function emitUsage(sessionId, used, size) {
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs
const MNOTE_TOOL_NAMES = [
'mnote.doc.fetch',
'mnote.doc.markdown_edit',
'mnote.block.*',
'mnote.page.*',
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_doc_fetch: 'mnote.doc.fetch',
mnote_doc_markdown_edit: 'mnote.doc.markdown_edit',
mnote_skill_read: 'mnote.skill.read',
mnote_context_snapshot: 'mnote.context.snapshot',
mnote_context_resolve_target: 'mnote.context.resolve_target',
mnote_context_read_current_page: 'mnote.context.read_current_page',
};
async function callMnoteTool(toolName, args) {
@@ -361,10 +423,50 @@ async function callMnoteTool(toolName, args) {
// ── Register Tools ───────────────────────────────────
const tools = new ToolRegistry();
const chatOnlyTools = new ToolRegistry();
tools.register({
name: 'mnote_doc_fetch',
description: '读取当前 mnote 文档的 markdown 内容。返回文档标题和正文。',
name: 'mnote_skill_read',
description: '按需读取 MNote skill 正文。只有任务需要 MNote 能力时才调用。',
parameters: {
type: 'object',
properties: {
skillId: { type: 'string', description: 'MNote skill ID' },
agentId: { type: 'string', description: '当前 agent ID' },
},
required: ['skillId'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_skill_read, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_snapshot',
description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。',
parameters: {
type: 'object',
properties: {
contextRefs: { type: 'array', items: {} },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_snapshot, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_resolve_target',
description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。',
parameters: { type: 'object', properties: {} },
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_resolve_target, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_context_read_current_page',
description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。',
parameters: {
type: 'object',
properties: {
@@ -373,33 +475,7 @@ tools.register({
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_fetch, args),
parallelSafe: false,
});
tools.register({
name: 'mnote_doc_markdown_edit',
description: '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。',
parameters: {
type: 'object',
properties: {
documentId: { type: 'string', description: '文档 ID' },
operations: {
type: 'array',
items: {
type: 'object',
properties: {
search: { type: 'string', description: '要搜索的文本片段' },
replace: { type: 'string', description: '替换后的文本' },
},
required: ['search', 'replace'],
},
description: '搜索替换操作列表',
},
},
required: ['documentId', 'operations'],
},
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_markdown_edit, args),
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_read_current_page, args),
parallelSafe: false,
});
@@ -429,20 +505,36 @@ onRequest('session/new', async (params) => {
const sessionId = randomUUID();
const client = new DeepSeekClient({ apiKey: DEEPSEEK_API_KEY });
const systemPrompt = [
'You are a helpful AI assistant for editing Markdown documents.',
'Use mnote_doc_fetch to read the current document.',
'Use mnote_doc_markdown_edit to apply precise search/replace edits.',
'Always use mnote_doc_fetch first to understand the document content before editing.',
const chatSystemPrompt = [
'You are a helpful AI assistant inside MNote.',
'For ordinary chat, reply directly.',
'Do not read or edit MNote pages, files, folders, or attachments unless MNote capabilities are explicitly attached for this prompt.',
].join('\n');
const loop = new CacheFirstLoop({
const mnoteSystemPrompt = [
'You are a helpful AI assistant inside MNote.',
'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.',
'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.',
'<available-skills>',
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
'</available-skills>',
].join('\n');
const chatLoop = new CacheFirstLoop({
client,
tools: chatOnlyTools,
prefix: new ImmutablePrefix({ system: chatSystemPrompt, toolSpecs: chatOnlyTools.specs() }),
});
const mnoteLoop = new CacheFirstLoop({
client,
tools,
prefix: new ImmutablePrefix({ system: systemPrompt, toolSpecs: tools.specs() }),
prefix: new ImmutablePrefix({ system: mnoteSystemPrompt, toolSpecs: tools.specs() }),
});
sessions.set(sessionId, { id: sessionId, loop, client, aborter: null });
sessions.set(sessionId, { id: sessionId, chatLoop, mnoteLoop, client, aborter: null });
return { sessionId };
});
@@ -478,7 +570,12 @@ onRequest('session/prompt', async (params) => {
traceId: params.traceId || params.mnoteTraceId,
workspaceId: params.workspaceId,
documentId: params.documentId,
mnoteCapabilities: params.mnoteCapabilities || null,
};
const mnoteCapabilities = params.mnoteCapabilities || {};
const useMnoteTools = mnoteCapabilities.attachMnoteCapabilities === true;
const loop = useMnoteTools ? session.mnoteLoop : session.chatLoop;
const promptText = text;
let stopReason = 'end_turn';
let hasAssistantOutput = false;
let hasToolCall = false;
@@ -509,7 +606,7 @@ onRequest('session/prompt', async (params) => {
try {
await toolContextStorage.run(toolContext, async () => {
for await (const ev of session.loop.step(text)) {
for await (const ev of loop.step(promptText)) {
if (session.aborter?.signal.aborted) {
stopReason = 'cancelled';
break;