feat(page-ai): add skill context and agent profile policy
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user