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
@@ -0,0 +1,127 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use serde_json::{json, Value};
pub async fn context_snapshot(
_state: &AppState,
_context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let context_refs = input
.arg_value("contextRefs")
.or_else(|| input.arg_value("context_refs"))
.unwrap_or_else(|| json!([]));
Ok(json!({
"ok": true,
"schema": "mnote.context_snapshot.v1",
"runId": input.run_id,
"sessionId": input.session_id,
"workspace": {
"workspaceId": input.effective_workspace_id(),
"sourceKind": input.effective_source_kind(),
"rootUri": input.effective_root_uri()
},
"contextRefs": context_refs,
"primaryTarget": {
"documentId": input.effective_document_id(),
"fileVersion": input.arg_value("fileVersion").or_else(|| input.arg_value("file_version"))
},
"availableReads": {
"currentPage": context_ref_enabled(input, "current_page"),
"selection": context_ref_enabled(input, "selection"),
"folder": context_ref_enabled(input, "folder"),
"changedFiles": context_ref_enabled(input, "changed_files")
}
}))
}
pub async fn resolve_target(
_state: &AppState,
_context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
Ok(json!({
"ok": true,
"schema": "mnote.context_target.v1",
"workspaceId": input.effective_workspace_id(),
"documentId": input.effective_document_id(),
"sourceKind": input.effective_source_kind(),
"rootUri": input.effective_root_uri(),
"relativePath": input.arg_string("relativePath").or_else(|| input.arg_string("relative_path")),
"fileVersion": input.arg_value("fileVersion").or_else(|| input.arg_value("file_version")),
"contextRefs": input.arg_value("contextRefs").or_else(|| input.arg_value("context_refs")).unwrap_or_else(|| json!([]))
}))
}
pub async fn read_current_page(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
if !context_ref_enabled(input, "current_page") {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_context_current_page_not_allowed",
"本次 run 未授权 current_page 上下文",
)
.with_context(context));
}
doc::doc_fetch(state, context, input).await
}
fn context_ref_enabled(input: &ToolCallInput, expected: &str) -> bool {
input
.arg_value("contextRefs")
.or_else(|| input.arg_value("context_refs"))
.and_then(|value| value.as_array().cloned())
.map(|items| {
items.iter().any(|item| {
item.as_str() == Some(expected)
|| item.get("kind").and_then(Value::as_str).map(str::trim) == Some(expected)
})
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn input_with_refs(context_refs: Value) -> ToolCallInput {
ToolCallInput {
tool_name: "mnote.context.read_current_page".into(),
workspace_id: Some("ws_1".into()),
document_id: Some("doc_1".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote".into()),
actor_id: Some("user_1".into()),
profile: None,
session_id: Some("sess_1".into()),
run_id: Some("run_1".into()),
tool_call_id: Some("tool_1".into()),
trace_id: Some("trace_1".into()),
idempotency_key: None,
dry_run: None,
capability_scope: Some(vec!["context.read".into()]),
args: Some(json!({ "contextRefs": context_refs })),
}
}
#[test]
fn context_ref_enabled_accepts_string_and_object_refs() {
assert!(context_ref_enabled(
&input_with_refs(json!(["current_page"])),
"current_page"
));
assert!(context_ref_enabled(
&input_with_refs(json!([{ "kind": "current_page" }])),
"current_page"
));
assert!(!context_ref_enabled(
&input_with_refs(json!(["selection"])),
"current_page"
));
}
}
@@ -13,6 +13,10 @@ pub fn manifest() -> Value {
"writeOwner": "rust-runtime-kernel"
},
"tools": [
skill_read_tool(),
context_snapshot_tool(),
context_read_current_page_tool(),
context_resolve_target_tool(),
doc_fetch_tool(),
doc_find_tool(),
block_fetch_tool(),
@@ -37,6 +41,95 @@ pub fn manifest() -> Value {
})
}
fn skill_read_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("skillId".into(), json!({ "type": "string" }));
map.insert("agentId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.skill.read",
"description": "按需读取 MNote skill 正文。默认 prompt 只列 skill 摘要,正文必须通过本工具懒加载。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["skill.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["skillId"],
"properties": properties
}
})
}
fn context_snapshot_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert(
"contextRefs".into(),
json!({ "type": "array", "items": { "type": ["string", "object"] } }),
);
}
json!({
"name": "mnote.context.snapshot",
"description": "返回本次 run 可用的 MNote 上下文摘要,不返回页面正文全文。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["context.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"properties": properties
}
})
}
fn context_read_current_page_tool() -> Value {
let mut tool = doc_fetch_tool();
if let Value::Object(map) = &mut tool {
map.insert(
"name".into(),
Value::String("mnote.context.read_current_page".into()),
);
map.insert(
"description".into(),
Value::String("在 current_page contextRef 被授权时读取当前 Markdown 页面。".into()),
);
map.insert(
"capabilityScope".into(),
json!(["context.read", "page.read"]),
);
}
tool
}
fn context_resolve_target_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert(
"contextRefs".into(),
json!({ "type": "array", "items": { "type": ["string", "object"] } }),
);
map.insert("relativePath".into(), json!({ "type": "string" }));
map.insert(
"fileVersion".into(),
json!({ "type": ["string", "object"] }),
);
}
json!({
"name": "mnote.context.resolve_target",
"description": "解析当前 Page AI run 的工作区、文档、rootUri、relativePath 与 file version。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["context.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"properties": properties
}
})
}
fn base_identity_properties() -> Value {
json!({
"workspaceId": { "type": "string" },
@@ -1,9 +1,11 @@
pub mod artifact;
pub mod block;
pub mod context_tools;
pub mod doc;
pub mod manifest;
pub mod page;
pub mod resource;
pub mod skill;
use crate::context::RequestContext;
use crate::error::WebError;
@@ -0,0 +1,149 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use axum::http::StatusCode;
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MnoteSkill {
pub id: &'static str,
pub title: &'static str,
pub description: &'static str,
pub agent_ids: &'static [&'static str],
pub read_only: bool,
pub requires_context_refs: &'static [&'static str],
pub tool_names: &'static [&'static str],
pub content: &'static str,
}
const SKILLS: &[MnoteSkill] = &[
MnoteSkill {
id: "mnote-current-page",
title: "MNote current page",
description: "Read the current MNote Markdown page only when the task needs page content.",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["current_page"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.context.read_current_page",
],
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
},
MnoteSkill {
id: "mnote-local-file",
title: "MNote local file editing",
description: "Read and patch local Markdown files inside MNote allowed roots.",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder"],
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
},
MnoteSkill {
id: "mnote-chat-only",
title: "MNote chat only",
description: "Reply conversationally without reading or writing MNote page/file context.",
agent_ids: &["chat_only", "hermes", "reasonix"],
read_only: true,
requires_context_refs: &[],
tool_names: &[],
content: include_str!("../../../../../skills/mnote-chat-only/SKILL.md"),
},
];
pub fn skill_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
SKILLS
.iter()
.filter(|skill| skill_matches_agent(skill, agent_id))
.map(skill_summary)
.collect()
}
pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> {
let requested = skill_id.trim();
SKILLS
.iter()
.find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id))
}
pub async fn skill_read(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let skill_id = input
.arg_string("skillId")
.or_else(|| input.arg_string("skill_id"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_skill_id_required", "缺少 skillId")
.with_context(context)
})?;
let agent_id = input
.arg_string("agentId")
.or_else(|| input.arg_string("agent_id"));
let skill = find_skill(&skill_id, agent_id.as_deref()).ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"mnote_skill_not_found",
"未知或当前 agent 不可用的 MNote skill",
)
.with_context(context)
})?;
Ok(json!({
"ok": true,
"schema": "mnote.skill.v1",
"skill": skill_summary(skill),
"content": skill.content,
"tools": skill.tool_names,
"constraints": {
"allowedRootsRequired": skill.requires_context_refs.contains(&"folder"),
"mustReadBackAfterWrite": !skill.read_only
}
}))
}
fn skill_matches_agent(skill: &MnoteSkill, agent_id: Option<&str>) -> bool {
let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
};
skill
.agent_ids
.iter()
.any(|candidate| *candidate == agent_id)
}
fn skill_summary(skill: &MnoteSkill) -> Value {
json!({
"id": skill.id,
"title": skill.title,
"description": skill.description,
"agentIds": skill.agent_ids,
"readOnly": skill.read_only,
"requiresContextRefs": skill.requires_context_refs,
"toolNames": skill.tool_names
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_registry_filters_by_agent() {
let chat_skills = skill_summaries_for_agent(Some("chat_only"));
assert!(chat_skills
.iter()
.any(|skill| skill["id"] == "mnote-chat-only"));
assert!(!chat_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-file"));
}
#[test]
fn skill_lookup_rejects_unknown_or_agent_mismatch() {
assert!(find_skill("mnote-current-page", Some("reasonix")).is_some());
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
assert!(find_skill("missing", Some("reasonix")).is_none());
}
}