feat(skill): expose frozen macro rendering to scripts (#209)

This commit is contained in:
Darkatse 2026-09-05 01:41:29 -04:00
parent e0a1e189c5
commit bd9d44521b
8 changed files with 237 additions and 11 deletions

1
src-tauri/Cargo.lock generated
View file

@ -8400,6 +8400,7 @@ dependencies = [
"rquickjs",
"serde_json",
"tokio",
"tt-domain",
"tt-ports",
]

View file

@ -963,3 +963,170 @@ async fn agent_runtime_does_not_retry_non_retryable_model_errors() {
let _ = fs::remove_dir_all(root).await;
}
#[tokio::test]
async fn agent_runtime_replays_frozen_macros_before_reading_and_searching() {
use tt_domain::models::chat::ChatMessage;
use tt_domain::models::skill::{
SkillImportInput, SkillInlineFile, SkillInstallRequest, SkillScope,
};
use tt_ports::repositories::skill_repository::SkillRepository;
let root = temp_root("frozen-macros");
let source = "heading\n{{description}}\n{{char}}\n{{greeting::1}} / {{getvar::Name}} / {{getglobalvar::Name}} / {{outlet::Lore}} / {{date}} / {{instructUserPrefix}}";
let fixture = agent_runtime_fixture_with_responses(
&root,
vec![
model_tool_response(vec![
model_tool_call(
"write",
"workspace_write_file",
json!({ "path": "output/main.md", "content": source }),
),
model_tool_call(
"skill_read",
"skill_read",
json!({ "name": "macro-demo", "path": "references/template.md", "start_line": 3, "line_count": 1 }),
),
model_tool_call(
"skill_search",
"skill_search",
json!({ "name": "macro-demo", "query": "lantern" }),
),
model_tool_call(
"chat_read",
"chat_read_messages",
json!({ "messages": [{ "index": 0, "start_line": 3, "line_count": 1 }] }),
),
model_tool_call("chat_search", "chat_search", json!({ "query": "lantern" })),
model_tool_call(
"file_read",
"workspace_read_file",
json!({ "path": "output/main.md" }),
),
model_tool_call(
"file_search",
"workspace_search_files",
json!({ "path": "output", "query": "lantern" }),
),
model_tool_call(
"script",
"skill_run_script",
json!({ "skill": "macro-demo", "script": "helper" }),
),
]),
model_tool_response(vec![model_tool_call(
"finish",
"workspace_finish",
json!({}),
)]),
],
);
FileSkillRepository::new(root.join("_tauritavern/skills"))
.install_import(SkillInstallRequest {
target_scope: SkillScope::Global,
conflict_strategy: None,
input: SkillImportInput::InlineFiles {
source: json!({ "kind": "test" }),
files: [
("SKILL.md", "---\nname: macro-demo\ndescription: Macro test\n---\n{{char}}"),
("references/template.md", source),
("scripts/helper.js", "import { macros, workspace } from '@tauritavern/runtime'; export default () => macros.render(workspace.readText('output/main.md'));"),
].into_iter().map(|(path, content)| SkillInlineFile {
path: path.into(), content: content.into(), encoding: "utf8".into(),
media_type: None, size_bytes: None, sha256: None,
}).collect(),
},
}).await.unwrap();
let mut profile = resolve_contract_profile(&fixture).await;
profile.tools.max_rounds = 2;
profile.tools.max_calls_per_run = 20;
let mut run = contract_run("run_macros", AgentRunPresentation::Background, &profile);
run.chat_ref = AgentChatRef::Character {
character_id: "Alice".into(),
file_name: "macros.jsonl".into(),
};
run.input_message_count = Some(1);
let mut chat = Chat::new("User", "Alice");
chat.file_name = Some("macros.jsonl".into());
chat.add_message(ChatMessage::user("User", source));
fixture.chat_repository.save(&chat).await.unwrap();
fixture.agent_repository.create_run(&run).await.unwrap();
let request = chat_request("read the frozen text");
let prompt = json!({
"chatCompletionPayload": request.payload,
"worldInfoActivation": { "entries": [] },
"frozenRunInputSnapshot": {
"promptInputs": {
"extensionPrompts": { "customWIOutlet_Lore": { "value": "Outlet" } }
},
"macroContext": {
"names": { "char": "Frozen name" },
"character": {
"description": "first line\nblue lantern\nlast line",
"firstMessage": "Main",
"alternateGreetings": ["Alternate"]
},
"variableValues": { "local": { "Name": "Local" }, "global": { "Name": "Global" } },
"builtins": { "date": "2026-09-05", "instructInput": "USER:" }
}
}
});
let (_cancel, mut receiver) = watch::channel(false);
fixture
.service
.execute_agent_loop_run_inner(&run.id, prompt, request, profile, &mut receiver)
.await
.unwrap();
let requests = fixture.model_gateway.requests().await;
let results = requests[1]
.messages
.iter()
.flat_map(|message| &message.parts)
.filter_map(|part| match part {
AgentModelContentPart::ToolResult { result } => Some((result.call_id.as_str(), result)),
_ => None,
})
.collect::<std::collections::HashMap<_, _>>();
for result in results.values() {
assert!(!result.is_error, "{}: {}", result.call_id, result.content);
}
assert!(results["skill_read"].content.contains("3 | blue lantern"));
assert_eq!(results["skill_read"].structured["totalLines"], 6);
assert_eq!(
results["chat_read"].structured["messages"][0]["text"],
"blue lantern"
);
for id in ["skill_search", "chat_search"] {
assert_eq!(
results[id].structured["hits"].as_array().unwrap().len(),
1,
"{id}"
);
}
assert!(
results["file_search"].structured["hits"]
.as_array()
.unwrap()
.is_empty()
);
assert!(results["file_read"].content.contains("{{description}}"));
assert_eq!(results["file_read"].structured["totalLines"], 4);
assert!(results["script"].content.contains("blue lantern"));
assert!(results["script"].content.contains("Frozen name"));
assert!(
results["script"]
.content
.contains("Alternate / Local / Global / Outlet / 2026-09-05 / USER:")
);
assert_eq!(
fixture
.agent_repository
.read_text(&run.id, &WorkspacePath::parse("output/main.md").unwrap())
.await
.unwrap()
.text,
source
);
let _ = fs::remove_dir_all(root).await;
}

View file

@ -12,6 +12,7 @@ serde_json = { workspace = true }
# 只保留 spawn_blocking(rt)与 Semaphore(sync)所需的 feature。
tokio = { version = "1", default-features = false, features = ["rt", "sync"] }
tt-ports = { path = "../tt-ports" }
tt-domain = { path = "../tt-domain" }
[dev-dependencies]
tokio = { version = "1", default-features = false, features = ["macros"] }

View file

@ -181,6 +181,8 @@ fn execute_sync(
// Runtime 状态经 ctx userdata 传入原生模块(模块求值时读取)。
// store_userdata 仅在 userdata 被并发访问时失败,此处不可能。
ctx.store_userdata(RuntimeState {
frozen_macros: request.frozen_macros.clone(),
max_render_bytes: limits.max_total_output_bytes,
overlay: overlay.clone(),
context: request.context.clone(),
})

View file

@ -12,6 +12,7 @@ fn request(source: &str, args: serde_json::Value) -> SkillScriptRequest {
let mut modules = HashMap::new();
modules.insert("scripts/main.js".to_string(), source.to_string());
SkillScriptRequest {
frozen_macros: Default::default(),
entry_module: "scripts/main.js".to_string(),
modules,
args,
@ -409,3 +410,28 @@ async fn concurrent_executions_all_complete() {
handle.await.expect("join").expect("execute");
}
}
#[tokio::test]
async fn native_macro_render_uses_frozen_values_and_enforces_its_output_budget() {
let captured = "abcdefghijklmnopqrst";
let macro_context = json!({ "names": { "char": captured } });
let mut input = request(
r#"
import { context, macros } from '@tauritavern/runtime';
export default () => {
context.macro.names.char = 'changed';
let limited = false;
try { macros.render('{{char}}'.repeat(4)); } catch (error) { limited = /exceeds 64 bytes/.test(error.message); }
return { text: macros.render('{{char}}'), limited };
}
"#,
json!({}),
);
input.context["macro"] = macro_context.clone();
input.frozen_macros = Arc::new(
tt_domain::frozen_macros::FrozenMacros::from_context(&macro_context, None).unwrap(),
);
let engine = QuickJsScriptEngine::new().with_budgets(DEFAULT_MAX_TOTAL_INPUT_BYTES, 64);
let result = engine.execute(input).await.unwrap();
assert_eq!(result.value, json!({ "text": captured, "limited": true }));
}

View file

@ -7,10 +7,12 @@
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use rquickjs::module::{Declarations, Exports, ModuleDef};
use rquickjs::{Ctx, JsLifetime, Result};
use rquickjs::{Ctx, Function, JsLifetime, Object, Result};
use serde_json::Value;
use tt_domain::frozen_macros::FrozenMacros;
use crate::api::{OverlayFs, build_log_object, build_workspace_object};
@ -18,6 +20,8 @@ pub(crate) const RUNTIME_MODULE_NAME: &str = "@tauritavern/runtime";
/// 一次执行的 Runtime 状态,经 ctx userdata 传给原生模块。
pub(crate) struct RuntimeState {
pub(crate) frozen_macros: Arc<FrozenMacros>,
pub(crate) max_render_bytes: usize,
pub(crate) overlay: Rc<RefCell<OverlayFs>>,
pub(crate) context: Value,
}
@ -35,19 +39,26 @@ impl ModuleDef for RuntimeModule {
decl.declare("workspace")?;
decl.declare("log")?;
decl.declare("context")?;
decl.declare("macros")?;
Ok(())
}
fn evaluate<'js>(ctx: &Ctx<'js>, exports: &Exports<'js>) -> Result<()> {
let (overlay, context) = match ctx.userdata::<RuntimeState>() {
Some(state) => (state.overlay.clone(), state.context.clone()),
None => {
return Err(rquickjs::Exception::throw_message(
ctx,
"runtime module evaluated without execution state",
));
}
};
let (overlay, context, frozen_macros, max_render_bytes) =
match ctx.userdata::<RuntimeState>() {
Some(state) => (
state.overlay.clone(),
state.context.clone(),
state.frozen_macros.clone(),
state.max_render_bytes,
),
None => {
return Err(rquickjs::Exception::throw_message(
ctx,
"runtime module evaluated without execution state",
));
}
};
let workspace = build_workspace_object(ctx, overlay.clone())?;
let log = build_log_object(ctx, overlay)?;
@ -56,6 +67,22 @@ impl ModuleDef for RuntimeModule {
exports.export("workspace", workspace)?;
exports.export("log", log)?;
exports.export("context", context)?;
let macros = Object::new(ctx.clone())?;
macros.set(
"render",
Function::new(
ctx.clone(),
move |ctx: Ctx<'_>, text: String| -> Result<String> {
frozen_macros
.render(&text, max_render_bytes)
.map(std::borrow::Cow::into_owned)
.map_err(|error| {
rquickjs::Exception::throw_message(&ctx, &error.to_string())
})
},
)?,
)?;
exports.export("macros", macros)?;
Ok(())
}
}

View file

@ -176,6 +176,7 @@ pub(in crate::services::agent_tools) async fn script(
let outcome = engine
.execute(SkillScriptRequest {
frozen_macros: session.frozen_macros.clone(),
entry_module: entry_module.clone(),
modules,
args: script_args,

View file

@ -31,8 +31,9 @@ pub enum SkillScriptEngineError {
}
/// 一次脚本执行请求。适配器只接触逻辑模块名、源码字符串与纯 JSON 上下文,
/// 不接收任何物理路径或领域模型类型。
/// 冻结宏仅携带内存替换值,不接收任何物理路径或宿主对象。
pub struct SkillScriptRequest {
pub frozen_macros: std::sync::Arc<tt_domain::frozen_macros::FrozenMacros>,
/// 入口模块的逻辑名(如 `scripts/main.js`),必须存在于 `modules` 中。
pub entry_module: String,
/// 内存模块快照:逻辑模块名 → 模块源码(含入口模块)。