mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
feat(skill): expose frozen macro rendering to scripts (#209)
This commit is contained in:
parent
e0a1e189c5
commit
bd9d44521b
8 changed files with 237 additions and 11 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -8400,6 +8400,7 @@ dependencies = [
|
|||
"rquickjs",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tt-domain",
|
||||
"tt-ports",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(¯o_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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
/// 内存模块快照:逻辑模块名 → 模块源码(含入口模块)。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue