From 0399606b51245b53a6e611288a6bcee73bce0c3d Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:24:42 +0800 Subject: [PATCH 1/4] Plugin helpers: a global `tw`, in-place edits that count, per-block streaming Plugins get a built-in helper object and no longer have to return the object they edited. - `tw` (crates/tw-plugin/src/helpers.js) is plain JavaScript evaluated together with the bridge at build time and baked into the snapshot: appendSystem, texts, toolCalls, mapStrings, replace, streamReplace, lines and pairs. It captures every builtin it uses before plugin code runs, throws English errors that name a missing permission, and the host trusts its output no more than hand-written plugin output. `tw` is added to the sandbox's global allow-list. - Replacement rules: one left-to-right scan, the first listed rule wins at a position, replaced text is never rescanned, string finds are literal, regular expressions behave like String.prototype.replace, and secret placeholders are never matched or split. - tw.streamReplace holds back only what a later piece could still change and buffers each text block separately. The host now passes the block number to the sandbox on every onReplyText / onReplyTextEnd call (guest ABI 2); plugin code cannot see it. - onRequest and onToolCall: when the hook returns undefined, the bridge serializes the object it was given; if that differs from the input it is handed to the host exactly as if the hook had returned it. Otherwise the result stays "unchanged" without involving the host. - The two default plugins use the helpers. Their behaviour is unchanged; manifests.json is regenerated. Tests cover every helper (normal use, misuse, missing permissions, tampered builtins), a randomized split property test that runs inside the sandbox (whole-text replace equals a naive reference, and any split streamed through streamReplace equals the whole-text result without cutting a placeholder or a surrogate pair), interleaved blocks, the in-place rules for both hooks, and end-to-end runs through the gateway. The latency example gains a "read a view, no return" case. Co-Authored-By: Claude Opus 5.5 --- .../src/plugin/defaults/manifests.json | 4 +- .../src/plugin/defaults/reply-language.js | 8 +- .../src/plugin/defaults/wsl-paths.js | 39 +- crates/tw-gateway/src/plugin/host.rs | 15 +- crates/tw-gateway/src/plugin/reply/mod.rs | 6 +- .../tw-gateway/src/plugin/reply/tests/mod.rs | 63 + crates/tw-gateway/src/plugin/sandbox.rs | 9 +- .../src/plugin/sandbox/tests/mod.rs | 46 +- crates/tw-gateway/tests/plugins_helpers.rs | 180 +++ crates/tw-plugin/build.rs | 25 +- crates/tw-plugin/examples/latency.rs | 22 +- crates/tw-plugin/guest/src/lib.rs | 20 +- crates/tw-plugin/src/bridge.js | 73 +- crates/tw-plugin/src/helpers.js | 766 ++++++++++++ crates/tw-plugin/src/lib.rs | 38 +- crates/tw-plugin/src/sandbox.rs | 12 +- crates/tw-plugin/tests/common/mod.rs | 4 +- crates/tw-plugin/tests/corpus/globals.js | 2 +- .../tw-plugin/tests/corpus/helpers-split.js | 212 ++++ crates/tw-plugin/tests/helpers.rs | 1049 +++++++++++++++++ crates/tw-plugin/tests/in_place.rs | 274 +++++ crates/tw-plugin/tests/isolation.rs | 11 +- crates/tw-plugin/tests/runtime.rs | 44 +- 23 files changed, 2793 insertions(+), 129 deletions(-) create mode 100644 crates/tw-gateway/tests/plugins_helpers.rs create mode 100644 crates/tw-plugin/src/helpers.js create mode 100644 crates/tw-plugin/tests/corpus/helpers-split.js create mode 100644 crates/tw-plugin/tests/helpers.rs create mode 100644 crates/tw-plugin/tests/in_place.rs diff --git a/crates/tw-gateway/src/plugin/defaults/manifests.json b/crates/tw-gateway/src/plugin/defaults/manifests.json index 306161cb..7ad66e88 100644 --- a/crates/tw-gateway/src/plugin/defaults/manifests.json +++ b/crates/tw-gateway/src/plugin/defaults/manifests.json @@ -32,7 +32,7 @@ } ] }, - "sha256": "f34e95b9db72c98eadac320f7c3f7af9acb8beae9fa3acd8eda1d0459e18bb6e" + "sha256": "30ca8624472ebdbeb6793b19226402ff3934ff832fb690e2ed1b513496e49322" }, "wsl-paths": { "manifest": { @@ -68,6 +68,6 @@ } ] }, - "sha256": "6aaf3646438f024a6482413eca7fe9f17230d3f5177458476d070485cb333a99" + "sha256": "06adbac883676e7587394437c4f483e2020449515a62fb2db69f682495b303df" } } diff --git a/crates/tw-gateway/src/plugin/defaults/reply-language.js b/crates/tw-gateway/src/plugin/defaults/reply-language.js index 03489878..63d18ee1 100644 --- a/crates/tw-gateway/src/plugin/defaults/reply-language.js +++ b/crates/tw-gateway/src/plugin/defaults/reply-language.js @@ -9,6 +9,9 @@ // 权限:system,只读写系统提示词。 // 设置:回答语言,装上时是简体中文。出错时(比如语言名写得不对)拒绝这个请求。 // +// 附加用的是辅助函数 tw.appendSystem:和原文之间空一行,原文已经有这句就不再附加;改的是 +// 传入的 req,不用返回。 +// // manifest 是纯数据,写成 core 改写它时的样子:界面改设置时只换这一段,改出来的和原来 // 只差改了的那一行。 // @@ -36,8 +39,5 @@ export function onRequest(req, ctx) { "The answer language setting accepts only the name of a language, such as English or Deutsch.", ); } - const line = `Always respond in ${language}, unless the user explicitly asks for another language.`; - if (req.system.includes(line)) return undefined; - req.system = req.system ? `${req.system}\n\n${line}` : line; - return req; + tw.appendSystem(req, `Always respond in ${language}, unless the user explicitly asks for another language.`); } diff --git a/crates/tw-gateway/src/plugin/defaults/wsl-paths.js b/crates/tw-gateway/src/plugin/defaults/wsl-paths.js index d6c76e37..5e9285bd 100644 --- a/crates/tw-gateway/src/plugin/defaults/wsl-paths.js +++ b/crates/tw-gateway/src/plugin/defaults/wsl-paths.js @@ -13,6 +13,10 @@ // // 设置只有一个开关:客户端在哪一侧。设置改不出任何别的改写。 // +// 遍历和复制交给辅助函数:tw.toolCalls 逐个交出对话历史里的工具调用,tw.mapStrings 复制 +// 参数、把其中每个字符串换一遍(参数里有 __proto__ 这样的键也照常复制)。改的是传入的 +// 对象,不用返回;什么都没换时请求原样发出,一个字节都不动。 +// // 权限:messages(对话历史),reply.tool_calls(回答里的工具调用)。reply.tool_calls 是 // 高风险权限:插件能改动模型要执行的操作;改过的工具调用照样经过 Lite 的工具调用审查。 // 设置:客户端运行在 Windows 上(关闭时按客户端在 WSL 里处理)。出错时拒绝这个请求。 @@ -52,39 +56,14 @@ function convert(value, windows) { return m ? `/mnt/${m[1].toLowerCase()}${(m[2] ?? "/").replaceAll("\\", "/")}` : value; } -function rewrite(value, windows) { - if (typeof value === "string") return convert(value, windows); - if (Array.isArray(value)) return value.map((item) => rewrite(item, windows)); - if (value !== null && typeof value === "object") { - // fromEntries 按原样建出每个键,参数里有 __proto__ 这样的键也不会出错 - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, rewrite(item, windows)]), - ); - } - return value; -} - -const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); - export function onRequest(req, ctx) { const windows = ctx.settings.windows_client === true; - let changed = false; - for (const m of req.messages) { - for (const p of m.parts) { - if (p.type !== "tool_call") continue; - const input = rewrite(p.input, windows); - if (!same(input, p.input)) { - p.input = input; - changed = true; - } - } - } - // 没改就不返回:请求原样发出,一个字节都不动 - return changed ? req : undefined; + tw.toolCalls(req, (call) => { + call.input = tw.mapStrings(call.input, (s) => convert(s, windows)); + }); } export function onToolCall(call, ctx) { - const input = rewrite(call.input, ctx.settings.windows_client === true); - if (same(input, call.input)) return undefined; - return { id: call.id, name: call.name, input }; + const windows = ctx.settings.windows_client === true; + call.input = tw.mapStrings(call.input, (s) => convert(s, windows)); } diff --git a/crates/tw-gateway/src/plugin/host.rs b/crates/tw-gateway/src/plugin/host.rs index fd750266..12bbd641 100644 --- a/crates/tw-gateway/src/plugin/host.rs +++ b/crates/tw-gateway/src/plugin/host.rs @@ -10,6 +10,7 @@ use std::time::Duration; use serde_json::Value; use crate::plugin::engine::Manifest; +use crate::plugin::reply::Lane; use crate::plugin::set::LogLine; pub trait PluginHost: Send + Sync { @@ -43,11 +44,14 @@ pub trait PluginHost: Send + Sync { } /// 一次回答的插件实例。**只给这一次回答用**。 +/// +/// `lane` 是这块文字在这次回答里的编号([`Lane`]):同时进行的几块文字分开缓冲。它只交给 +/// 沙箱里的 `tw.streamReplace`,插件代码看不到 pub trait ReplyHost: Send { /// `None` 是没改 - fn on_text(&mut self, text: &str) -> Invocation>; + fn on_text(&mut self, lane: Lane, text: &str) -> Invocation>; /// 流式一块文字结束。`None` 是什么都不补 - fn on_text_end(&mut self) -> Invocation>; + fn on_text_end(&mut self, lane: Lane) -> Invocation>; fn on_tool_call(&mut self, call: Value) -> Invocation; } @@ -302,7 +306,8 @@ pub mod double { type EndFn = dyn FnMut() -> Invocation> + Send; type ToolFn = dyn FnMut(Value) -> Invocation + Send; - /// 一个回答实例的替身:三个闭包,可以带状态(流式扣住文字的插件就要)。 + /// 一个回答实例的替身:三个闭包,可以带状态(流式扣住文字的插件就要)。块编号不交给 + /// 闭包:插件代码本来就看不到它 pub struct Closures { pub text: Box, pub end: Box, @@ -310,11 +315,11 @@ pub mod double { } impl ReplyHost for Closures { - fn on_text(&mut self, text: &str) -> Invocation> { + fn on_text(&mut self, _lane: Lane, text: &str) -> Invocation> { (self.text)(text) } - fn on_text_end(&mut self) -> Invocation> { + fn on_text_end(&mut self, _lane: Lane) -> Invocation> { (self.end)() } diff --git a/crates/tw-gateway/src/plugin/reply/mod.rs b/crates/tw-gateway/src/plugin/reply/mod.rs index c34d2af8..518890a1 100644 --- a/crates/tw-gateway/src/plugin/reply/mod.rs +++ b/crates/tw-gateway/src/plugin/reply/mod.rs @@ -445,7 +445,7 @@ impl Chain { .lanes .get(&lane) .is_some_and(|l| !l.unrevealed.is_empty()); - match self.invoke(i, move |r| r.on_text(&given)).await { + match self.invoke(i, move |r| r.on_text(lane, &given)).await { Ok(None) if !carrying => { self.stages[i] .lanes @@ -551,7 +551,7 @@ impl Chain { let hidden = self.bridge.hide(&whole); let given = hidden.clone(); self.stages[i].counts.text_calls += 1; - match self.invoke(i, move |r| r.on_text(&given)).await { + match self.invoke(i, move |r| r.on_text(lane, &given)).await { Ok(None) => out.push(whole), Ok(Some(s)) => { if s != hidden { @@ -581,7 +581,7 @@ impl Chain { } let mut last = String::new(); if self.live(i) && self.stages[i].text_end { - match self.invoke(i, |r| r.on_text_end()).await { + match self.invoke(i, move |r| r.on_text_end(lane)).await { Ok(None) => {} Ok(Some(s)) => { if !s.is_empty() { diff --git a/crates/tw-gateway/src/plugin/reply/tests/mod.rs b/crates/tw-gateway/src/plugin/reply/tests/mod.rs index 4db70cad..18a5497a 100644 --- a/crates/tw-gateway/src/plugin/reply/tests/mod.rs +++ b/crates/tw-gateway/src/plugin/reply/tests/mod.rs @@ -715,6 +715,69 @@ async fn whole_bodies_restore_a_placeholder_a_stream_plugin_splits() { } } +/// 记下每次调用拿到的块编号,文字原样交回 +struct Lanes(Arc>>); + +impl ReplyHost for Lanes { + fn on_text(&mut self, lane: Lane, text: &str) -> Invocation> { + self.0.lock().unwrap().push(format!("{lane}:{text}")); + Invocation::ok(None) + } + + fn on_text_end(&mut self, lane: Lane) -> Invocation> { + self.0.lock().unwrap().push(format!("{lane}:end")); + Invocation::ok(None) + } + + fn on_tool_call(&mut self, _: Value) -> Invocation { + Invocation::ok(ToolCallOutcome::Unchanged) + } +} + +/// 两块文字同时开着、增量交替到达:每次调用都带着它那一块的编号(`tw.streamReplace` +/// 靠它分开缓冲) +#[tokio::test] +async fn every_text_call_carries_the_number_of_its_block() { + let input = [ + ev("message_start", json!({"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[],"usage":{"input_tokens":3,"output_tokens":1}}})), + ev("content_block_start", json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}})), + ev("content_block_start", json!({"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}})), + ev("content_block_delta", json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"a1"}})), + ev("content_block_delta", json!({"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"b1"}})), + ev("content_block_delta", json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"a2"}})), + ev("content_block_delta", json!({"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"b2"}})), + ev("content_block_stop", json!({"type":"content_block_stop","index":0})), + ev("content_block_stop", json!({"type":"content_block_stop","index":1})), + ev("message_delta", json!({"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":9}})), + ev("message_stop", json!({"type":"message_stop"})), + ] + .concat(); + for (mode, want) in [ + ( + ReplyMode::Stream, + vec!["0:a1", "1:b1", "0:a2", "1:b2", "0:end", "1:end"], + ), + // 整块模式:块结束时一次交一整块,也带着编号 + (ReplyMode::Block, vec!["0:a1a2", "1:b1b2"]), + ] { + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + let rec = Double::new("lanes") + .permit(&[Permission::ReplyText]) + .mode(mode) + .on_reply(true, mode == ReplyMode::Stream, false, move |_| { + Ok(Box::new(Lanes(log.clone()))) + }); + let mut s = Stream::new( + chain_of(Dialect::Anthropic, vec![rec], &json!({})).await, + Framing::Sse, + ); + let (_, err) = run(&mut s, &input, 4096).await; + assert!(err.is_none()); + assert_eq!(*seen.lock().unwrap(), want, "{mode:?}"); + } +} + // ───────────────────────────────────────────────────────── Chat fn chat_stream() -> String { diff --git a/crates/tw-gateway/src/plugin/sandbox.rs b/crates/tw-gateway/src/plugin/sandbox.rs index b5db5f49..d94f629d 100644 --- a/crates/tw-gateway/src/plugin/sandbox.rs +++ b/crates/tw-gateway/src/plugin/sandbox.rs @@ -17,6 +17,7 @@ use crate::plugin::engine::{Engine, Hooks, LoadError, Manifest, SettingSpec}; use crate::plugin::host::{ Invocation, PluginHost, ReplyHost, RequestOutcome, RunError, ToolCallOutcome, }; +use crate::plugin::reply::Lane; use crate::plugin::set::{LogLine, Scope}; /// 进程里那一份运行时。起不来时是起不来的原因 @@ -92,12 +93,12 @@ impl PluginHost for Host { struct Reply(tw_plugin::Reply); impl ReplyHost for Reply { - fn on_text(&mut self, text: &str) -> Invocation> { - invocation(self.0.on_text(text), |t| t) + fn on_text(&mut self, lane: Lane, text: &str) -> Invocation> { + invocation(self.0.on_text(lane, text), |t| t) } - fn on_text_end(&mut self) -> Invocation> { - invocation(self.0.on_text_end(), |t| t) + fn on_text_end(&mut self, lane: Lane) -> Invocation> { + invocation(self.0.on_text_end(lane), |t| t) } fn on_tool_call(&mut self, call: Value) -> Invocation { diff --git a/crates/tw-gateway/src/plugin/sandbox/tests/mod.rs b/crates/tw-gateway/src/plugin/sandbox/tests/mod.rs index f0179a4e..5d0249d7 100644 --- a/crates/tw-gateway/src/plugin/sandbox/tests/mod.rs +++ b/crates/tw-gateway/src/plugin/sandbox/tests/mod.rs @@ -185,9 +185,9 @@ fn a_rejection_and_a_throw_keep_their_meaning() { fn a_reply_instance_keeps_its_state_for_one_answer() { let h = load(BOTH); let mut r = h.reply(ctx()).unwrap(); - assert_eq!(r.on_text("hel").result, Ok(Some(String::new()))); - assert_eq!(r.on_text("lo").result, Ok(Some(String::new()))); - assert_eq!(r.on_text_end().result, Ok(Some("HELLO".into()))); + assert_eq!(r.on_text(0, "hel").result, Ok(Some(String::new()))); + assert_eq!(r.on_text(0, "lo").result, Ok(Some(String::new()))); + assert_eq!(r.on_text_end(0).result, Ok(Some("HELLO".into()))); // 工具调用:丢掉一个、改一个 assert_eq!( r.on_tool_call(json!({"id": "t1", "name": "Bash", "input": {"command": "ls"}})) @@ -205,7 +205,45 @@ fn a_reply_instance_keeps_its_state_for_one_answer() { // 另一个回答是另一个实例:上一个攒着的不会漏过来 let mut fresh = h.reply(ctx()).unwrap(); - assert_eq!(fresh.on_text_end().result, Ok(Some(String::new()))); + assert_eq!(fresh.on_text_end(0).result, Ok(Some(String::new()))); +} + +/// 块编号一路交到沙箱里:两块文字交替送段,`tw.streamReplace` 各扣各的 +#[test] +fn the_lane_reaches_the_sandbox() { + let h = load( + r#"export const manifest = { name: "spelling", api: 1, permissions: ["reply.text"], reply: "stream" }; + export const { onReplyText, onReplyTextEnd } = tw.streamReplace([["colour", "color"]]);"#, + ); + let mut r = h.reply(ctx()).unwrap(); + let mut text = |lane: u64, t: &str| match r.on_text(lane, t).result { + Ok(Some(s)) => s, + Ok(None) => t.to_string(), + Err(e) => panic!("{e:?}"), + }; + let mut a = text(0, "the col"); + let mut b = text(1, "a col"); + a += &text(0, "our"); + b += &text(1, "d day"); + a += &r.on_text_end(0).result.unwrap().unwrap_or_default(); + b += &r.on_text_end(1).result.unwrap().unwrap_or_default(); + assert_eq!(a, "the color"); + assert_eq!(b, "a cold day"); +} + +/// 修改传入的 req 即生效,不用返回 +#[test] +fn an_edited_request_counts_without_a_return() { + let h = load( + r#"export const manifest = { name: "t", api: 1, permissions: ["system"] }; + export function onRequest(req) { tw.appendSystem(req, "Answer in English."); }"#, + ); + match h.on_request(view(), ctx()).result { + Ok(RequestOutcome::Changed(v)) => { + assert_eq!(v["system"], "Be brief.\n\nAnswer in English.") + } + other => panic!("{other:?}"), + } } #[test] diff --git a/crates/tw-gateway/tests/plugins_helpers.rs b/crates/tw-gateway/tests/plugins_helpers.rs new file mode 100644 index 00000000..19aa99b5 --- /dev/null +++ b/crates/tw-gateway/tests/plugins_helpers.rs @@ -0,0 +1,180 @@ +//! 插件辅助函数 `tw`,端到端:真的插件跑在真的沙箱里,请求从客户端到假上游走一整圈。 +//! +//! - 修改传入的 `req` 即生效:不返回,上游收到的也是改过的那一份;什么都没改就记成没改。 +//! - 逐段替换(`tw.streamReplace`):假上游一个字一帧,客户端拿到的和整包时一样。 +//! - 回答里的密钥:插件看到的是占位符,替换规则碰不到它、也不会把它拆开,客户端照样拿回 +//! 原值。 + +mod plugin_harness; + +use plugin_harness::*; +use serde_json::{Value, json}; +use tw_config::{RedactPolicy, Security, SecurityMode}; + +/// 用户粘进对话里的那把 key(出站脱敏的 anthropic-api-key 规则认得它) +const USER_KEY: &str = "sk-ant-api03-USERSOWNKEYAAAAAAAAAAAAAA"; + +fn body(text: &str, stream: bool) -> Value { + json!({ + "model": "claude-sonnet-4-5", "max_tokens": 64, "stream": stream, + "system": "你是助手。", + "messages": [{ "role": "user", "content": text }] + }) +} + +fn text_of(r: &Resp, stream: bool) -> String { + if stream { + sse_text(&r.body) + } else { + json_text(&r.body) + } +} + +const SPELLING: &str = r#" +export const manifest = { + name: "American spelling", + api: 1, + permissions: ["system", "messages"], +}; + +export function onRequest(req) { + tw.appendSystem(req, "Use American spelling."); + tw.texts(req, (t, place) => (place.kind === "text" ? tw.replace(t, [["colour", "color"]]) : undefined)); +} +"#; + +#[tokio::test] +async fn request_edits_count_without_a_return() { + let up = Upstream::start(vec![Answer::Text("ok".into())]).await; + let gw = Gateway::start( + config(&up, Security::default()), + vec![Plug::new("spelling", SPELLING)], + ) + .await; + let r = gw.ask(body("what colour is it", false)).await; + assert_eq!(r.status, 200, "{}", r.body); + let sent = up.body(0); + assert_eq!(sent["system"], "你是助手。\n\nUse American spelling."); + assert_eq!(sent["messages"][0]["content"], "what color is it"); + assert_eq!(gw.outcomes("spelling"), ["changed"]); +} + +const LOOK: &str = r#" +export const manifest = { name: "look", api: 1, permissions: ["system", "messages"] }; + +export function onRequest(req) { + let n = 0; + tw.texts(req, (t) => { n += t.length; }); + tw.toolCalls(req, () => {}); + console.log(String(n)); +} +"#; + +#[tokio::test] +async fn a_request_hook_that_only_reads_changes_nothing() { + let up = Upstream::start(vec![Answer::Text("ok".into())]).await; + let gw = Gateway::start( + config(&up, Security::default()), + vec![Plug::new("look", LOOK)], + ) + .await; + let raw = r#"{"model":"claude-sonnet-4-5","max_tokens":64,"temperature":1.0,"system":"你是助手。","messages":[{"role":"user","content":"hi"}]}"#; + let r = gw.post_raw("/v1/messages", raw).await; + assert_eq!(r.status, 200, "{}", r.body); + // 一个字节都不动(连 1.0 的写法都在) + assert_eq!(up.raw(0), raw); + assert_eq!(gw.outcomes("look"), ["unchanged"]); +} + +const TERMS: &str = r#" +export const manifest = { + name: "Terms", + api: 1, + permissions: ["reply.text"], + reply: "stream", + settings: { + terms: { type: "string", label: "Terms", value: "" }, + }, +}; + +export const { onReplyText, onReplyTextEnd } = tw.streamReplace((ctx) => [ + ...tw.pairs(ctx.settings.terms), + [/\d{3,}/, '#'], +]); +"#; + +#[tokio::test] +async fn stream_replace_gives_the_same_answer_one_character_at_a_time() { + for stream in [false, true] { + let up = Upstream::start(vec![Answer::Text( + "my favourite colour is 12345, the colour of 7 seas".into(), + )]) + .await; + let gw = Gateway::start( + config(&up, Security::default()), + vec![ + Plug::new("terms", TERMS) + .settings(json!({ "terms": "colour => color\nfavourite => favorite" })), + ], + ) + .await; + let r = gw.ask(body("hi", stream)).await; + assert_eq!(r.status, 200, "{stream}: {}", r.body); + assert_eq!( + text_of(&r, stream), + "my favorite color is #, the color of 7 seas", + "{stream}" + ); + } +} + +const SAFE: &str = r#" +export const manifest = { name: "safe", api: 1, permissions: ["reply.text"], reply: "stream" }; + +// 每一条都能咬住占位符里的字:占位符整个跳过,它们一个也咬不到 +export const { onReplyText, onReplyTextEnd } = tw.streamReplace([ + ["SECRET", "x"], + ["<<", "«"], + ["TW_", "tw"], + [/\d+>>/, "?"], + ["key", "KEY"], +]); +"#; + +#[tokio::test] +async fn stream_replace_leaves_secrets_whole_and_the_client_gets_its_key_back() { + for mode in [SecurityMode::Enforce, SecurityMode::Observe] { + for stream in [false, true] { + // 上游把它看到的那串原样说一遍:拦截档下是占位符,观察档下是真 key + let up = Upstream::start(vec![Answer::Echo]).await; + let gw = Gateway::start( + config( + &up, + Security { + redact: RedactPolicy { + mode, + ..Default::default() + }, + ..Default::default() + }, + ), + vec![Plug::new("safe", SAFE)], + ) + .await; + let r = gw + .ask(body(&format!("my key is {USER_KEY} << ok"), stream)) + .await; + assert_eq!(r.status, 200, "{mode:?}/{stream}: {}", r.body); + assert_eq!( + text_of(&r, stream), + format!("my KEY is {USER_KEY} « ok"), + "{mode:?}/{stream}" + ); + assert!( + !r.body.contains("< Vec { + let mut src = fs::read(manifest_dir.join("src/helpers.js")).expect("read src/helpers.js"); + src.push(b'\n'); + src.extend(fs::read(manifest_dir.join("src/bridge.js")).expect("read src/bridge.js")); + src +} + +/// 在构建机上实例化一次、跑 `tw_init(helpers.js + bridge.js)`,把那一刻的整块内存写回数据段 fn snapshot(wasm: &[u8], bridge: &[u8]) -> Vec { use wasmtime::{Engine, Linker, Module, Store}; @@ -593,7 +604,7 @@ fn snapshot(wasm: &[u8], bridge: &[u8]) -> Vec { src.push(0); memory .write(&mut store, ptr as usize, &src) - .unwrap_or_else(|e| fail(&format!("write bridge.js: {e}"))); + .unwrap_or_else(|e| fail(&format!("write the bridge script: {e}"))); let rc = init .call(&mut store, (ptr, len)) .unwrap_or_else(|e| fail(&format!("tw_init trapped: {e:?}"))); @@ -605,7 +616,9 @@ fn snapshot(wasm: &[u8], bridge: &[u8]) -> Vec { .get(p..p + n) .map(|b| String::from_utf8_lossy(b).into_owned()) .unwrap_or_default(); - fail(&format!("bridge.js failed to initialize: {msg}")); + fail(&format!( + "helpers.js + bridge.js failed to initialize: {msg}" + )); } let image = memory.data(&store).to_vec(); rewrite(wasm, &image) diff --git a/crates/tw-plugin/examples/latency.rs b/crates/tw-plugin/examples/latency.rs index 41972000..32ad7224 100644 --- a/crates/tw-plugin/examples/latency.rs +++ b/crates/tw-plugin/examples/latency.rs @@ -49,7 +49,7 @@ fn main() { }); let mut r = p.reply(ctx.clone()).expect("reply"); report("on_text on a live reply instance", 20000, || { - let inv = r.on_text("a small widget delta"); + let inv = r.on_text(0, "a small widget delta"); assert!(inv.result.is_ok()); inv.cpu }); @@ -63,9 +63,29 @@ fn main() { }"#, ) .expect("load"); + // 钩子什么都不改、也不返回:插件只读请求(比如只记日志)时付的代价 + let look = rt + .load( + br#"export const manifest = { name: "look", api: 1, permissions: ["messages"] }; + export function onRequest(req) { + let n = 0; + for (const m of req.messages) n += m.parts.length; + if (n < 0) return req; + }"#, + ) + .expect("load"); for (label, size, n) in [("100 KB", 100usize << 10, 200), ("1 MB", 1 << 20, 30)] { let v = big_view(size); let bytes = serde_json::to_vec(&v).unwrap().len(); + report( + &format!("read a {label} view, no return, sandbox CPU"), + n, + || { + let inv = look.on_request(v.clone(), ctx.clone()); + assert!(matches!(inv.result, Ok(RequestOutcome::Unchanged))); + inv.cpu + }, + ); report( &format!("edit a {label} view ({bytes} B), sandbox CPU"), n, diff --git a/crates/tw-plugin/guest/src/lib.rs b/crates/tw-plugin/guest/src/lib.rs index bd6930ad..7c20636f 100644 --- a/crates/tw-plugin/guest/src/lib.rs +++ b/crates/tw-plugin/guest/src/lib.rs @@ -6,11 +6,13 @@ //! //! 宿主只通过下面这些导出驱动它: //! -//! 1. 构建时:`tw_init(bridge.js)` 建运行时和上下文、求值桥脚本;之后整块内存 -//! 做成快照,每个实例都从那里开始(`tw-plugin` 的 `build.rs`)。 +//! 1. 构建时:`tw_init(helpers.js + bridge.js)` 建运行时和上下文、求值桥脚本(前面拼着 +//! 插件辅助函数 `tw`);之后整块内存做成快照,每个实例都从那里开始(`tw-plugin` 的 +//! `build.rs`)。 //! 2. 加载插件:`tw_compile(源码)` 出字节码;再在新实例里走一遍 3 读出清单。 //! 3. 每个实例:`tw_seed` → `tw_load(字节码)`(求值模块顶层)→ `tw_set_ctx`。 -//! 4. 每次钩子:`tw_call` —— 调钩子、跑完 Promise 任务、取结果。 +//! 4. 每次钩子:`tw_call` —— 调钩子(连同这是回答里的第几块文字)、跑完 Promise 任务、 +//! 取结果。 //! //! 结果放在一块输出缓冲里,宿主用 `tw_out_ptr` / `tw_out_len` 读。输入由宿主用 //! `tw_alloc` 要一块内存写进来,交给导出函数之后归这边释放。 @@ -77,9 +79,9 @@ const JS_STACK_LIMIT: usize = 256 * 1024; // ── 导出 ───────────────────────────────────────────────────────── /// 宿主和胶水之间的约定版本写在这个导出的名字里(宿主按名字找它,不用实例化)。 -/// 改了导出的签名或含义就改名,两边一起改 +/// 改了导出的签名或含义就改名,两边一起改。2:`tw_call` 多了 `lane` #[unsafe(no_mangle)] -pub extern "C" fn tw_abi_1() {} +pub extern "C" fn tw_abi_2() {} /// 要一块 `len` 字节的内存给宿主写输入。多给一个字节:要求以 NUL 结尾的 /// 输入(`JS_Eval`)由宿主在末尾补 0 @@ -306,11 +308,12 @@ pub unsafe extern "C" fn tw_set_ctx(json: *mut u8, len: usize) -> u32 { } /// 调一次钩子。`kind`:0 onRequest,1 onReplyText,2 onReplyTextEnd,3 onToolCall。 -/// `input` 可以是空指针(onReplyTextEnd 没有输入)。 +/// `lane`:回答里的第几块文字(别的钩子是 0),桥只交给 `tw.streamReplace`,插件代码 +/// 看不到。`input` 可以是空指针(onReplyTextEnd 没有输入)。 /// /// 输出总是「一位状态码 + 内容」,返回值也是那位状态码(见 `bridge.js`)。 #[unsafe(no_mangle)] -pub unsafe extern "C" fn tw_call(kind: u32, input: *mut u8, len: usize) -> u32 { +pub unsafe extern "C" fn tw_call(kind: u32, lane: u64, input: *mut u8, len: usize) -> u32 { unsafe { q::JS_UpdateStackTop(RT); let arg = if input.is_null() { @@ -323,7 +326,8 @@ pub unsafe extern "C" fn tw_call(kind: u32, input: *mut u8, len: usize) -> u32 { if q::JS_IsException(arg) { return threw(); } - let mut args = [int(kind), arg]; + // 数字不占堆,不会失败,也不用释放;超过 2^53 的编号会丢精度,那只是插件自己分不清 + let mut args = [int(kind), q::JS_NewNumber(CTX, lane as f64), arg]; let r = call_bridge(c"call", &mut args); q::JS_FreeValue(CTX, arg); if q::JS_IsException(r) { diff --git a/crates/tw-plugin/src/bridge.js b/crates/tw-plugin/src/bridge.js index 39a13109..78a2593f 100644 --- a/crates/tw-plugin/src/bridge.js +++ b/crates/tw-plugin/src/bridge.js @@ -1,12 +1,13 @@ // 插件桥:在沙箱里、插件之前求值一次,然后连同整个 QuickJS 一起做进快照 -// (见 build.rs)。整个脚本的值是给 Rust 胶水(guest/src/lib.rs)的桥对象 —— -// 它不挂在任何全局上,插件拿不到。 +// (见 build.rs,它把 helpers.js 拼在这个文件前面一起求值)。整个脚本的值是给 Rust +// 胶水(guest/src/lib.rs)的桥对象 —— 它不挂在任何全局上,插件拿不到。 // -// 它做四件事: -// 1. 只留标准 ECMAScript 的全局,外加 console 和 reject; +// 它做五件事: +// 1. 只留标准 ECMAScript 的全局,外加 console、reject 和 tw(helpers.js); // 2. Math.random 换成每个实例重新播种的版本(快照把原来的状态冻住了); // 3. 调钩子,把返回值按钩子的类型核对、转成 JSON; -// 4. 把异常整理成 {"message","stack"}。 +// 4. 钩子不返回时,以它收到的对象此刻的样子为准(修改传入的对象即生效); +// 5. 把异常整理成 {"message","stack"}。 // // **宿主不信这里的任何结论。**插件和桥在同一个领域里,插件能改原型、改内建 // 函数,所以这里的核对只是为了给出好懂的错误;输出交回宿主后,宿主照样按 @@ -36,6 +37,9 @@ const ErrorCtor = Error; const TypeErrorCtor = TypeError; const hostLog = G.__tw_log; + // helpers.js 留下的那条私下的路(见它的开头):告诉 tw 这是第几块文字、是不是逐段模式 + const setLane = G.__tw_helpers.lane; + const setMode = G.__tw_helpers.mode; // 状态码:输出的第一个字符(Rust 那边按它分派) const VALUE = "0"; @@ -54,6 +58,9 @@ let current = null; // 正在跑的钩子名(reject 只认 onRequest) let rejected = null; // reject() 给的理由 let outcome = null; // { done, ok, value } + // onRequest、onToolCall 收到的对象和宿主给的原文:钩子不返回时拿它比(见 untouched) + let arg; + let argText = null; function clip(s, n) { return s.length > n ? ReflectApply(StringSlice, s, [0, n]) : s; @@ -164,11 +171,30 @@ return VALUE + s; } + // 钩子没返回(undefined):以它收到的那个对象此刻的样子为准。序列化出来和宿主给的 + // 原文一字不差就是没改,不必交给宿主;不一样就当它返回了这个对象,交给宿主照常解析、 + // 核对(改了又改回的那几项宿主不算改动)。 + // + // 这里判成「没改」只会丢掉改动,放不过越权的改动:越权只可能出在交给宿主的结果里,而 + // 宿主的核对一点没变。async 钩子到这里时 Promise 已经落定,比的是落定时的样子 + function untouched(kind) { + const hook = HOOKS[kind]; + let s; + try { + s = JSONstringify(arg); + } catch (e) { + return BAD + hook + " changed its argument into something that cannot be turned into JSON: " + clip(show(e), 500); + } + if (typeof s !== "string") return BAD + hook + " changed its argument into something that cannot be turned into JSON"; + if (s === argText) return UNCHANGED; + return VALUE + (kind === 0 ? s : "[" + s + "]"); + } + function finish(kind, v) { const hook = HOOKS[kind]; switch (kind) { case 0: - if (v === undefined) return UNCHANGED; + if (v === undefined) return untouched(0); if (v === null || typeof v !== "object" || ArrayIsArray(v)) { return BAD + "onRequest must return the request object or undefined, not " + typeName(v); } @@ -183,7 +209,7 @@ // 和 TextEncoder 的做法一样,不让一个截断的表情把整个回答弄挂 return VALUE + wellFormed(v); case 3: - if (v === undefined) return UNCHANGED; + if (v === undefined) return untouched(3); if (v === null) return DROP; if (typeof v !== "object") { return BAD + "onToolCall must return a tool call, an array of them, null or undefined, not " + typeName(v); @@ -273,8 +299,9 @@ ObjectDefineProperty(G, "console", { value: consoleObject, writable: true, configurable: true, enumerable: false }); ObjectDefineProperty(G, "reject", { value: reject, writable: true, configurable: true, enumerable: false }); - // 全局只留 ECMAScript 标准里的(含附录 B 的 escape/unescape)和上面两个。 - // 引擎哪天多给了什么(queueMicrotask、performance、navigator……),在这里统一去掉 + // 全局只留 ECMAScript 标准里的(含附录 B 的 escape/unescape)、上面两个和 tw。 + // 引擎哪天多给了什么(queueMicrotask、performance、navigator……),在这里统一去掉; + // 交接用的 __tw_log、__tw_helpers 也在这里去掉 const KEEP = [ "globalThis", "Infinity", "NaN", "undefined", "eval", "isFinite", "isNaN", "parseFloat", "parseInt", @@ -287,7 +314,7 @@ "SuppressedError", "Symbol", "SyntaxError", "TypeError", "Uint8Array", "Uint8ClampedArray", "Uint16Array", "Uint32Array", "URIError", "WeakMap", "WeakRef", "WeakSet", "Atomics", "JSON", "Math", "Reflect", - "console", "reject", + "console", "reject", "tw", ]; const keep = { __proto__: null }; for (let i = 0; i < KEEP.length; i++) keep[KEEP[i]] = true; @@ -336,6 +363,14 @@ } } catch (_) {} } + // tw.streamReplace 要知道:逐段模式下才扣住文字,扣着的要有 onReplyTextEnd 放出来 + let stream = false; + if (kind === "object") { + try { + stream = m.reply === "stream"; + } catch (_) {} + } + setMode(stream, typeof h.onReplyTextEnd === "function"); const def = ns.default; return ( '{"hooks":' + found + @@ -360,10 +395,14 @@ ctxValue = deepFreeze(JSONparse(text)); }, - call(kind, input) { + // `lane`:回答里的第几块文字(请求钩子、工具调用是 0),只交给 tw.streamReplace + call(kind, lane, input) { rejected = null; outcome = null; + arg = undefined; + argText = null; current = HOOKS[kind]; + setLane(lane); const f = hooks[current]; if (typeof f !== "function") { outcome = { __proto__: null, done: true, ok: false, value: new TypeErrorCtor(current + " is not exported") }; @@ -371,10 +410,12 @@ } let r; try { - if (kind === 0) r = ReflectApply(f, undefined, [JSONparse(input), ctxValue]); - else if (kind === 1) r = ReflectApply(f, undefined, [input, ctxValue]); - else if (kind === 2) r = ReflectApply(f, undefined, [ctxValue]); - else r = ReflectApply(f, undefined, [JSONparse(input), ctxValue]); + if (kind === 0 || kind === 3) { + argText = input; + arg = JSONparse(input); + r = ReflectApply(f, undefined, [arg, ctxValue]); + } else if (kind === 1) r = ReflectApply(f, undefined, [input, ctxValue]); + else r = ReflectApply(f, undefined, [ctxValue]); } catch (e) { outcome = { __proto__: null, done: true, ok: false, value: e }; return; @@ -414,6 +455,8 @@ return finish(kind, o.value); } finally { current = null; + arg = undefined; + argText = null; } }, diff --git a/crates/tw-plugin/src/helpers.js b/crates/tw-plugin/src/helpers.js new file mode 100644 index 00000000..d76cfb64 --- /dev/null +++ b/crates/tw-plugin/src/helpers.js @@ -0,0 +1,766 @@ +// 插件辅助函数:全局的 `tw`。build.rs 把它拼在桥脚本(bridge.js)前面、一起求值一次, +// 连同整个 QuickJS 做进快照 —— 每个实例起来时它已经在了,不多花一点时间。 +// +// 它省掉的是常见改写里的样板:附加系统提示词、逐段改文字、改工具参数、按规则替换(整段 +// 和逐段两种,同一套语义)、拆多行设置。**它不是安全边界**:tw 和插件在同一个领域里, +// 插件能改它、绕开它;它交出去的结果和插件手写的一样,宿主照常解析、按权限核对。 +// +// 所以这里的纪律和桥一样,只为一件事:插件改了原型、改了内建函数,tw 也不会出莫名其妙 +// 的错。用到的内建函数在插件运行之前拿住,之后不再从全局或原型上取;往数组里追加用没有 +// 原型的数组(Array.prototype 上挂的 setter 拦不住),属性描述符也没有原型 +// (Object.prototype 上挂一个 get 改不了它的意思)。抛给插件的错误一律英文,和桥一致。 +// +// 和桥之间只有一条私下的路:求值完在全局上留一个 `__tw_helpers`,桥在插件之前拿走它, +// 再连同别的多余全局一起删掉。桥用它告诉这里两件插件代码看不到的事: +// - 每次调钩子之前:这是这次回答里的第几块文字(lane)。同时进行的几块文字(比如 +// Chat Completions 的 n > 1)由 tw.streamReplace 分开缓冲; +// - 模块求值完之后:manifest 是不是逐段模式、有没有导出 onReplyTextEnd。 +"use strict"; +(function () { + const G = globalThis; + + const ObjectDefineProperty = Object.defineProperty; + const ObjectFreeze = Object.freeze; + const ObjectKeys = Object.keys; + const ObjectSetPrototypeOf = Object.setPrototypeOf; + const ObjectGetPrototypeOf = Object.getPrototypeOf; + const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const ArrayIsArray = Array.isArray; + const ArrayPrototype = Array.prototype; + const ArrayJoin = Array.prototype.join; + const ReflectApply = Reflect.apply; + const NumberIsInteger = Number.isInteger; + const MathMin = Math.min; + const MathMax = Math.max; + const StringCtor = String; + const StringIndexOf = String.prototype.indexOf; + const StringSlice = String.prototype.slice; + const StringTrim = String.prototype.trim; + const StringCharCodeAt = String.prototype.charCodeAt; + const RegExpCtor = RegExp; + const RegExpPrototype = RegExp.prototype; + const RegExpExec = RegExp.prototype.exec; + const MapCtor = Map; + const MapGet = Map.prototype.get; + const MapSet = Map.prototype.set; + const MapHas = Map.prototype.has; + const MapDelete = Map.prototype.delete; + const MapEntries = Map.prototype.entries; + const MapIteratorNext = ObjectGetPrototypeOf(new Map().entries()).next; + const SetCtor = Set; + const SetAdd = Set.prototype.add; + const SetHas = Set.prototype.has; + const SymbolIterator = Symbol.iterator; + const ErrorCtor = Error; + const TypeErrorCtor = TypeError; + + // 正则的来源和标志直接从内部槽读(原型上的 getter):实例上另挂一个 source、flags + // 改不了这里读到的 + function getter(name) { + const d = ObjectGetOwnPropertyDescriptor(RegExpPrototype, name); + return d === undefined ? undefined : d.get; + } + const RegExpSource = getter("source"); + const FLAGS = [ + ["i", getter("ignoreCase")], + ["m", getter("multiline")], + ["s", getter("dotAll")], + ["u", getter("unicode")], + ["v", getter("unicodeSets")], + ]; + const RegExpSticky = getter("sticky"); + + // 桥告诉这里的(见开头) + let lane = 0; + let stream = false; + let ends = false; + + // ── 小工具 ─────────────────────────────────────────────────── + + function slice(s, a, b) { + return ReflectApply(StringSlice, s, b === undefined ? [a] : [a, b]); + } + + function indexOf(s, x, from) { + return ReflectApply(StringIndexOf, s, [x, from]); + } + + function code(s, i) { + return ReflectApply(StringCharCodeAt, s, [i]); + } + + function trim(s) { + return ReflectApply(StringTrim, s, []); + } + + function exec(re, s, from) { + re.lastIndex = from; + return ReflectApply(RegExpExec, re, [s]); + } + + // 拼字、攒结果用的数组:没有原型 + function list() { + const a = []; + ObjectSetPrototypeOf(a, null); + return a; + } + + // 要交给插件的那些换回普通数组 + function array(a) { + ObjectSetPrototypeOf(a, ArrayPrototype); + return a; + } + + function join(a) { + return ReflectApply(ArrayJoin, a, [""]); + } + + // 建一个普通的自有属性。`__proto__` 这样的键也是一个普通的键,不会去改原型 + function define(o, k, v) { + ObjectDefineProperty(o, k, { __proto__: null, value: v, writable: true, enumerable: true, configurable: true }); + } + + function typeName(v) { + if (v === null) return "null"; + if (v === undefined) return "undefined"; + if (ArrayIsArray(v)) return "an array"; + return typeof v === "object" ? "an object" : "a " + typeof v; + } + + function isHigh(s, i) { + const c = code(s, i); + return c >= 0xd800 && c <= 0xdbff; + } + + function isLow(s, i) { + const c = code(s, i); + return c >= 0xdc00 && c <= 0xdfff; + } + + function isMap(v) { + try { + ReflectApply(MapHas, v, [undefined]); + return true; + } catch (_) { + return false; + } + } + + function isRegExp(v) { + if (v === null || typeof v !== "object" || v === RegExpPrototype) return false; + try { + ReflectApply(RegExpSource, v, []); + return true; + } catch (_) { + return false; + } + } + + function needRequest(req, who) { + if (req === null || typeof req !== "object") { + throw new TypeErrorCtor(who + " expects the request object, not " + typeName(req)); + } + } + + function needFunction(fn, who) { + if (typeof fn !== "function") { + throw new TypeErrorCtor(who + " expects a function, not " + typeName(fn)); + } + } + + function needString(s, who, what) { + if (typeof s !== "string") { + throw new TypeErrorCtor(who + ": " + what + " must be a string, not " + typeName(s)); + } + } + + // 视图里的一节没给(没申请这项权限,宿主就不给这一节) + function messagesOf(req, who) { + const ms = req.messages; + if (ms === undefined) { + throw new TypeErrorCtor(who + ' needs req.messages: add the "messages" permission'); + } + if (!ArrayIsArray(ms)) throw new TypeErrorCtor(who + ": req.messages is " + typeName(ms) + ", not an array"); + return ms; + } + + function partsOf(m, i, who) { + const ps = m !== null && typeof m === "object" ? m.parts : undefined; + if (!ArrayIsArray(ps)) throw new TypeErrorCtor(who + ": req.messages[" + i + "] has no parts array"); + return ps; + } + + // ── 占位符 ─────────────────────────────────────────────────── + // + // 插件看到的密钥是占位符(`<>`、`<>`……,写法见 tw-guard 的 + // `Scheme::SECRET` 和规则的标签:`TW_` 加大写字母、数字、下划线,再是号码)。替换整个 + // 跳过它们:不匹配、不拆开,一处命中也不能跨过一个占位符 —— 两个占位符之间的每一节当作 + // 一段单独的文字。这里认的比宿主宽:宁可多护住一段像占位符的字,也不能把真的拆开或换掉 + // (拆开了宿主就还原不了,客户端拿到的是占位符而不是原值) + const PLACEHOLDER = /<>/g; + // 文字末尾可能是一个还没收全的占位符 + const PARTIAL = /^<(?:<(?:T(?:W(?:_[A-Z0-9_]{0,64}>?)?)?)?)?$/; + const PARTIAL_MAX = 70; + + // `s` 末尾那个可能还没收全的占位符从哪儿起;没有就是 `s.length` + function partial(s) { + const n = s.length; + for (let p = MathMax(0, n - PARTIAL_MAX); p < n; p++) { + if (code(s, p) === 60 && exec(PARTIAL, slice(s, p), 0) !== null) return p; + } + return n; + } + + // ── 替换规则 ───────────────────────────────────────────────── + // + // 规则是 [查找, 替换] 对的列表(数组、Map 或别的可迭代对象)。查找是字符串(按字面)或 + // 正则(按全局找,`g` 这里加);替换是字符串(查找为字符串时按字面写入,为正则时认 `$1`、 + // `$` 这些)或函数,和 String.prototype.replace 一样。 + // + // 扫一遍、从左到右:同一位置几条规则都命中时取列表里靠前的;换进去的字不再参与匹配。 + + function pairOf(e, n, who) { + if (!ArrayIsArray(e) || e.length !== 2) { + throw new TypeErrorCtor(who + ": rule " + n + " is not a [find, replacement] pair"); + } + return e; + } + + function pairsOf(rules, who) { + const out = list(); + if (ArrayIsArray(rules)) { + const n = rules.length; + for (let i = 0; i < n; i++) out[out.length] = pairOf(rules[i], i + 1, who); + return out; + } + if (isMap(rules)) { + const it = ReflectApply(MapEntries, rules, []); + for (;;) { + const r = ReflectApply(MapIteratorNext, it, []); + if (r.done) return out; + out[out.length] = pairOf(r.value, out.length + 1, who); + } + } + const f = rules !== null && (typeof rules === "object" || typeof rules === "function") ? rules[SymbolIterator] : undefined; + if (typeof f !== "function") { + throw new TypeErrorCtor(who + ": the rules must be an array or a Map of [find, replacement] pairs, not " + typeName(rules)); + } + // 插件自己的可迭代对象:照协议走,它的 next 是它自己的 + const it = ReflectApply(f, rules, []); + const next = it.next; + for (;;) { + const r = ReflectApply(next, it, []); + if (r === null || typeof r !== "object") throw new TypeErrorCtor(who + ": the rules' iterator returned " + typeName(r)); + if (r.done) return out; + out[out.length] = pairOf(r.value, out.length + 1, who); + } + } + + // 核对一遍,编成扫描用的样子。`who` 是报错时的名字 + function compile(rules, who) { + const pairs = pairsOf(rules, who); + const out = list(); + let regex = false; + let longest = 0; + for (let i = 0; i < pairs.length; i++) { + const n = i + 1; + const find = pairs[i][0]; + const to = pairs[i][1]; + const r = { __proto__: null, n, find: null, re: null, to: null, fn: null, plain: true }; + if (typeof find === "string") { + if (find === "") throw new TypeErrorCtor(who + ": rule " + n + " looks for an empty string"); + r.find = find; + if (find.length > longest) longest = find.length; + } else if (isRegExp(find)) { + if (RegExpSticky !== undefined && ReflectApply(RegExpSticky, find, [])) { + throw new TypeErrorCtor(who + ": rule " + n + " has the y flag, which a rule cannot use"); + } + let flags = "g"; + for (let k = 0; k < FLAGS.length; k++) { + const g = FLAGS[k][1]; + if (g !== undefined && ReflectApply(g, find, [])) flags += FLAGS[k][0]; + } + r.re = new RegExpCtor(ReflectApply(RegExpSource, find, []), flags); + if (exec(r.re, "", 0) !== null) { + throw new TypeErrorCtor(who + ": rule " + n + " can match an empty string; a rule has to match at least one character"); + } + regex = true; + } else { + throw new TypeErrorCtor(who + ": rule " + n + " looks for " + typeName(find) + "; use a string or a regular expression"); + } + if (typeof to === "string") { + r.to = to; + r.plain = r.re === null || indexOf(to, "$", 0) === -1; + } else if (typeof to === "function") { + r.fn = to; + } else { + throw new TypeErrorCtor(who + ": rule " + n + " replaces with " + typeName(to) + "; use a string or a function"); + } + out[out.length] = r; + } + return { __proto__: null, who, rules: out, regex, longest, prefixes: null }; + } + + // 逐段要的:每个查找字符串的真前缀(文字末尾是不是某个查找字符串的开头,查一次) + function withPrefixes(c) { + const set = new SetCtor(); + for (let k = 0; k < c.rules.length; k++) { + const f = c.rules[k].find; + if (f === null) continue; + for (let n = 1; n < f.length; n++) ReflectApply(SetAdd, set, [slice(f, 0, n)]); + } + c.prefixes = set; + return c; + } + + // 替换字符串里的 `$` 写法,照 String.prototype.replace(规范的 GetSubstitution) + function expand(t, m, s, at) { + const matched = m[0]; + const captures = m.length - 1; + const groups = m.groups; + let out = ""; + let i = 0; + for (;;) { + const d = indexOf(t, "$", i); + if (d === -1) return out + slice(t, i); + out += slice(t, i, d); + const c = code(t, d + 1); + if (c === 36) { + out += "$"; + i = d + 2; + } else if (c === 38) { + out += matched; + i = d + 2; + } else if (c === 96) { + out += slice(s, 0, at); + i = d + 2; + } else if (c === 39) { + out += slice(s, at + matched.length); + i = d + 2; + } else if (c >= 48 && c <= 57) { + const c2 = code(t, d + 2); + let count = c2 >= 48 && c2 <= 57 ? 2 : 1; + let index = count === 2 ? (c - 48) * 10 + (c2 - 48) : c - 48; + if (index > captures && count === 2) { + count = 1; + index = c - 48; + } + if (index >= 1 && index <= captures) { + const v = m[index]; + if (v !== undefined) out += v; + } else { + out += slice(t, d, d + 1 + count); + } + i = d + 1 + count; + } else if (c === 60) { + const gt = groups === undefined ? -1 : indexOf(t, ">", d); + if (gt === -1) { + out += "$<"; + i = d + 2; + } else { + const v = groups[slice(t, d + 2, gt)]; + if (v !== undefined) out += `${v}`; + i = gt + 1; + } + } else { + out += "$"; + i = d + 1; + } + } + } + + // 一处命中换成什么。`s` 是正在扫的文字,`at` 是命中在它里面的位置。函数收到的位置按 + // 整节(两个占位符之间那一段)算:`base` 是 `s` 在那一节里的起点,整段和逐段一样 + function replacement(r, m, s, at, base) { + if (r.fn === null) return r.plain ? r.to : expand(r.to, m, s, at); + let args; + if (r.re === null) { + args = [r.find, base + at, s]; + } else { + args = list(); + for (let i = 0; i < m.length; i++) args[args.length] = m[i]; + args[args.length] = base + at; + args[args.length] = s; + if (m.groups !== undefined) args[args.length] = m.groups; + } + return `${ReflectApply(r.fn, undefined, args)}`; + } + + // 第 k 条规则从 `cur` 起的下一处命中,记进 at / end / hit。没有是 -1 + function locate(c, k, s, cur, at, end, hit) { + const r = c.rules[k]; + if (r.re === null) { + const i = indexOf(s, r.find, cur); + at[k] = i; + end[k] = i + r.find.length; + return i; + } + let m = exec(r.re, s, cur); + // 带 u 的正则从一个代理对中间起找时,命中可能落在 cur 前面一格:从下一格重找 + if (m !== null && m.index < cur) m = exec(r.re, s, cur + 1); + if (m === null || m.index < cur) { + at[k] = -1; + return -1; + } + const a = m.index; + const e = a + m[0].length; + if (e === a) { + throw new TypeErrorCtor(c.who + ": rule " + r.n + " matched an empty string; a rule has to match at least one character"); + } + at[k] = a; + end[k] = e; + hit[k] = m; + return a; + } + + // 扫 `s` 里 [from, limit) 这一段,换好的追加到 `out`。起点在 limit 之前的命中都算(它可以 + // 越过 limit);limit 之前没有命中的原样追加。返回扫到了哪儿(不小于 limit) + function scan(c, s, from, limit, out, base) { + const n = c.rules.length; + const at = list(); + const end = list(); + const hit = list(); + // -2:还没找过;-1:往后再也没有 + for (let k = 0; k < n; k++) at[k] = -2; + let cur = from; + while (cur < limit) { + let best = -1; + let bestAt = limit; + for (let k = 0; k < n; k++) { + let a = at[k]; + if (a === -1) continue; + if (a < cur) { + a = locate(c, k, s, cur, at, end, hit); + if (a === -1) continue; + } + if (a < bestAt) { + bestAt = a; + best = k; + } + } + if (best === -1) break; + if (bestAt > cur) out[out.length] = slice(s, cur, bestAt); + out[out.length] = replacement(c.rules[best], hit[best], s, bestAt, base); + cur = end[best]; + } + if (cur < limit) { + out[out.length] = slice(s, cur, limit); + cur = limit; + } + return cur; + } + + // 整段替换:按占位符分节,一节一节地扫 + function replaceWhole(c, text) { + const out = list(); + let from = 0; + if (c.rules.length === 0) return text; + if (indexOf(text, "< 1) { + for (let p = MathMax(cur, n - c.longest + 1); p < f; p++) { + if (ReflectApply(SetHas, c.prefixes, [slice(a, p)])) { + f = p; + break; + } + } + } + if (f <= cur) return cur; + if (f < n && isHigh(a, f - 1) && isLow(a, f)) f -= 1; + return f; + } + + function drain(st, max, end) { + const c = st.c; + const out = list(); + // 收全了的占位符:它前面那一节到头了,整个定下来 + while (indexOf(st.pending, "< { + const r = ReflectApply(fn, undefined, [text, { kind, role }]); + if (r !== undefined && typeof r !== "string") { + throw new TypeErrorCtor(who + ": the function returned " + typeName(r) + "; return a string, or undefined to keep the text"); + } + return r; + }; + if (system !== undefined) { + needString(system, who, "req.system"); + const r = call(system, "system", null); + if (r !== undefined) req.system = r; + } + if (ms === undefined) return; + if (!ArrayIsArray(ms)) throw new TypeErrorCtor(who + ": req.messages is " + typeName(ms) + ", not an array"); + for (let i = 0; i < ms.length; i++) { + const m = ms[i]; + const ps = partsOf(m, i, who); + for (let j = 0; j < ps.length; j++) { + const p = ps[j]; + if (p === null || typeof p !== "object") continue; + const kind = p.type; + if (kind !== "text" && kind !== "tool_result") continue; + const text = p.text; + if (typeof text !== "string") continue; + const r = call(text, kind, m.role); + if (r !== undefined) p.text = r; + } + } + }, + + // 对话历史里的每个工具调用片段交给 fn(就是那个片段本身:改它的 input 即生效) + toolCalls(req, fn) { + const who = "tw.toolCalls"; + needRequest(req, who); + needFunction(fn, who); + const ms = messagesOf(req, who); + for (let i = 0; i < ms.length; i++) { + const ps = partsOf(ms[i], i, who); + for (let j = 0; j < ps.length; j++) { + const p = ps[j]; + if (p !== null && typeof p === "object" && p.type === "tool_call") ReflectApply(fn, undefined, [p]); + } + } + }, + + // 一份副本,其中每个字符串值换成 fn(字符串, 键):键是属性名、数组下标,最外层是 null。 + // 对象的键、数字、布尔值、null 原样 + mapStrings(value, fn) { + const who = "tw.mapStrings"; + needFunction(fn, who); + const path = list(); + const walk = (v, key) => { + if (typeof v === "string") { + const r = ReflectApply(fn, undefined, [v, key]); + if (typeof r !== "string") { + throw new TypeErrorCtor(who + ": the function returned " + typeName(r) + "; it has to return a string"); + } + return r; + } + if (v === null || typeof v !== "object") return v; + for (let i = 0; i < path.length; i++) { + if (path[i] === v) throw new TypeErrorCtor(who + ": the value refers to itself"); + } + path[path.length] = v; + let out; + if (ArrayIsArray(v)) { + out = []; + const n = v.length; + for (let i = 0; i < n; i++) define(out, i, walk(v[i], i)); + } else { + out = {}; + const ks = ObjectKeys(v); + for (let i = 0; i < ks.length; i++) define(out, ks[i], walk(v[ks[i]], ks[i])); + } + path.length = path.length - 1; + return out; + }; + return walk(value, null); + }, + + replace(text, rules) { + needString(text, "tw.replace", "the text"); + return replaceWhole(compile(rules, "tw.replace"), text); + }, + + // 逐段模式的回答钩子:`export const { onReplyText, onReplyTextEnd } = tw.streamReplace(rules)`。 + // rules 可以是一个函数 (ctx) => 规则,每块文字开始时调一次(读设置用) + streamReplace(rules, options) { + const who = "tw.streamReplace"; + let max = 256; + if (options !== undefined) { + if (options === null || typeof options !== "object") { + throw new TypeErrorCtor(who + ": the options must be an object, not " + typeName(options)); + } + const v = options.maxMatch; + if (v !== undefined) { + if (typeof v !== "number" || !NumberIsInteger(v) || v < 1) { + throw new TypeErrorCtor(who + ": maxMatch must be a positive integer, not " + (typeof v === "number" ? StringCtor(v) : typeName(v))); + } + max = v; + } + } + const dynamic = typeof rules === "function"; + // 固定的规则现在就核对:写错了,插件加载时就报 + const fixed = dynamic ? null : withPrefixes(compile(rules, who)); + const rulesFor = (ctx) => (dynamic ? compile(ReflectApply(rules, undefined, [ctx]), who) : fixed); + // lane → 这一块的状态 + const lanes = new MapCtor(); + return { + onReplyText(text, ctx) { + if (typeof text !== "string") throw new TypeErrorCtor(who + ": onReplyText got " + typeName(text) + ", not a string"); + // 整块模式:一次就是一整块,整段换 + if (!stream) return replaceWhole(rulesFor(ctx), text); + if (!ends) { + throw new ErrorCtor(who + ": export its onReplyTextEnd as well; without it the text held back at the end of a block is lost"); + } + let st = ReflectApply(MapGet, lanes, [lane]); + if (st === undefined) { + const c = rulesFor(ctx); + st = { __proto__: null, c: c.prefixes === null ? withPrefixes(c) : c, pending: "", ctx: "", base: 0 }; + ReflectApply(MapSet, lanes, [lane, st]); + } + st.pending += text; + return drain(st, max, false); + }, + onReplyTextEnd() { + const st = ReflectApply(MapGet, lanes, [lane]); + if (st === undefined) return ""; + ReflectApply(MapDelete, lanes, [lane]); + return drain(st, max, true); + }, + }; + }, + + // 按行拆(\r\n 也算一个换行),每行去掉首尾空白,略去空行 + lines(text) { + needString(text, "tw.lines", "the text"); + const out = list(); + let from = 0; + for (;;) { + const i = indexOf(text, "\n", from); + const line = trim(slice(text, from, i === -1 ? text.length : i)); + if (line !== "") out[out.length] = line; + if (i === -1) return array(out); + from = i + 1; + } + }, + + // 一行一对:每个非空行在第一个分隔符处拆成 [左, 右],两边去掉空白。缺分隔符、左边是空 + // 的行抛错,说出是第几行(从 1 数)—— 忽略它的话,设置里的笔误就无从察觉 + pairs(text, separator) { + const who = "tw.pairs"; + needString(text, who, "the text"); + const sep = separator === undefined ? "=>" : separator; + needString(sep, who, "the separator"); + if (sep === "") throw new TypeErrorCtor(who + ": the separator must not be empty"); + const out = list(); + let from = 0; + for (let n = 1; ; n++) { + const i = indexOf(text, "\n", from); + const line = slice(text, from, i === -1 ? text.length : i); + if (trim(line) !== "") { + const at = indexOf(line, sep, 0); + if (at === -1) throw new ErrorCtor(who + ": line " + n + ' has no "' + sep + '"'); + const left = trim(slice(line, 0, at)); + if (left === "") throw new ErrorCtor(who + ": line " + n + ' has nothing before "' + sep + '"'); + out[out.length] = [left, trim(slice(line, at + sep.length))]; + } + if (i === -1) return array(out); + from = i + 1; + } + }, + }; + ObjectFreeze(tw); + + ObjectDefineProperty(G, "tw", { value: tw, writable: true, configurable: true, enumerable: false }); + ObjectDefineProperty(G, "__tw_helpers", { + value: ObjectFreeze({ + __proto__: null, + lane(n) { + lane = n; + }, + mode(isStream, hasEnd) { + stream = isStream; + ends = hasEnd; + }, + }), + writable: true, + configurable: true, + enumerable: false, + }); +})(); diff --git a/crates/tw-plugin/src/lib.rs b/crates/tw-plugin/src/lib.rs index 8a364086..3bc31a15 100644 --- a/crates/tw-plugin/src/lib.rs +++ b/crates/tw-plugin/src/lib.rs @@ -3,7 +3,12 @@ //! 插件是一个 JavaScript 模块。它在 QuickJS 里执行,而 QuickJS 本身编成了 wasm、 //! 跑在 Wasmtime 里(`build.rs` 把它编好、做成快照、预编译成本机机器码嵌进来)。 //! 沙箱从宿主那里只拿得到两样东西:写一行日志、看一眼时钟。没有文件、网络、 -//! 环境变量、进程,也没有别的插件。 +//! 环境变量、进程,也没有别的插件。插件看得到的全局除了 ECMAScript 标准内置,只有 +//! `console`、`reject` 和辅助函数 `tw`(`src/helpers.js`:纯 JavaScript,和桥脚本一起 +//! 做进快照;它交出的东西宿主照样不信)。 +//! +//! 请求钩子、工具调用钩子不返回时,以它收到的对象在调用结束时的样子为准(修改传入的 +//! 对象即生效,见 `src/bridge.js`)。 //! //! 每次请求钩子都是一个**新实例**;一个回答用**一个实例**,那个回答的每次钩子 //! 调用共用它,回答结束就丢掉。实例从快照起步(QuickJS 已经初始化好),然后求值 @@ -133,7 +138,7 @@ pub enum LogLevel { #[derive(Debug, Clone, PartialEq)] pub enum RequestOutcome { - /// 返回了 `undefined`,或者返回的视图和传进去的一样 + /// 返回的视图(返回 `undefined` 时是钩子结束时的 `req`)和传进去的一样 Unchanged, /// 改过的视图(没核对过结构和权限,那是 tw-gateway 的事) Changed(Value), @@ -617,7 +622,7 @@ impl Plugin { let cap = limits.max_output.limit(view_json.len()); let raw = sb .set_ctx(&ctx_json) - .and_then(|()| sb.call(Hook::Request, Some(&view_json), cap)); + .and_then(|()| sb.call(Hook::Request, 0, Some(&view_json), cap)); // 量到钩子返回为止:解析输出、和原视图比较是这边的事 let spent = sb.elapsed(); logs.extend(sb.take_logs()); @@ -675,13 +680,16 @@ impl fmt::Debug for Reply { } impl Reply { - /// 一段助手文字。分块模式下是整块,流式时是一段增量。`None` 表示照原样 - pub fn on_text(&mut self, text: &str) -> Invocation> { + /// 一段助手文字。分块模式下是整块,流式时是一段增量。`None` 表示照原样。 + /// + /// `lane` 是这块文字在这次回答里的编号:同时进行的几块文字(Chat Completions 的 + /// `n > 1`)各一个。它只交给沙箱里的 `tw.streamReplace` 分开缓冲,插件代码看不到 + pub fn on_text(&mut self, lane: u64, text: &str) -> Invocation> { if !self.plugin.manifest.hooks.reply_text { return self.skip(None); } let cap = self.plugin.rt.limits.max_output.limit(text.len()); - self.run(Hook::ReplyText, Some(text.as_bytes()), cap) + self.run(Hook::ReplyText, lane, Some(text.as_bytes()), cap) .then(|(status, payload)| match status { sandbox::VALUE => { let s = String::from_utf8_lossy(&payload).into_owned(); @@ -692,13 +700,13 @@ impl Reply { }) } - /// 流式模式下一个文字块结束:放出还攒着的文字 - pub fn on_text_end(&mut self) -> Invocation> { + /// 流式模式下第 `lane` 块文字结束:放出还攒着的文字 + pub fn on_text_end(&mut self, lane: u64) -> Invocation> { if !self.plugin.manifest.hooks.reply_text_end { return self.skip(None); } let cap = self.plugin.rt.limits.max_output.limit(0); - self.run(Hook::ReplyTextEnd, None, cap) + self.run(Hook::ReplyTextEnd, lane, None, cap) .then(|(status, payload)| match status { sandbox::VALUE => Ok(Some(String::from_utf8_lossy(&payload).into_owned())), sandbox::UNCHANGED => Ok(None), @@ -713,7 +721,7 @@ impl Reply { } let input = to_json(&call); let cap = self.plugin.rt.limits.max_output.limit(input.len()); - self.run(Hook::ToolCall, Some(&input), cap) + self.run(Hook::ToolCall, 0, Some(&input), cap) .then(|(status, payload)| decode_tool_call(status, payload, &call)) } @@ -725,7 +733,13 @@ impl Reply { } } - fn run(&mut self, hook: Hook, input: Option<&[u8]>, cap: usize) -> Invocation<(u8, Vec)> { + fn run( + &mut self, + hook: Hook, + lane: u64, + input: Option<&[u8]>, + cap: usize, + ) -> Invocation<(u8, Vec)> { let mut logs = std::mem::take(&mut self.carry_logs); let mut cpu = std::mem::take(&mut self.carry_cpu); if let Some(e) = &self.failed { @@ -747,7 +761,7 @@ impl Reply { } let _running = self.plugin.rt.ticker.enter(); self.sb.arm(limits.reply_call_cpu.min(left)); - let result = self.sb.call(hook, input, cap); + let result = self.sb.call(hook, lane, input, cap); let spent = self.sb.elapsed(); self.used += spent; cpu += spent; diff --git a/crates/tw-plugin/src/sandbox.rs b/crates/tw-plugin/src/sandbox.rs index 8f5f112f..deadeff0 100644 --- a/crates/tw-plugin/src/sandbox.rs +++ b/crates/tw-plugin/src/sandbox.rs @@ -14,9 +14,9 @@ use wasmtime::{ use crate::{Limits, LogLevel, LogLine, RunError, cpu}; -/// guest 和这边的约定版本:guest 导出一个带版本号的函数名,见它的 `tw_abi_1`。 +/// guest 和这边的约定版本:guest 导出一个带版本号的函数名,见它的 `tw_abi_2`。 /// 改了导出的签名或含义,两边一起改名 -const ABI_EXPORT: &str = "tw_abi_1"; +const ABI_EXPORT: &str = "tw_abi_2"; /// 沙箱模块唯一允许的导入(build.rs 编的时候查过一次,加载时再查一次) pub(crate) const ALLOWED_IMPORTS: &[(&str, &str)] = @@ -184,7 +184,7 @@ struct Exports { load: TypedFunc<(u32, u32), u32>, seed: TypedFunc<(u32, u32, u32, u32), u32>, set_ctx: TypedFunc<(u32, u32), u32>, - call: TypedFunc<(u32, u32, u32), u32>, + call: TypedFunc<(u32, u64, u32, u32), u32>, } /// 一段 JS 抛出的错误,桥整理成的样子 @@ -447,10 +447,12 @@ impl Sandbox { Ok(()) } - /// 调一次钩子。返回状态码和内容;内容超过 `cap` 字节按超出输出上限算 + /// 调一次钩子。`lane` 是回答里的第几块文字(只交给沙箱里的 `tw.streamReplace`), + /// 别的钩子给 0。返回状态码和内容;内容超过 `cap` 字节按超出输出上限算 pub(crate) fn call( &mut self, hook: Hook, + lane: u64, input: Option<&[u8]>, cap: usize, ) -> Result<(u8, Vec), RunError> { @@ -460,7 +462,7 @@ impl Sandbox { }; self.f .call - .call(&mut self.store, (hook as u32, ptr, len)) + .call(&mut self.store, (hook as u32, lane, ptr, len)) .map_err(|e| self.classify(e))?; if self.memory_denied() { return Err(RunError::MemoryLimit); diff --git a/crates/tw-plugin/tests/common/mod.rs b/crates/tw-plugin/tests/common/mod.rs index ad2a1f16..f3eeed70 100644 --- a/crates/tw-plugin/tests/common/mod.rs +++ b/crates/tw-plugin/tests/common/mod.rs @@ -116,14 +116,14 @@ pub fn reply(p: &Plugin, settings: Value) -> Reply { pub fn text(r: &mut Reply, s: &str) -> Invocation> { let t = Instant::now(); - let inv = r.on_text(s); + let inv = r.on_text(0, s); assert!(t.elapsed() < BOUND, "onReplyText took {:?}", t.elapsed()); inv } pub fn text_end(r: &mut Reply) -> Invocation> { let t = Instant::now(); - let inv = r.on_text_end(); + let inv = r.on_text_end(0); assert!(t.elapsed() < BOUND, "onReplyTextEnd took {:?}", t.elapsed()); inv } diff --git a/crates/tw-plugin/tests/corpus/globals.js b/crates/tw-plugin/tests/corpus/globals.js index 097ee544..bf290f14 100644 --- a/crates/tw-plugin/tests/corpus/globals.js +++ b/crates/tw-plugin/tests/corpus/globals.js @@ -1,6 +1,6 @@ // 探查:沙箱里有哪些全局名字。把 globalThis 上的全部键(含不可枚举的、符号键) // 写进系统提示词,由测试和允许的清单比对。 -// 预期:只有 ECMAScript 标准内置、console 和 reject。 +// 预期:只有 ECMAScript 标准内置、console、reject 和 tw。 export const manifest = { name: "列出全局", api: 1, permissions: ["system"] }; export function onRequest(req) { diff --git a/crates/tw-plugin/tests/corpus/helpers-split.js b/crates/tw-plugin/tests/corpus/helpers-split.js new file mode 100644 index 00000000..58c9ae32 --- /dev/null +++ b/crates/tw-plugin/tests/corpus/helpers-split.js @@ -0,0 +1,212 @@ +// 逐段替换的切分性质:随机文字、随机规则(字符串和正则混着,文字里带占位符和像占位符 +// 的字),三样必须一致 —— +// 1. 一个逐个位置去试的参照实现(每个位置按规则的先后试,正则用 y 标志钉在那个位置, +// 替换交给内建的 String.prototype.replace 去算); +// 2. 整段的 tw.replace; +// 3. 任意切分之后逐段交给 tw.streamReplace,拼起来的结果。 +// 另外查两样:逐段交出的每一段都不会把输入里的占位符切开,也不会把一个代理对切开。 +// +// 在 onRequest 里跑(lane 一直是 0),每种切分是同一个 lane 上的一块文字,块和块之间 +// 调 onReplyTextEnd。ctx.settings:seed(随机种子)、cases(几组规则和文字)、splits +// (每组切几种)。结果写进系统提示词:{ checked, failures }。 +export const manifest = { + name: "split property", + api: 1, + permissions: ["system", "reply.text"], + reply: "stream", +}; + +export const { onReplyText, onReplyTextEnd } = tw.streamReplace([]); + +let seed = 1; +function rand() { + seed ^= seed << 13; + seed ^= seed >>> 17; + seed ^= seed << 5; + return (seed >>> 0) / 4294967296; +} +const pick = (a) => a[Math.floor(rand() * a.length)]; +const int = (lo, hi) => lo + Math.floor(rand() * (hi - lo + 1)); + +const PIECES = ["a", "b", "c", "ab", "A", "1", "2", " ", "\n", "<", ">", "<<", ">>", "T", "W", "_", "TW_", "<>", "<>", "<>", "<>"]; +const LOOKALIKES = ["<>", "<", ">", "<<>", "<>>", "<", "<<", ">>", "T", "W", "_", "TW_", "SECRET", "_1", "é", "😀"]; +const TOS = ["", "x", "$&", "$1", "<", "TW_", "<>", "😀", "[]"]; +// 每一条一次命中连同前后断言看到的范围都不超过 7 个字(maxMatch 至少 8) +const REGEXES = [ + [/a{1,3}/, "[$&]"], + [/ab|a/, "<$&>"], + [/[ab]c/, "x"], + [/(?<=a)b/, "B"], + [/b(?=c)/, "β"], + [/(?[ab])c/, "$!"], + [/A/i, "i"], + [/\d/, "#"], + [/T(?=W)/, "t"], + [/<>/, "»"], + [/_1>/, "!"], + [/😀/u, ":)"], + [/[^\n]{2}\n/, "¶"], + [/W_[A-Z]/, "w"], + [/SECRET/, "s"], + [/\d{1,2}>>/, "?"], + [/./, "·"], +]; +// 函数替换:一个看命中的字,一个看位置(按两个占位符之间那一节算) +const FUNCTIONS = [ + (m) => "{" + m + "}", + (...a) => "@" + a[a.length - (typeof a[a.length - 1] === "object" ? 3 : 2)], +]; + +function text() { + let s = ""; + const n = int(0, 30); + for (let i = 0; i < n; i++) { + const r = rand(); + s += r < 0.08 ? pick(PLACEHOLDERS) : r < 0.13 ? pick(LOOKALIKES) : pick(PIECES); + } + return s; +} + +function rules() { + const out = []; + const n = int(0, 4); + for (let i = 0; i < n; i++) { + const fn = rand() < 0.15 ? pick(FUNCTIONS) : null; + if (rand() < 0.5) { + let f = ""; + const k = int(1, 3); + for (let j = 0; j < k; j++) f += pick(FINDS); + out.push([f, fn ?? pick(TOS)]); + } else { + const [re, to] = pick(REGEXES); + out.push([re, fn ?? to]); + } + } + return out; +} + +const PH = /<>/g; + +// 参照实现:两个占位符之间每一节单独替换,占位符原样,记下它们在结果里的位置 +function reference(s, rs) { + let out = ""; + const spans = []; + let from = 0; + let m; + PH.lastIndex = 0; + while ((m = PH.exec(s)) !== null) { + out += section(s.slice(from, m.index), rs); + spans.push([out.length, out.length + m[0].length]); + out += m[0]; + from = PH.lastIndex; + } + return { out: out + section(s.slice(from), rs), spans }; +} + +function section(t, rs) { + let out = ""; + let i = 0; + outer: while (i < t.length) { + for (const [find, to] of rs) { + if (typeof find === "string") { + if (t.startsWith(find, i)) { + out += typeof to === "function" ? String(to(find, i, t)) : to; + i += find.length; + continue outer; + } + continue; + } + const re = new RegExp(find.source, find.flags + "y"); + re.lastIndex = i; + const m = re.exec(t); + if (m === null) continue; + const end = i + m[0].length; + re.lastIndex = i; + // 不带 g:只换钉在 i 的这一处,$ 写法和函数参数都交给内建的去算 + const whole = t.replace(re, to); + out += whole.slice(i, whole.length - (t.length - end)); + i = end; + continue outer; + } + out += t[i]; + i += 1; + } + return out; +} + +// 随机切开,不切在代理对中间(宿主交来的每一段都是完整的 UTF-8) +function split(s) { + const mode = rand(); + const chunks = []; + let start = 0; + for (let i = 1; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c >= 0xdc00 && c <= 0xdfff) continue; + if (mode < 0.2 || rand() < 0.35) { + chunks.push(s.slice(start, i)); + start = i; + } + } + chunks.push(s.slice(start)); + // 偶尔夹一段空的 + if (rand() < 0.1) chunks.splice(int(0, chunks.length), 0, ""); + return chunks; +} + +function show(rs) { + return rs.map(([f, t]) => [typeof f === "string" ? f : String(f), typeof t === "function" ? "fn:" + FUNCTIONS.indexOf(t) : t]); +} + +export function onRequest(req, ctx) { + seed = ctx.settings.seed >>> 0 || 1; + const failures = []; + let checked = 0; + for (let k = 0; k < ctx.settings.cases && failures.length < 5; k++) { + const rs = rules(); + const max = int(8, 16); + const input = text(); + const ref = reference(input, rs); + const finds = rs.map(([f]) => f); + const asMap = rand() < 0.2 && new Set(finds).size === finds.length; + const whole = tw.replace(input, asMap ? new Map(rs) : rs); + if (whole !== ref.out) { + failures.push({ what: "whole", input, rules: show(rs), want: ref.out, got: whole }); + continue; + } + const h = tw.streamReplace(rand() < 0.3 ? () => rs : rs, { maxMatch: max }); + for (let n = 0; n < ctx.settings.splits; n++) { + const chunks = split(input); + const outs = chunks.map((c) => h.onReplyText(c, ctx)); + outs.push(h.onReplyTextEnd(ctx)); + const got = outs.join(""); + checked++; + if (got !== ref.out) { + failures.push({ what: "stream", input, chunks, max, rules: show(rs), want: ref.out, got, outs }); + break; + } + let at = 0; + for (const o of outs) { + at += o.length; + const c = got.charCodeAt(at); + if (ref.spans.some(([a, b]) => a < at && at < b) || (c >= 0xdc00 && c <= 0xdfff && at > 0)) { + failures.push({ what: "cut", input, chunks, max, rules: show(rs), outs, at }); + break; + } + } + } + } + req.system = JSON.stringify({ checked, failures }); +} diff --git a/crates/tw-plugin/tests/helpers.rs b/crates/tw-plugin/tests/helpers.rs new file mode 100644 index 00000000..9d43d430 --- /dev/null +++ b/crates/tw-plugin/tests/helpers.rs @@ -0,0 +1,1049 @@ +//! 插件辅助函数 `tw`(`src/helpers.js`)在真的沙箱里的行为:每个函数正常的用法、各种 +//! 用错的地方、缺权限时的报错;替换规则的语义;逐段替换的切分性质和多块文字交错;插件 +//! 改了内建函数之后 tw 照常工作。 +//! +//! 插件源码都写在测试里;切分性质的随机测试是语料里的 `helpers-split.js`,在沙箱里自己 +//! 循环切分,跑得快。 + +mod common; + +use std::sync::OnceLock; + +use serde_json::{Value, json}; +use tw_plugin::*; + +fn rt() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| Runtime::new(Limits::default()).expect("runtime")) +} + +fn load(src: &str) -> Plugin { + match rt().load(src.as_bytes()) { + Ok(p) => p, + Err(e) => panic!("load failed: {e:?}\n{src}"), + } +} + +fn load_err(src: &str) -> LoadError { + match rt().load(src.as_bytes()) { + Ok(p) => panic!("expected a load error, got {:?}", p.manifest()), + Err(e) => e, + } +} + +fn ctx(settings: Value) -> Value { + json!({ + "client": "claude-code", + "model": "claude-x", + "format": "anthropic", + "upstream": "relay", + "settings": settings, + }) +} + +/// 一段对话的视图:每种片段都有 +fn view() -> Value { + json!({ + "format": "anthropic", + "model": "claude-x", + "system": "be brief", + "messages": [ + { "key": "m0", "role": "user", "parts": [ + { "key": "p0", "type": "text", "text": "read /mnt/c/a.txt" }, + { "key": "p1", "type": "image", "media_type": "image/png" }, + ] }, + { "key": "m1", "role": "assistant", "parts": [ + { "key": "p2", "type": "thinking", "text": "hmm" }, + { "key": "p3", "type": "text", "text": "ok" }, + { "key": "p4", "type": "tool_call", "id": "t1", "name": "Read", "input": { "path": "/mnt/c/a.txt", "n": [1, "/mnt/d"] } }, + ] }, + { "key": "m2", "role": "tool", "parts": [ + { "key": "p5", "type": "tool_result", "call_id": "t1", "text": "contents", "is_error": false }, + { "key": "p6", "type": "other", "label": "document" }, + ] }, + ], + "params": { "model": "claude-x", "max_tokens": 1024 } + }) +} + +/// 嵌入的视图:一项输入一条 user 消息,没有系统提示词 +fn inputs_view() -> Value { + json!({ + "format": "openai_embeddings", + "model": "e", + "messages": [ + { "key": "m0", "role": "user", "parts": [ { "key": "p0", "type": "text", "text": "one" } ] }, + { "key": "m1", "role": "user", "parts": [ + { "key": "p1", "type": "text", "text": "two" }, + { "key": "p2", "type": "other", "label": "tokens" }, + ] }, + ], + }) +} + +fn plugin(perms: &str, body: &str) -> Plugin { + load(&format!( + "export const manifest = {{ name: \"t\", api: 1, permissions: [{perms}] }};\n\ + export function onRequest(req, ctx) {{ {body} }}" + )) +} + +const ALL: &str = r#""system", "messages", "tools", "params""#; + +/// 有全部请求权限的插件对 `view` 跑一次 +fn run_on(view: Value, body: &str) -> Invocation { + plugin(ALL, body).on_request(view, ctx(json!({}))) +} + +fn run(body: &str) -> Invocation { + run_on(view(), body) +} + +fn changed(inv: &Invocation) -> Value { + match &inv.result { + Ok(RequestOutcome::Changed(v)) => v.clone(), + other => panic!("expected Changed, got {other:?} (logs {:?})", inv.logs), + } +} + +/// 算一个值带出来:`out(v)` 写进系统提示词 +fn eval(expr: &str) -> Value { + let inv = run(&format!("req.system = JSON.stringify({expr});")); + let v = changed(&inv); + serde_json::from_str(v["system"].as_str().unwrap()).unwrap() +} + +fn threw(inv: &Invocation) -> String { + match &inv.result { + Err(RunError::Threw { message, .. }) => message.clone(), + other => panic!("expected Threw, got {other:?}"), + } +} + +fn eval_err(expr: &str) -> String { + threw(&run(&format!("req.system = JSON.stringify({expr});"))) +} + +// ── 全局 ───────────────────────────────────────────────────────── + +#[test] +fn tw_is_a_frozen_global_the_plugin_may_shadow() { + let v = eval( + r#"{ + frozen: Object.isFrozen(tw), + names: Object.keys(tw).sort(), + enumerable: Object.keys(globalThis).includes("tw"), + assign: (() => { try { tw.replace = null; return "assigned"; } catch (e) { return e.constructor.name; } })(), + }"#, + ); + assert_eq!(v["frozen"], true); + assert_eq!( + v["names"], + json!([ + "appendSystem", + "lines", + "mapStrings", + "pairs", + "replace", + "streamReplace", + "texts", + "toolCalls" + ]) + ); + assert_eq!(v["enumerable"], false); + assert_eq!(v["assign"], "TypeError"); + // 和 console、reject 一样,全局的这个名字插件可以换掉:只影响它自己 + let inv = run("globalThis.tw = { appendSystem() {} }; tw.appendSystem(req, 'x');"); + assert_eq!(inv.result, Ok(RequestOutcome::Unchanged)); +} + +#[test] +fn errors_point_at_the_plugin_not_at_tw() { + let inv = run("tw.toolCalls(req, 5);"); + let Err(RunError::Threw { message, stack }) = &inv.result else { + panic!("{:?}", inv.result); + }; + assert_eq!( + message, + "TypeError: tw.toolCalls expects a function, not a number" + ); + let stack = stack.as_deref().unwrap_or(""); + assert!(stack.contains("plugin.js"), "{stack}"); + assert!(!stack.contains("bridge.js"), "{stack}"); +} + +// ── appendSystem ───────────────────────────────────────────────── + +#[test] +fn append_system_adds_a_paragraph_once() { + let v = changed(&run("tw.appendSystem(req, 'Answer in English.');")); + assert_eq!(v["system"], "be brief\n\nAnswer in English."); + // 已经有了:不再附加 + assert_eq!( + run("tw.appendSystem(req, 'brief');").result, + Ok(RequestOutcome::Unchanged) + ); + // 空的系统提示词:直接写入 + let v = changed(&run("req.system = ''; tw.appendSystem(req, 'x');")); + assert_eq!(v["system"], "x"); + // 返回 req 也一样 + let v = changed(&run("tw.appendSystem(req, 'y'); return req;")); + assert_eq!(v["system"], "be brief\n\ny"); +} + +#[test] +fn append_system_needs_the_system_permission_and_a_string() { + let p = plugin(r#""messages""#, "tw.appendSystem(req, 'x');"); + let mut v = view(); + v.as_object_mut().unwrap().remove("system"); + assert_eq!( + threw(&p.on_request(v, ctx(json!({})))), + r#"TypeError: tw.appendSystem needs req.system: add the "system" permission"# + ); + assert_eq!( + threw(&run("tw.appendSystem(req, 5);")), + "TypeError: tw.appendSystem: the text must be a string, not a number" + ); + assert_eq!( + threw(&run("tw.appendSystem(null, 'x');")), + "TypeError: tw.appendSystem expects the request object, not null" + ); + assert_eq!( + threw(&run("req.system = 3; tw.appendSystem(req, 'x');")), + "TypeError: tw.appendSystem: req.system must be a string, not a number" + ); +} + +// ── texts ──────────────────────────────────────────────────────── + +#[test] +fn texts_visits_the_system_prompt_text_and_tool_results_in_order() { + let inv = run(r#" + const seen = []; + tw.texts(req, (t, place) => { seen.push([t, place.kind, place.role]); return t === "ok" ? "OK" : undefined; }); + console.log(JSON.stringify(seen)); + "#); + let seen: Value = serde_json::from_str(&inv.logs[0].text).unwrap(); + assert_eq!( + seen, + json!([ + ["be brief", "system", null], + ["read /mnt/c/a.txt", "text", "user"], + ["ok", "text", "assistant"], + ["contents", "tool_result", "tool"], + ]) + ); + let v = changed(&inv); + assert_eq!(v["messages"][1]["parts"][1]["text"], "OK"); + // 别的原样:思考、工具调用、图片 + let mut want = view(); + want["messages"][1]["parts"][1]["text"] = json!("OK"); + assert_eq!(v, want); +} + +#[test] +fn texts_with_replace_rewrites_every_text() { + let v = changed(&run( + "tw.texts(req, (t) => tw.replace(t, [[\"/mnt/c/\", \"C:/\"], [\"brief\", \"short\"]]));", + )); + assert_eq!(v["system"], "be short"); + assert_eq!(v["messages"][0]["parts"][0]["text"], "read C:/a.txt"); + // 工具调用的参数不是文字 + assert_eq!( + v["messages"][1]["parts"][2]["input"]["path"], + "/mnt/c/a.txt" + ); +} + +#[test] +fn texts_on_an_inputs_view_passes_each_input_as_user_text() { + let inv = run_on( + inputs_view(), + r#" + const seen = []; + tw.texts(req, (t, place) => { seen.push([t, place.kind, place.role]); return t.toUpperCase(); }); + console.log(JSON.stringify(seen)); + "#, + ); + let seen: Value = serde_json::from_str(&inv.logs[0].text).unwrap(); + assert_eq!( + seen, + json!([["one", "text", "user"], ["two", "text", "user"]]) + ); + let v = changed(&inv); + assert_eq!(v["messages"][1]["parts"][0]["text"], "TWO"); + assert_eq!( + v["messages"][1]["parts"][1], + json!({ "key": "p2", "type": "other", "label": "tokens" }) + ); +} + +#[test] +fn texts_checks_what_the_function_returns_and_what_it_is_given() { + assert_eq!( + threw(&run("tw.texts(req, () => 5);")), + "TypeError: tw.texts: the function returned a number; return a string, or undefined to keep the text" + ); + assert_eq!( + threw(&run("tw.texts(req, () => null);")), + "TypeError: tw.texts: the function returned null; return a string, or undefined to keep the text" + ); + assert_eq!( + threw(&run("tw.texts(req);")), + "TypeError: tw.texts expects a function, not undefined" + ); + // 两节都没给 + let p = plugin(r#""params""#, "tw.texts(req, (t) => t);"); + assert_eq!( + threw(&p.on_request( + json!({ "format": "anthropic", "model": "m", "params": {} }), + ctx(json!({})) + )), + r#"TypeError: tw.texts needs req.system or req.messages: add the "system" or "messages" permission"# + ); + // 只给了一节:那一节照常 + let p = plugin(r#""system""#, "tw.texts(req, (t) => t + '!');"); + let inv = p.on_request( + json!({ "format": "anthropic", "model": "m", "system": "s" }), + ctx(json!({})), + ); + assert_eq!(changed(&inv)["system"], "s!"); + assert_eq!( + threw(&run("req.messages = {}; tw.texts(req, (t) => t);")), + "TypeError: tw.texts: req.messages is an object, not an array" + ); + assert_eq!( + threw(&run( + "req.messages.push({ role: 'user' }); tw.texts(req, (t) => t);" + )), + "TypeError: tw.texts: req.messages[3] has no parts array" + ); +} + +// ── toolCalls ──────────────────────────────────────────────────── + +#[test] +fn tool_calls_hands_over_the_live_parts() { + let inv = run(r#" + const seen = []; + tw.toolCalls(req, (call) => { seen.push(call.name); call.input.path = "C:/a.txt"; return "ignored"; }); + console.log(JSON.stringify(seen)); + "#); + assert_eq!(inv.logs[0].text, r#"["Read"]"#); + let v = changed(&inv); + assert_eq!( + v["messages"][1]["parts"][2], + json!({ "key": "p4", "type": "tool_call", "id": "t1", "name": "Read", "input": { "path": "C:/a.txt", "n": [1, "/mnt/d"] } }) + ); + // 什么都没改:没改 + assert_eq!( + run("tw.toolCalls(req, () => {});").result, + Ok(RequestOutcome::Unchanged) + ); +} + +#[test] +fn tool_calls_needs_the_messages_permission() { + let p = plugin(r#""system""#, "tw.toolCalls(req, () => {});"); + assert_eq!( + threw(&p.on_request( + json!({ "format": "anthropic", "model": "m", "system": "s" }), + ctx(json!({})) + )), + r#"TypeError: tw.toolCalls needs req.messages: add the "messages" permission"# + ); + assert_eq!( + threw(&run("tw.toolCalls(req, 'f');")), + "TypeError: tw.toolCalls expects a function, not a string" + ); +} + +// ── mapStrings ─────────────────────────────────────────────────── + +#[test] +fn map_strings_copies_and_rewrites_string_values_only() { + let v = eval( + r#"(() => { + const input = { path: "a", list: ["b", 1, true, null, { deep: "c" }], n: 2, "x y": "d" }; + const keys = []; + const out = tw.mapStrings(input, (s, k) => { keys.push(k); return s.toUpperCase(); }); + return { out, keys, untouched: input.path === "a" && input.list[4].deep === "c", top: tw.mapStrings("t", (s, k) => s + String(k)) }; + })()"#, + ); + assert_eq!( + v["out"], + json!({ "path": "A", "list": ["B", 1, true, null, { "deep": "C" }], "n": 2, "x y": "D" }) + ); + assert_eq!(v["keys"], json!(["path", 0, "deep", "x y"])); + assert_eq!(v["untouched"], true); + assert_eq!(v["top"], "tnull"); +} + +#[test] +fn map_strings_keeps_proto_keys_as_plain_keys() { + // JSON.parse 建出来的 `__proto__` 是一个普通的自有键:副本里也是,原型不变 + let v = eval( + r#"(() => { + const input = JSON.parse('{"__proto__": {"polluted": "yes"}, "constructor": "c"}'); + const out = tw.mapStrings(input, (s) => s + "!"); + return { + own: Object.prototype.hasOwnProperty.call(out, "__proto__"), + protoIntact: Object.getPrototypeOf(out) === Object.prototype, + polluted: ({}).polluted === undefined ? "no" : "yes", + json: JSON.stringify(out), + }; + })()"#, + ); + assert_eq!(v["own"], true); + assert_eq!(v["protoIntact"], true); + assert_eq!(v["polluted"], "no"); + assert_eq!( + v["json"], + r#"{"__proto__":{"polluted":"yes!"},"constructor":"c!"}"# + ); +} + +#[test] +fn map_strings_rejects_bad_functions_and_cycles() { + assert_eq!( + eval_err("tw.mapStrings({ a: 'x' }, () => 1)"), + "TypeError: tw.mapStrings: the function returned a number; it has to return a string" + ); + assert_eq!( + eval_err("tw.mapStrings({ a: 'x' })"), + "TypeError: tw.mapStrings expects a function, not undefined" + ); + assert_eq!( + eval_err( + "(() => { const o = { a: [] }; o.a.push(o); return tw.mapStrings(o, (s) => s); })()" + ), + "TypeError: tw.mapStrings: the value refers to itself" + ); + // 同一个对象出现两次不是环 + assert_eq!( + eval("(() => { const s = { a: 'x' }; return tw.mapStrings([s, s], (t) => t + '1'); })()"), + json!([{ "a": "x1" }, { "a": "x1" }]) + ); +} + +// ── replace ────────────────────────────────────────────────────── + +#[test] +fn replace_with_strings_is_literal_and_case_sensitive() { + assert_eq!( + eval(r#"tw.replace("a $1 cost $5, A", [["$5", "$&$$"], ["a", "b"]])"#), + "b $1 cost $&$$, A" + ); + assert_eq!(eval(r#"tw.replace("aAa", [[/a/i, "x"]])"#), "xxx"); +} + +#[test] +fn replace_with_regexes_works_like_string_replace() { + assert_eq!( + eval(r#"tw.replace("John Smith", [[/(\w+)\s(\w+)/, "$2, $1"]])"#), + "Smith, John" + ); + assert_eq!( + eval( + r#"tw.replace("2026-10-16", [[/(?\d{4})-(?\d\d)-(?\d\d)/, "$/$/$"]])"# + ), + "16/10/2026" + ); + assert_eq!( + eval(r#"tw.replace("abc", [[/b/, "[$&|$`|$'|$$|$0|$9|$]"]])"#), + "a[b|a|c|$|$0|$9|$]c" + ); + assert_eq!( + eval(r#"tw.replace("abc", [[/(b)/, "$01$11$2"]])"#), + "abb1$2c" + ); + // 函数:命中、各组、位置、整段、具名组,和 String.prototype.replace 一样 + assert_eq!( + eval( + r#"tw.replace("x-ab-ab", [[/(a)(?b)/, (m, a, b, at, s, g) => `${m}${a}${b}${at}${s.length}${g.n}`]])"# + ), + "x-abab27b-abab57b" + ); + // 没有 g 也找全部;不用写 g + assert_eq!(eval(r#"tw.replace("aaa", [[/a/, "b"]])"#), "bbb"); + assert_eq!(eval(r#"tw.replace("aaa", [[/a/g, "b"]])"#), "bbb"); + // 函数替换,查找是字符串 + assert_eq!( + eval(r#"tw.replace("xaxa", [["a", (m, at) => m + at]])"#), + "xa1xa3" + ); +} + +#[test] +fn replace_scans_once_left_to_right_and_the_first_rule_wins_a_tie() { + // 换进去的字不再参与匹配:A→B、B→C 并存时,A 不会变成 C + assert_eq!(eval(r#"tw.replace("AB", [["A", "B"], ["B", "C"]])"#), "BC"); + // 同一位置:列表里靠前的 + assert_eq!( + eval(r#"tw.replace("abc", [["ab", "1"], ["abc", "2"]])"#), + "1c" + ); + assert_eq!( + eval(r#"tw.replace("abc", [["abc", "2"], ["ab", "1"]])"#), + "2" + ); + // 不同位置:靠左的,不管规则的先后 + assert_eq!( + eval(r#"tw.replace("abc", [["bc", "1"], ["ab", "2"]])"#), + "2c" + ); + // 正则和字符串一起比 + assert_eq!(eval(r#"tw.replace("a1", [[/\d/, '#'], ["a1", "x"]])"#), "x"); + // Map 也行 + assert_eq!( + eval(r#"tw.replace("cat dog", new Map([["cat", "dog"], ["dog", "cat"]]))"#), + "dog cat" + ); + // 别的可迭代对象 + assert_eq!( + eval(r#"tw.replace("ab", (function* () { yield ["a", "1"]; yield ["b", "2"]; })())"#), + "12" + ); + assert_eq!(eval(r#"tw.replace("ab", [])"#), "ab"); +} + +#[test] +fn replace_leaves_placeholders_whole() { + // 占位符整个跳过:里面的字不匹配,命中也不跨过它 + assert_eq!( + eval( + r#"tw.replace("key <> and <>", [["SECRET", "x"], ["_1", "y"], ["<<", "«"], [/\d+>>/, "?"], ["> and <", "|"]])"# + ), + "key <> and <>" + ); + assert_eq!( + eval(r#"tw.replace("a<>b", [["ab", "X"], [/a.*b/s, "Y"]])"#), + "a<>b" + ); + // 两个占位符之间的每一节是一段单独的文字:^、$ 落在占位符旁边 + assert_eq!( + eval(r#"tw.replace("ab<>ab", [[/^a/, "^"], [/b$/, "$$"]])"#), + "^$<>^$" + ); + // 不像占位符的照常换 + assert_eq!( + eval(r#"tw.replace("<> <", [["<<", "«"]])"#), + "«TW_x>> «TW_SECRET_1>" + ); +} + +#[test] +fn replace_rejects_bad_rules() { + for (rules, msg) in [ + ( + "'a'", + "tw.replace: the rules must be an array or a Map of [find, replacement] pairs, not a string", + ), + ( + "null", + "tw.replace: the rules must be an array or a Map of [find, replacement] pairs, not null", + ), + ( + "[['a']]", + "tw.replace: rule 1 is not a [find, replacement] pair", + ), + ( + "[['a', 'b'], 'c']", + "tw.replace: rule 2 is not a [find, replacement] pair", + ), + ( + "[['', 'b']]", + "tw.replace: rule 1 looks for an empty string", + ), + ( + "[[1, 'b']]", + "tw.replace: rule 1 looks for a number; use a string or a regular expression", + ), + ( + "[[/a*/, 'b']]", + "tw.replace: rule 1 can match an empty string; a rule has to match at least one character", + ), + ( + "[[/(?:)/, 'b']]", + "tw.replace: rule 1 can match an empty string; a rule has to match at least one character", + ), + ( + "[[/a/y, 'b']]", + "tw.replace: rule 1 has the y flag, which a rule cannot use", + ), + ( + "[['a', 1]]", + "tw.replace: rule 1 replaces with a number; use a string or a function", + ), + ( + "[['a', null]]", + "tw.replace: rule 1 replaces with null; use a string or a function", + ), + ] { + assert_eq!( + eval_err(&format!("tw.replace('abc', {rules})")), + format!("TypeError: {msg}"), + "{rules}" + ); + } + assert_eq!( + eval_err("tw.replace(5, [])"), + "TypeError: tw.replace: the text must be a string, not a number" + ); + // 只在某些位置匹配空串的(\b、前瞻):碰到时报错 + assert_eq!( + eval_err(r#"tw.replace("a b", [[/\b/, "|"]])"#), + "TypeError: tw.replace: rule 1 matched an empty string; a rule has to match at least one character" + ); + // 替换函数抛的错原样抛给插件 + assert_eq!( + eval_err(r#"tw.replace("a", [["a", () => { throw new RangeError("mine"); }]])"#), + "RangeError: mine" + ); +} + +#[test] +fn replace_handles_a_large_text_quickly() { + // 1 MB 的文字、几条规则:远在请求钩子的 CPU 上限之内 + let inv = run(r#" + const text = "the widget costs 12 dollars; ".repeat(36000); + const out = tw.replace(text, [["widget", "gadget"], [/\d+/, '#'], ["dollars", "USD"]]); + req.system = out.length + "|" + out.slice(0, 22); + "#); + assert_eq!(changed(&inv)["system"], "864000|the gadget costs # USD"); +} + +// ── lines / pairs ──────────────────────────────────────────────── + +#[test] +fn lines_splits_trims_and_drops_empty_lines() { + assert_eq!( + eval(r#"tw.lines(" a \r\n\n b\t\n\r\n c")"#), + json!(["a", "b", "c"]) + ); + assert_eq!(eval(r#"tw.lines("")"#), json!([])); + assert_eq!( + eval(r#"Array.isArray(tw.lines("a")) && tw.lines("a").map((x) => x + "!")[0]"#), + "a!" + ); + assert_eq!( + eval_err("tw.lines(undefined)"), + "TypeError: tw.lines: the text must be a string, not undefined" + ); +} + +#[test] +fn pairs_splits_each_line_at_the_first_separator() { + assert_eq!( + eval(r#"tw.pairs("colour => color\n\n a=>b=>c \r\nx =>")"#), + json!([["colour", "color"], ["a", "b=>c"], ["x", ""]]) + ); + assert_eq!( + eval(r#"tw.pairs("a: b\nc:d", ":")"#), + json!([["a", "b"], ["c", "d"]]) + ); + // 格式不对的行:报错,说出第几行(空行也算行号) + assert_eq!( + eval_err(r#"tw.pairs("a => b\n\nno separator")"#), + r#"Error: tw.pairs: line 3 has no "=>""# + ); + assert_eq!( + eval_err(r#"tw.pairs("a => b\n => c")"#), + r#"Error: tw.pairs: line 2 has nothing before "=>""# + ); + assert_eq!( + eval_err(r#"tw.pairs("a", "")"#), + "TypeError: tw.pairs: the separator must not be empty" + ); + assert_eq!( + eval_err(r#"tw.pairs("a", 1)"#), + "TypeError: tw.pairs: the separator must be a string, not a number" + ); + assert_eq!( + eval_err(r#"tw.pairs(1)"#), + "TypeError: tw.pairs: the text must be a string, not a number" + ); + // 和 replace 连起来用 + assert_eq!( + eval(r#"tw.replace("the colour", tw.pairs("colour => color"))"#), + "the color" + ); +} + +// ── 插件改了内建函数 ───────────────────────────────────────────── + +#[test] +fn tw_keeps_working_after_the_plugin_tampers_with_built_ins() { + // 逐段模式的插件:tw.streamReplace 走扣住文字的那条路 + let p = load( + r#"export const manifest = { name: "t", api: 1, permissions: ["system", "reply.text"], reply: "stream" }; + export const { onReplyText, onReplyTextEnd } = tw.streamReplace([]); + export function onRequest(req) { + const stringify = JSON.stringify; + String.prototype.indexOf = () => { throw new Error("tampered indexOf"); }; + String.prototype.slice = () => "tampered"; + String.prototype.trim = () => "tampered"; + String.prototype.charCodeAt = () => 60; + RegExp.prototype.exec = () => { throw new Error("tampered exec"); }; + Array.prototype.join = () => "tampered"; + Array.prototype.push = () => { throw new Error("tampered push"); }; + Object.defineProperty(Array.prototype, "0", { set() { throw new Error("tampered setter"); }, configurable: true }); + Object.prototype.get = function () { return "tampered"; }; + Map.prototype.get = () => { throw new Error("tampered map"); }; + Set.prototype.has = () => { throw new Error("tampered set"); }; + Reflect.apply = () => { throw new Error("tampered apply"); }; + Object.defineProperty = () => { throw new Error("tampered defineProperty"); }; + JSON.stringify = () => "tampered"; + const results = { + replace: tw.replace("a <> b1", [["a", "A"], [/b(\d)/, "B$1"]]), + lines: tw.lines(" x \n y "), + pairs: tw.pairs("p => q"), + map: tw.mapStrings({ k: ["v"] }, (s) => s + "!"), + }; + const h = tw.streamReplace([["ab", "X"]]); + results.stream = h.onReplyText("xa", {}) + h.onReplyText("b", {}) + h.onReplyTextEnd({}); + tw.appendSystem(req, "added"); + delete Array.prototype[0]; + delete Object.prototype.get; + req.system += "|" + stringify(results); + }"#, + ); + let inv = p.on_request( + json!({ "format": "anthropic", "model": "m", "system": "s" }), + ctx(json!({})), + ); + // JSON.stringify 换掉了也没关系:桥比对、交出结果用的是它自己拿住的那一份 + let system = changed(&inv)["system"].as_str().unwrap().to_string(); + let (head, json) = system.split_once('|').unwrap(); + assert_eq!(head, "s\n\nadded"); + assert_eq!( + serde_json::from_str::(json).unwrap(), + json!({ + "replace": "A <> B1", + "lines": ["x", "y"], + "pairs": [["p", "q"]], + "map": { "k": ["v!"] }, + "stream": "xX", + }) + ); +} + +// ── streamReplace ──────────────────────────────────────────────── + +const STREAM_HEAD: &str = r#"export const manifest = { name: "s", api: 1, permissions: ["reply.text"], reply: "stream" }; +"#; + +fn stream_plugin(body: &str) -> Plugin { + load(&format!("{STREAM_HEAD}{body}")) +} + +/// 一块文字逐段交进去,返回每段交出来的(`None` 记成原样)和块结束时补的 +fn feed(r: &mut Reply, lane: u64, pieces: &[&str]) -> Vec { + let mut out: Vec = pieces + .iter() + .map(|p| match r.on_text(lane, p).result { + Ok(Some(s)) => s, + Ok(None) => p.to_string(), + Err(e) => panic!("onReplyText failed: {e:?}"), + }) + .collect(); + match r.on_text_end(lane).result { + Ok(Some(s)) => out.push(s), + Ok(None) => out.push(String::new()), + Err(e) => panic!("onReplyTextEnd failed: {e:?}"), + } + out +} + +#[test] +fn stream_replace_with_strings_holds_only_a_possible_start() { + let p = stream_plugin( + r#"export const { onReplyText, onReplyTextEnd } = tw.streamReplace([["widget", "gadget"], ["cat", "dog"]]);"#, + ); + let mut r = p.reply(ctx(json!({}))).unwrap(); + // 「wid」可能是 widget 的开头:先扣着;「ca」同理;别的马上放出 + assert_eq!( + feed(&mut r, 0, &["a wid", "get and a ca", "t; done", "!"]), + ["a ", "gadget and a ", "dog; done", "!", ""] + ); + // 扣着的到块结束放出 + assert_eq!(feed(&mut r, 0, &["the wid"]), ["the ", "wid"]); +} + +#[test] +fn stream_replace_with_a_regex_holds_max_match_characters() { + let p = stream_plugin( + r#"export const { onReplyText, onReplyTextEnd } = tw.streamReplace([[/\d{3,}/, '#']], { maxMatch: 4 });"#, + ); + let mut r = p.reply(ctx(json!({}))).unwrap(); + assert_eq!( + feed(&mut r, 0, &["abcdefgh", "12", "34 x"]), + ["abcd", "ef", "gh#", " x"] + ); +} + +#[test] +fn stream_replace_options_are_checked() { + for (opts, msg) in [ + ( + "{ maxMatch: 0 }", + "maxMatch must be a positive integer, not 0", + ), + ( + "{ maxMatch: 2.5 }", + "maxMatch must be a positive integer, not 2.5", + ), + ( + "{ maxMatch: '9' }", + "maxMatch must be a positive integer, not a string", + ), + ("5", "the options must be an object, not a number"), + ] { + let e = load_err(&format!( + "{STREAM_HEAD}export const {{ onReplyText, onReplyTextEnd }} = tw.streamReplace([['a', 'b']], {opts});" + )); + assert!( + e.to_string().contains(&format!("tw.streamReplace: {msg}")), + "{opts}: {e}" + ); + } + // 规则写错了:加载时就报 + let e = load_err(&format!( + "{STREAM_HEAD}export const {{ onReplyText, onReplyTextEnd }} = tw.streamReplace([['', 'b']]);" + )); + assert!( + e.to_string() + .contains("tw.streamReplace: rule 1 looks for an empty string"), + "{e}" + ); +} + +#[test] +fn stream_replace_reads_rules_once_per_block() { + let p = stream_plugin( + r#"export const { onReplyText, onReplyTextEnd } = tw.streamReplace((ctx) => { + console.log("rules"); + return tw.pairs(ctx.settings.terms); + });"#, + ); + let mut r = p + .reply(ctx(json!({ "terms": "colour => color\nfavour => favor" }))) + .unwrap(); + let first = r.on_text(0, "my favourite col"); + assert_eq!(first.logs.len(), 1); + let second = r.on_text(0, "our"); + assert!(second.logs.is_empty()); + let end = r.on_text_end(0); + assert_eq!( + format!( + "{}{}{}", + first.result.unwrap().unwrap(), + second.result.unwrap().unwrap_or_default(), + end.result.unwrap().unwrap_or_default() + ), + "my favorite color" + ); + // 下一块再读一次 + assert_eq!(r.on_text(0, "x").logs.len(), 1); + // 一个字都没收到的块结束:什么都不补,也不读规则 + let mut fresh = p.reply(ctx(json!({ "terms": "a => b" }))).unwrap(); + let end = fresh.on_text_end(3); + assert_eq!(end.result, Ok(Some(String::new()))); + assert!(end.logs.is_empty()); +} + +#[test] +fn stream_replace_never_splits_a_placeholder() { + let p = stream_plugin( + r#"export const { onReplyText, onReplyTextEnd } = tw.streamReplace([["SECRET", "x"], ["<<", "«"], ["key", "KEY"]]);"#, + ); + let mut r = p.reply(ctx(json!({}))).unwrap(); + let text = "my key <> ok << and < = text.chars().map(String::from).collect(); + let pieces: Vec<&str> = pieces.iter().map(String::as_str).collect(); + let out = feed(&mut r, 0, &pieces); + assert_eq!(out.concat(), "my KEY <> ok « and «TW_ab"); + // 占位符整个在某一段里 + assert!( + out.iter().any(|o| o.contains("<>")), + "{out:?}" + ); +} + +#[test] +fn stream_replace_keeps_interleaved_blocks_apart() { + let p = stream_plugin( + r#"export const { onReplyText, onReplyTextEnd } = tw.streamReplace([["hello", "HI"], [/w\w{3}d/, "W"]], { maxMatch: 8 });"#, + ); + let mut r = p.reply(ctx(json!({}))).unwrap(); + let big = 1u64 << 40; + let mut out: std::collections::BTreeMap = Default::default(); + let mut push = |lane: u64, inv: Invocation>, given: &str| { + let s = match inv.result { + Ok(Some(s)) => s, + Ok(None) => given.to_string(), + Err(e) => panic!("{e:?}"), + }; + out.entry(lane).or_default().push_str(&s); + }; + // 两块交替送段:每块各自扣着自己的尾巴 + for (lane, piece) in [ + (0, "say hel"), + (big, "hello wo"), + (0, "lo wor"), + (big, "rld, hel"), + (0, "ld"), + (big, "l"), + ] { + let inv = r.on_text(lane, piece); + push(lane, inv, piece); + } + let inv = r.on_text_end(big); + push(big, inv, ""); + let inv = r.on_text_end(0); + push(0, inv, ""); + assert_eq!(out[&0], "say HI W"); + assert_eq!(out[&big], "HI W, hell"); +} + +#[test] +fn stream_replace_needs_its_end_hook_in_stream_mode() { + let p = stream_plugin(r#"export const { onReplyText } = tw.streamReplace([["a", "b"]]);"#); + let mut r = p.reply(ctx(json!({}))).unwrap(); + match r.on_text(0, "abc").result { + Err(RunError::Threw { message, .. }) => assert_eq!( + message, + "Error: tw.streamReplace: export its onReplyTextEnd as well; without it the text held back at the end of a block is lost" + ), + other => panic!("{other:?}"), + } +} + +#[test] +fn stream_replace_in_block_mode_replaces_each_whole_block() { + let p = load( + r#"export const manifest = { name: "b", api: 1, permissions: ["reply.text"] }; + export const { onReplyText } = tw.streamReplace([["widget", "gadget"], [/\d+/, '#']]);"#, + ); + let mut r = p.reply(ctx(json!({}))).unwrap(); + assert_eq!( + r.on_text(0, "a widget for 12345").result, + Ok(Some("a gadget for #".into())) + ); + assert_eq!(r.on_text(1, "nothing").result, Ok(None)); +} + +// ── 切分性质 ───────────────────────────────────────────────────── + +/// 随机文字、随机规则:参照实现 == 整段 tw.replace == 任意切分后的逐段结果;逐段交出的 +/// 每一段都不切开占位符和代理对。见语料 `helpers-split.js` +#[test] +fn stream_replace_matches_whole_replace_under_any_split() { + let p = common::load_roomy("helpers-split"); + for seed in [1u32, 0x2545_f491, 0x9e37_79b9] { + let inv = p.on_request( + json!({ "format": "anthropic", "model": "m", "system": "" }), + ctx(json!({ "seed": seed, "cases": 1500, "splits": 4 })), + ); + let v = changed(&inv); + let report: Value = serde_json::from_str(v["system"].as_str().unwrap()).unwrap(); + assert_eq!( + report["failures"], + json!([]), + "seed {seed}: {}", + serde_json::to_string_pretty(&report["failures"]).unwrap() + ); + assert!(report["checked"].as_u64().unwrap() >= 5000, "{report}"); + } +} + +/// 多块交错:两个 lane 交替送段(随机切开),每块的结果和整块模式(一次交一整块)的 +/// 结果一样 —— 各块的缓冲不串 +#[test] +fn interleaved_blocks_come_out_as_if_each_were_sent_alone() { + const RULES: &str = r##"[["widget", "gadget"], ["<<", "«"], ["SECRET", "x"], [/(?<=a)b/, "B"], [/c$/m, "C"], [/\d{2,4}/, "#"], [/😀/u, ":)"]]"##; + let stream = stream_plugin(&format!( + "export const {{ onReplyText, onReplyTextEnd }} = tw.streamReplace({RULES}, {{ maxMatch: 6 }});" + )); + let block = load(&format!( + r#"export const manifest = {{ name: "b", api: 1, permissions: ["reply.text"] }}; + export const {{ onReplyText }} = tw.streamReplace({RULES});"# + )); + let words = [ + "a", + "b", + "c", + "ab", + "\n", + " ", + "1", + "23", + "wid", + "get", + "widget", + "<", + "<<", + "😀", + "<>", + "<> 17; + seed ^= seed << 5; + (seed as usize) % n + }; + let lanes = [0u64, 7, 1 << 40]; + for case in 0..200 { + let mut r = stream.reply(ctx(json!({}))).unwrap(); + let mut blocks = block.reply(ctx(json!({}))).unwrap(); + // 每块一段文字,切成几段 + let texts: Vec = lanes + .iter() + .map(|_| (0..rand(25)).map(|_| words[rand(words.len())]).collect()) + .collect(); + let mut queues: Vec> = texts + .iter() + .map(|t| { + let chars: Vec = t.chars().collect(); + let mut pieces = Vec::new(); + let mut cur = String::new(); + for c in chars { + cur.push(c); + if rand(3) == 0 { + pieces.push(std::mem::take(&mut cur)); + } + } + pieces.push(cur); + pieces.reverse(); + pieces + }) + .collect(); + let mut got = vec![String::new(); lanes.len()]; + // 随机挑一块送下一段,送完的块随机时候结束 + while queues.iter().any(|q| !q.is_empty()) { + let i = rand(lanes.len()); + let Some(piece) = queues[i].pop() else { + continue; + }; + match r.on_text(lanes[i], &piece).result { + Ok(Some(s)) => got[i].push_str(&s), + Ok(None) => got[i].push_str(&piece), + Err(e) => panic!("case {case}: {e:?}"), + } + if queues[i].is_empty() { + got[i].push_str(&r.on_text_end(lanes[i]).result.unwrap().unwrap_or_default()); + } + } + for (i, t) in texts.iter().enumerate() { + let want = match blocks.on_text(lanes[i], t).result { + Ok(Some(s)) => s, + Ok(None) => t.clone(), + Err(e) => panic!("case {case}: {e:?}"), + }; + assert_eq!(got[i], want, "case {case}, lane {}: {t:?}", lanes[i]); + } + } +} diff --git a/crates/tw-plugin/tests/in_place.rs b/crates/tw-plugin/tests/in_place.rs new file mode 100644 index 00000000..531a9cca --- /dev/null +++ b/crates/tw-plugin/tests/in_place.rs @@ -0,0 +1,274 @@ +//! 修改传入的对象即生效:`onRequest`、`onToolCall` 不返回(`undefined`)时,以钩子结束时 +//! 那个对象的样子为准。 +//! +//! 桥把它序列化,和宿主给的原文比:一字不差就是没改,不交给宿主;不一样就当钩子返回了 +//! 它,交给宿主照常解析、核对(改了又改回、只是键的先后变了,宿主照样判成没改)。返回 +//! 值的规矩照旧:返回对象就以返回的为准,`null`、数组、`reject()` 都不变。 + +use std::sync::OnceLock; + +use serde_json::{Value, json}; +use tw_plugin::*; + +fn rt() -> &'static Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| Runtime::new(Limits::default()).expect("runtime")) +} + +fn load(src: &str) -> Plugin { + match rt().load(src.as_bytes()) { + Ok(p) => p, + Err(e) => panic!("load failed: {e:?}\n{src}"), + } +} + +fn ctx() -> Value { + json!({ "client": null, "model": "m", "format": "anthropic", "upstream": "u", "settings": {} }) +} + +fn view() -> Value { + json!({ + "format": "anthropic", + "model": "m", + "system": "be brief", + "messages": [ + { "key": "m0", "role": "user", "parts": [ { "key": "p0", "type": "text", "text": "hi" } ] } + ], + // 1.0 进出 JS 变成 1、大整数丢精度:序列化出来和原文不一样,但不算改动 + "params": { "model": "m", "max_tokens": 1024, "temperature": 1.0, "seed": 12345678901234567890u64 } + }) +} + +fn request(body: &str) -> Invocation { + load(&format!( + r#"export const manifest = {{ name: "t", api: 1, permissions: ["system", "messages", "params"] }}; + export async function onRequest(req, ctx) {{ {body} }}"# + )) + .on_request(view(), ctx()) +} + +fn changed(inv: &Invocation) -> Value { + match &inv.result { + Ok(RequestOutcome::Changed(v)) => v.clone(), + other => panic!("expected Changed, got {other:?} (logs {:?})", inv.logs), + } +} + +#[test] +fn an_edited_request_counts_without_a_return() { + let v = changed(&request( + "req.system += ' and kind'; req.params.max_tokens = 8;", + )); + assert_eq!(v["system"], "be brief and kind"); + assert_eq!(v["params"]["max_tokens"], 8); + // 别的照原样(数字按 JS 的眼光不变) + assert_eq!(v["messages"], view()["messages"]); +} + +#[test] +fn an_untouched_or_restored_request_is_unchanged() { + for body in [ + "", + "return undefined;", + "const s = req.system; req.system = 'other'; req.system = s;", + // 键的先后变了:不算改 + "const s = req.system; delete req.system; req.system = s;", + "req.messages.push({ role: 'user', parts: [] }); req.messages.pop();", + // 读一读、在别处造一份改过的:传入的那份没动 + "const copy = JSON.parse(JSON.stringify(req)); copy.system = 'x';", + ] { + assert_eq!( + request(body).result, + Ok(RequestOutcome::Unchanged), + "{body}" + ); + } +} + +#[test] +fn a_returned_object_still_wins() { + // 返回传入的那一个:和以前一样 + assert_eq!( + changed(&request("req.system = 'x'; return req;"))["system"], + "x" + ); + // 返回另一个对象:以返回的为准,传入的那一份改了也不算 + let v = changed(&request( + "const other = JSON.parse(JSON.stringify(req)); other.system = 'returned'; req.system = 'ignored'; return other;", + )); + assert_eq!(v["system"], "returned"); + // 返回原样的另一份:没改 + assert_eq!( + request("req.system = 'ignored'; const o = JSON.parse(JSON.stringify(req)); o.system = 'be brief'; return o;").result, + Ok(RequestOutcome::Unchanged) + ); +} + +#[test] +fn async_hooks_count_the_object_as_it_is_when_they_settle() { + let v = changed(&request( + "await null; req.system = 'later'; await Promise.resolve(); req.params.max_tokens = 1;", + )); + assert_eq!(v["system"], "later"); + assert_eq!(v["params"]["max_tokens"], 1); + // 先改、await 之后改回 + assert_eq!( + request("req.system = 'x'; await null; req.system = 'be brief';").result, + Ok(RequestOutcome::Unchanged) + ); +} + +#[test] +fn reject_and_throw_beat_an_edit() { + assert_eq!( + request("req.system = 'x'; reject('no');").result, + Ok(RequestOutcome::Rejected("no".into())) + ); + assert_eq!( + request("req.system = 'x'; await null; reject('later');").result, + Ok(RequestOutcome::Rejected("later".into())) + ); + match request("req.system = 'x'; throw new Error('boom');").result { + Err(RunError::Threw { message, .. }) => assert_eq!(message, "Error: boom"), + other => panic!("{other:?}"), + } +} + +#[test] +fn an_edit_that_cannot_be_written_as_json_is_an_error() { + match request("req.params.big = 1n;").result { + Err(RunError::BadOutput(m)) => assert!( + m.starts_with( + "onRequest changed its argument into something that cannot be turned into JSON" + ), + "{m}" + ), + other => panic!("{other:?}"), + } + match request("req.self = req;").result { + Err(RunError::BadOutput(m)) => assert!(m.contains("cannot be turned into JSON"), "{m}"), + other => panic!("{other:?}"), + } +} + +// ── onToolCall ─────────────────────────────────────────────────── + +fn tool_plugin() -> Plugin { + load( + r#"export const manifest = { name: "t", api: 1, permissions: ["reply.tool_calls"] }; + export async function onToolCall(call) { + switch (call.name) { + case "edit": call.input.path = "C:/x"; return; + case "same": return; + case "restore": { const p = call.input.path; call.input.path = "y"; call.input.path = p; return; } + case "reorder": { const i = call.input; delete call.input; call.input = i; return; } + case "later": await null; call.input.path = "later"; return; + case "returned": call.input.path = "ignored"; return { name: "returned", input: {} }; + case "self": call.input.path = "self"; return call; + case "drop": call.input.path = "ignored"; return null; + case "split": call.input.path = "ignored"; return [{ name: "a", input: {} }]; + case "extra": call.extra = 1; return; + case "bigint": call.input.n = 1n; return; + case "noname": call.name = ""; return; + } + }"#, + ) +} + +fn call(name: &str) -> Value { + json!({ "id": "c1", "name": name, "input": { "path": "/mnt/c/x", "n": 1.0 } }) +} + +#[test] +fn an_edited_tool_call_counts_without_a_return() { + let p = tool_plugin(); + let mut r = p.reply(ctx()).unwrap(); + assert_eq!( + r.on_tool_call(call("edit")).result, + Ok(ToolCallOutcome::Replace(vec![ + json!({ "id": "c1", "name": "edit", "input": { "path": "C:/x", "n": 1 } }) + ])) + ); + assert_eq!( + r.on_tool_call(call("later")).result, + Ok(ToolCallOutcome::Replace(vec![ + json!({ "id": "c1", "name": "later", "input": { "path": "later", "n": 1 } }) + ])) + ); + // 没有 id 的调用(null)也一样 + assert_eq!( + r.on_tool_call(json!({ "id": null, "name": "edit", "input": { "path": "a" } })) + .result, + Ok(ToolCallOutcome::Replace(vec![ + json!({ "name": "edit", "input": { "path": "C:/x" } }) + ])) + ); +} + +#[test] +fn an_untouched_or_restored_tool_call_is_unchanged() { + let p = tool_plugin(); + let mut r = p.reply(ctx()).unwrap(); + for name in ["same", "restore", "reorder", "unknown"] { + assert_eq!( + r.on_tool_call(call(name)).result, + Ok(ToolCallOutcome::Unchanged), + "{name}" + ); + } +} + +#[test] +fn what_a_tool_call_hook_returns_still_wins() { + let p = tool_plugin(); + let mut r = p.reply(ctx()).unwrap(); + assert_eq!( + r.on_tool_call(call("returned")).result, + Ok(ToolCallOutcome::Replace(vec![ + json!({ "name": "returned", "input": {} }) + ])) + ); + assert_eq!( + r.on_tool_call(call("self")).result, + Ok(ToolCallOutcome::Replace(vec![ + json!({ "id": "c1", "name": "self", "input": { "path": "self", "n": 1 } }) + ])) + ); + assert_eq!( + r.on_tool_call(call("drop")).result, + Ok(ToolCallOutcome::Drop) + ); + assert_eq!( + r.on_tool_call(call("split")).result, + Ok(ToolCallOutcome::Replace(vec![ + json!({ "name": "a", "input": {} }) + ])) + ); +} + +#[test] +fn an_edited_tool_call_is_checked_like_a_returned_one() { + let p = tool_plugin(); + let mut r = p.reply(ctx()).unwrap(); + for (name, want) in [ + ("extra", "unknown field `extra`"), + ("bigint", "cannot be turned into JSON"), + ("noname", "needs a non-empty string `name`"), + ] { + match r.on_tool_call(call(name)).result { + Err(RunError::BadOutput(m)) => assert!(m.contains(want), "{name}: {m}"), + other => panic!("{name}: {other:?}"), + } + } +} + +#[test] +fn reply_text_hooks_are_unaffected() { + // 字符串改不了:不返回就是没改 + let p = load( + r#"export const manifest = { name: "t", api: 1, permissions: ["reply.text"] }; + export function onReplyText(t) { t = t.toUpperCase(); }"#, + ); + let mut r = p.reply(ctx()).unwrap(); + assert_eq!(r.on_text(0, "abc").result, Ok(None)); +} diff --git a/crates/tw-plugin/tests/isolation.rs b/crates/tw-plugin/tests/isolation.rs index cb4e7a26..fa1c26ff 100644 --- a/crates/tw-plugin/tests/isolation.rs +++ b/crates/tw-plugin/tests/isolation.rs @@ -1,7 +1,7 @@ //! 沙箱里有什么、实例之间留下什么(I2、I3)。 //! -//! - 全局只有 ECMAScript 标准内置、`console` 和 `reject`;连网、读文件、拿环境 -//! 变量、加载模块的东西一样都没有。 +//! - 全局只有 ECMAScript 标准内置、`console`、`reject` 和辅助函数 `tw`;连网、读文件、 +//! 拿环境变量、加载模块的东西一样都没有。 //! - 每次请求钩子都是新实例;一个回答一个实例,回答之间、插件之间什么都不共享。 //! - `ctx` 冻结,改不动。 //! - 时钟是真的,随机数每个实例不同(快照冻住的种子会让每个实例一模一样)。 @@ -16,7 +16,7 @@ use serde_json::{Value, json}; use tw_plugin::{RequestOutcome, RunError}; /// ECMAScript 2026 规范里全局对象上的属性(第 19 章,含附录 B 的 escape、unescape), -/// 外加插件的两个:`console`、`reject`。引擎没实现的可以缺,**多出来的一个都不行** +/// 外加插件的三个:`console`、`reject`、`tw`。引擎没实现的可以缺,**多出来的一个都不行** const ALLOWED_GLOBALS: &[&str] = &[ // 值属性 "globalThis", @@ -89,10 +89,11 @@ const ALLOWED_GLOBALS: &[&str] = &[ // 插件的 "console", "reject", + "tw", ]; #[test] -fn the_global_object_holds_only_standard_built_ins_console_and_reject() { +fn the_global_object_holds_only_standard_built_ins_console_reject_and_tw() { let p = load("globals"); let names: Vec = serde_json::from_str(&system_of(&request(&p, json!({})).result)).unwrap(); @@ -107,7 +108,7 @@ fn the_global_object_holds_only_standard_built_ins_console_and_reject() { extra.is_empty(), "globals outside the allowed set: {extra:?}" ); - for must in ["console", "reject", "JSON", "Date", "Math", "RegExp"] { + for must in ["console", "reject", "tw", "JSON", "Date", "Math", "RegExp"] { assert!( names.iter().any(|n| n == must), "{must} is missing: {names:?}" diff --git a/crates/tw-plugin/tests/runtime.rs b/crates/tw-plugin/tests/runtime.rs index 18379417..dec82fc2 100644 --- a/crates/tw-plugin/tests/runtime.rs +++ b/crates/tw-plugin/tests/runtime.rs @@ -278,10 +278,10 @@ fn only_standard_globals_exist() { let inv = run( r#"for (const n of ["fetch", "require", "std", "os", "process", "queueMicrotask", "setTimeout", "setInterval", "performance", "navigator", "XMLHttpRequest", "WebAssembly", "Deno", "Bun", - "print", "gc", "scriptArgs", "atob", "btoa", "__tw_log", "module", "exports"]) { + "print", "gc", "scriptArgs", "atob", "btoa", "__tw_log", "__tw_helpers", "module", "exports"]) { if (typeof globalThis[n] !== "undefined") throw new Error(n + " is defined"); } - if (typeof console.log !== "function" || typeof reject !== "function") throw new Error("missing"); + if (typeof console.log !== "function" || typeof reject !== "function" || typeof tw.replace !== "function") throw new Error("missing"); return undefined;"#, ); assert_eq!( @@ -360,11 +360,11 @@ fn state_persists_within_one_reply() { export function onReplyText(text) { n++; return text + "#" + n; }"##, ); let mut r = p.reply(ctx()).unwrap(); - assert_eq!(r.on_text("a").result, Ok(Some("a#1".into()))); - assert_eq!(r.on_text("b").result, Ok(Some("b#2".into()))); + assert_eq!(r.on_text(0, "a").result, Ok(Some("a#1".into()))); + assert_eq!(r.on_text(0, "b").result, Ok(Some("b#2".into()))); let mut r2 = p.reply(ctx()).unwrap(); - assert_eq!(r2.on_text("c").result, Ok(Some("c#1".into()))); - assert_eq!(r.on_text("d").result, Ok(Some("d#3".into()))); + assert_eq!(r2.on_text(0, "c").result, Ok(Some("c#1".into()))); + assert_eq!(r.on_text(0, "d").result, Ok(Some("d#3".into()))); } // ── 回答钩子 ───────────────────────────────────────────────────── @@ -380,10 +380,10 @@ fn stream_mode_holds_text_and_flushes_it_at_the_end() { assert_eq!(p.manifest().reply_mode, ReplyMode::Stream); assert!(p.manifest().hooks.reply_text_end); let mut r = p.reply(ctx()).unwrap(); - assert_eq!(r.on_text("abc").result, Ok(Some(String::new()))); - assert_eq!(r.on_text("def").result, Ok(Some("ABCDEF".into()))); - assert_eq!(r.on_text("gh").result, Ok(Some(String::new()))); - assert_eq!(r.on_text_end().result, Ok(Some("gh".into()))); + assert_eq!(r.on_text(0, "abc").result, Ok(Some(String::new()))); + assert_eq!(r.on_text(0, "def").result, Ok(Some("ABCDEF".into()))); + assert_eq!(r.on_text(0, "gh").result, Ok(Some(String::new()))); + assert_eq!(r.on_text_end(0).result, Ok(Some("gh".into()))); } #[test] @@ -400,13 +400,13 @@ fn reply_text_output_is_validated_and_made_well_formed() { ); let mut r = p.reply(ctx()).unwrap(); assert!( - matches!(r.on_text("num").result, Err(RunError::BadOutput(m)) if m.contains("must return a string")) + matches!(r.on_text(0, "num").result, Err(RunError::BadOutput(m)) if m.contains("must return a string")) ); // 切半的代理对换成 U+FFFD,不让整段回答出错 - assert_eq!(r.on_text("split").result, Ok(Some("a\u{fffd}".into()))); - assert_eq!(r.on_text("same").result, Ok(None)); - assert_eq!(r.on_text("other").result, Ok(None)); - let m = match r.on_text("reject").result { + assert_eq!(r.on_text(0, "split").result, Ok(Some("a\u{fffd}".into()))); + assert_eq!(r.on_text(0, "same").result, Ok(None)); + assert_eq!(r.on_text(0, "other").result, Ok(None)); + let m = match r.on_text(0, "reject").result { Err(RunError::Threw { message, .. }) => message, other => panic!("{other:?}"), }; @@ -478,8 +478,8 @@ fn tool_call_hooks_replace_drop_and_keep_calls() { fn hooks_a_plugin_does_not_export_are_no_ops() { let p = request("return req;"); let mut r = p.reply(ctx()).unwrap(); - assert_eq!(r.on_text("x").result, Ok(None)); - assert_eq!(r.on_text_end().result, Ok(None)); + assert_eq!(r.on_text(0, "x").result, Ok(None)); + assert_eq!(r.on_text_end(0).result, Ok(None)); assert_eq!( r.on_tool_call(json!({"name": "x", "input": {}})).result, Ok(ToolCallOutcome::Unchanged) @@ -560,8 +560,8 @@ fn a_giant_output_is_stopped_by_the_output_cap() { export function onReplyTextEnd() { return "y".repeat(2 << 20); }"#, ); let mut r = p.reply(ctx()).unwrap(); - assert_eq!(r.on_text("a").result, Ok(Some(String::new()))); - assert_eq!(r.on_text_end().result, Err(RunError::OutputLimit)); + assert_eq!(r.on_text(0, "a").result, Ok(Some(String::new()))); + assert_eq!(r.on_text_end(0).result, Err(RunError::OutputLimit)); } #[test] @@ -600,7 +600,7 @@ fn reply_calls_have_their_own_and_a_total_cpu_budget() { }"#, ); let mut r = p.reply(ctx()).unwrap(); - let inv = r.on_text("spin"); + let inv = r.on_text(0, "spin"); assert_eq!(inv.result, Err(RunError::CpuLimit)); assert!( inv.cpu >= limits.reply_call_cpu && inv.cpu < limits.reply_call_cpu + slack(), @@ -608,7 +608,7 @@ fn reply_calls_have_their_own_and_a_total_cpu_budget() { inv.cpu ); // 被打断过的实例不再用 - assert_eq!(r.on_text("0").result, Err(RunError::CpuLimit)); + assert_eq!(r.on_text(0, "0").result, Err(RunError::CpuLimit)); // 每次都没超单次的预算,但加起来超过一个回答的总预算。单次预算放宽到 // 远大于每次的用量:Windows 上量的是墙上时间,被抢占一下就可能先撞单次上限 @@ -632,7 +632,7 @@ fn reply_calls_have_their_own_and_a_total_cpu_budget() { let mut used = Duration::ZERO; let mut stopped = false; for _ in 0..1000 { - let inv = r.on_text("10"); + let inv = r.on_text(0, "10"); used += inv.cpu; if inv.result == Err(RunError::CpuLimit) { stopped = true; From 7a1eee220433803de181227a4df5b32ba02865cd Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:38:59 +0800 Subject: [PATCH 2/4] Count a stream-mode plugin's text as changed per block, not per held piece A stream-mode reply plugin that holds text back hands out pieces that differ from what it was given, so every such call counted as "changed" in the run record. tw.streamReplace with a regular expression holds the last characters of every piece, so nearly every answer was recorded as changed even when no rule matched. The gateway now compares, per text block, everything the plugin was given with everything it handed back (in placeholder form). Matched parts are dropped as they come; only the stretch that has not matched yet is kept, so a block is not stored twice. The block counts as one change if the two differ at the end. A plugin skipped after an error counts only if what it already handed out differs; text it was still holding goes on unchanged. Block mode keeps its per-block comparison. Also drop a stale comment in the guest's Cargo.toml that described files the repository no longer has. Co-Authored-By: Claude Opus 5.5 --- crates/tw-gateway/src/plugin/reply/mod.rs | 57 ++++++--- crates/tw-gateway/src/plugin/reply/tally.rs | 124 ++++++++++++++++++++ crates/tw-gateway/tests/plugins_helpers.rs | 61 ++++++++++ crates/tw-plugin/guest/Cargo.toml | 6 +- 4 files changed, 230 insertions(+), 18 deletions(-) create mode 100644 crates/tw-gateway/src/plugin/reply/tally.rs diff --git a/crates/tw-gateway/src/plugin/reply/mod.rs b/crates/tw-gateway/src/plugin/reply/mod.rs index 518890a1..b44807be 100644 --- a/crates/tw-gateway/src/plugin/reply/mod.rs +++ b/crates/tw-gateway/src/plugin/reply/mod.rs @@ -51,6 +51,9 @@ mod anthropic; mod chat; mod gemini; mod responses; +mod tally; + +use tally::Tally; /// 一块文字在这次回答里的编号。由各格式自己发 pub type Lane = u64; @@ -116,11 +119,15 @@ struct StageLane { /// 流式模式:插件交回来的结尾是半个占位符(它从占位符中间切开了交出的文字),先扣着, /// 和它交出的下一段拼起来再换回原值(见 [`Bridge::unfinished_from`]) unrevealed: String, + /// 流式模式:这一块里交给插件的和它交出的对到哪儿了。块结束时拼起来不一样才算改了 + /// 文字(只扣着不改的不算) + tally: Tally, } #[derive(Default, Clone, Copy)] struct Counts { text_calls: u64, + /// 改了文字的块(整块模式一块一次调用;流式模式按块比,见 [`Tally`]) text_changed: u64, tool_calls: u64, replaced: u64, @@ -395,6 +402,14 @@ impl Chain { self.stages[i].instance.is_some() } + /// 第 `i` 个流式插件出错被拿掉了,这一块到此为止:它交出的已经和交给它的对不上(改了、 + /// 多交了)才算改了文字;它扣着没交的原样往下走,不算 + fn count_dropped(&mut self, i: usize, l: &StageLane) { + if l.tally.changed_so_far() { + self.stages[i].counts.text_changed += 1; + } + } + /// 一段文字流过第 `i` 个插件,返回它此刻交出的(可能没有) async fn piece( &mut self, @@ -405,7 +420,10 @@ impl Chain { if !self.live(i) { // 在别的块里出错被拿掉的:这一块里它攒着的原样接在这一段前面放出来 return Ok(Some(match self.stages[i].lanes.remove(&lane) { - Some(l) => format!("{}{}{}{p}", l.unrevealed, l.pending, l.buf), + Some(l) => { + self.count_dropped(i, &l); + format!("{}{}{}{p}", l.unrevealed, l.pending, l.buf) + } None => p, })); } @@ -447,20 +465,20 @@ impl Chain { .is_some_and(|l| !l.unrevealed.is_empty()); match self.invoke(i, move |r| r.on_text(lane, &given)).await { Ok(None) if !carrying => { - self.stages[i] - .lanes - .entry(lane) - .or_default() - .pending - .clear(); + let l = self.stages[i].lanes.entry(lane).or_default(); + l.pending.clear(); + l.tally.pass(&hidden, &hidden); Ok(Some(send)) } // 没改就是交回了交给它的那样:前一段结尾扣着半个占位符时,接在它后面一起换 Ok(s) => { let s = s.unwrap_or_else(|| hidden.clone()); - if s != hidden { - self.stages[i].counts.text_changed += 1; - } + self.stages[i] + .lanes + .entry(lane) + .or_default() + .tally + .pass(&hidden, &s); let out = self.reveal(i, lane, &s, false); let l = self.stages[i].lanes.entry(lane).or_default(); // 交出了东西(哪怕整段都还扣在半个占位符里):收到的原文有了着落 @@ -475,6 +493,7 @@ impl Chain { self.fail(i, e)?; // 跳过:它交出的半个占位符、它扣着的、这一段、还没交给它的尾巴,原样往下走 let l = self.stages[i].lanes.remove(&lane).unwrap_or_default(); + self.count_dropped(i, &l); Ok(Some(format!( "{}{}{send}{}", l.unrevealed, l.pending, l.buf @@ -538,6 +557,7 @@ impl Chain { if !self.live(i) { // 半路被拿掉的:它攒着的原样放出来 if let Some(l) = self.stages[i].lanes.remove(&lane) { + self.count_dropped(i, &l); out.push(format!("{}{}{}", l.unrevealed, l.pending, l.buf)); } carry = out; @@ -584,21 +604,30 @@ impl Chain { match self.invoke(i, move |r| r.on_text_end(lane)).await { Ok(None) => {} Ok(Some(s)) => { - if !s.is_empty() { - self.stages[i].counts.text_changed += 1; - } + self.stages[i] + .lanes + .entry(lane) + .or_default() + .tally + .returned(&s); last = s; } Err(e) => { let l = self.stages[i].lanes.remove(&lane).unwrap_or_default(); self.fail(i, e)?; + self.count_dropped(i, &l); out.push(format!("{}{}", l.unrevealed, l.pending)); } } } // 扣着的半个占位符接上最后这一段换回去,凑不完整的原样放出去 out.push(self.reveal(i, lane, &last, true)); - self.stages[i].lanes.remove(&lane); + // 这一块到头了:交出的和交给它的拼起来不一样,才算改了一次文字 + if let Some(l) = self.stages[i].lanes.remove(&lane) + && l.tally.changed() + { + self.stages[i].counts.text_changed += 1; + } } } carry = out; diff --git a/crates/tw-gateway/src/plugin/reply/tally.rs b/crates/tw-gateway/src/plugin/reply/tally.rs new file mode 100644 index 00000000..779503bb --- /dev/null +++ b/crates/tw-gateway/src/plugin/reply/tally.rs @@ -0,0 +1,124 @@ +//! 流式插件在一块文字里改没改:交给它的全部文字拼起来,和它交出的全部文字拼起来(都是 +//! 占位符的写法)比。 +//! +//! 逐段插件扣字是常事(等一个词收全、等正则看清后文),只扣着、到块结束原样放出的不算 +//! 改了文字。比的时候**不把整块存两份**:两边对上的部分随到随丢,只留还没对上的那一截; +//! 一处对不上就记下「改了」,之后不再比。 + +/// 一块文字的账。 +#[derive(Debug, Default)] +pub(super) struct Tally { + /// 还没对上的那一截:`ahead` 为真时是插件多交出的,否则是交给它、它还没交回的 + gap: String, + ahead: bool, + differs: bool, +} + +impl Tally { + /// 交给插件一段、它交回一段(没改就是同一段) + pub(super) fn pass(&mut self, given: &str, returned: &str) { + // 最常见的:前面都对上了,这一段原样交回 + if self.gap.is_empty() && !self.differs && given == returned { + return; + } + self.push(given, false); + self.push(returned, true); + } + + /// 只有交回的(块结束时 `onReplyTextEnd` 补的) + pub(super) fn returned(&mut self, s: &str) { + self.push(s, true); + } + + fn push(&mut self, s: &str, out: bool) { + if self.differs || s.is_empty() { + return; + } + if self.gap.is_empty() || self.ahead == out { + self.gap.push_str(s); + self.ahead = out; + } else if self.gap.starts_with(s) { + // 另一边先到的那一截,这一段对上了它的开头 + self.gap.drain(..s.len()); + } else if s.starts_with(self.gap.as_str()) { + // 这一段把先到的那一截整个对上了,多出来的轮到这一边先到 + self.gap = s[self.gap.len()..].to_string(); + self.ahead = out; + } else { + self.differs = true; + self.gap = String::new(); + } + } + + /// 块结束了:两边拼起来不一样 + pub(super) fn changed(&self) -> bool { + self.differs || !self.gap.is_empty() + } + + /// 插件半路出错被拿掉了:它还没交回的那些原样往下走,不算它改的;它交出的和交给它的 + /// 对不上,或者多交出了没收到过的字,算 + pub(super) fn changed_so_far(&self) -> bool { + self.differs || (self.ahead && !self.gap.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(steps: &[(&str, &str)], end: &str) -> Tally { + let mut t = Tally::default(); + for (given, returned) in steps { + t.pass(given, returned); + } + t.returned(end); + t + } + + #[test] + fn holding_back_and_releasing_the_same_text_is_not_a_change() { + // 原样交回 + assert!(!run(&[("ab", "ab"), ("cd", "cd")], "").changed()); + // 扣到最后 + assert!(!run(&[("ab", ""), ("cd", "")], "abcd").changed()); + // 扣一截、补一截,切在别处 + assert!(!run(&[("abc", "a"), ("def", "bcde")], "f").changed()); + // 交出的比收到的切得碎 + assert!(!run(&[("abcdef", "ab"), ("", "cd"), ("g", "efg")], "").changed()); + } + + #[test] + fn any_difference_in_the_joined_text_is_a_change() { + assert!(run(&[("ab", "aB")], "").changed()); + assert!(run(&[("ab", ""), ("cd", "")], "ABCD").changed()); + // 少交了 + assert!(run(&[("ab", "a")], "").changed()); + // 多交了 + assert!(run(&[("ab", "ab")], "!").changed()); + // 顺序换了 + assert!(run(&[("ab", ""), ("cd", "cd")], "ab").changed()); + // 一处不同之后再对上也算改了 + assert!(run(&[("ab", "xb"), ("cd", "cd")], "").changed()); + } + + #[test] + fn text_handed_out_before_it_arrived_counts_once_it_arrives() { + // 插件先交出了还没收到的字(猜中了):拼起来一样就不算 + let mut t = Tally::default(); + t.pass("ab", "abc"); + assert!(t.changed_so_far()); + t.pass("cd", "d"); + assert!(!t.changed()); + } + + #[test] + fn a_plugin_dropped_halfway_counts_only_what_it_changed() { + // 扣着的原样往下走:不算它改的 + let mut t = Tally::default(); + t.pass("abc", "a"); + assert!(!t.changed_so_far()); + // 改过的:算 + t.pass("d", "X"); + assert!(t.changed_so_far()); + } +} diff --git a/crates/tw-gateway/tests/plugins_helpers.rs b/crates/tw-gateway/tests/plugins_helpers.rs index 19aa99b5..29b0a517 100644 --- a/crates/tw-gateway/tests/plugins_helpers.rs +++ b/crates/tw-gateway/tests/plugins_helpers.rs @@ -178,3 +178,64 @@ async fn stream_replace_leaves_secrets_whole_and_the_client_gets_its_key_back() } } } + +// ── 记录里的「改了文字」 ───────────────────────────────────────── +// +// 逐段插件扣字是常事:按块比,这一块里插件交出的全部文字拼起来和交给它的全部文字拼起来 +// 不一样,才算改了文字 + +const NEVER: &str = r#" +export const manifest = { name: "never", api: 1, permissions: ["reply.text"], reply: "stream" }; + +// 有正则:每段都扣着末尾 maxMatch 个字,块结束才放出 +export const { onReplyText, onReplyTextEnd } = tw.streamReplace([[/\d{3}/, '#'], ["zebra", "horse"]]); +"#; + +const HOARD: &str = r#" +export const manifest = { name: "hoard", api: 1, permissions: ["reply.text"], reply: "stream" }; + +let held = ""; +export function onReplyText(t) { held += t; return ""; } +export function onReplyTextEnd() { const out = held; held = ""; return out; } +"#; + +const SHOUT_AT_THE_END: &str = r#" +export const manifest = { name: "shout", api: 1, permissions: ["reply.text"], reply: "stream" }; + +let held = ""; +export function onReplyText(t) { held += t; return ""; } +export function onReplyTextEnd() { const out = held; held = ""; return out.toUpperCase(); } +"#; + +const SAME_BLOCK: &str = r#" +export const manifest = { name: "same", api: 1, permissions: ["reply.text"] }; + +export function onReplyText(t) { return t.replace("zebra", "horse"); } +"#; + +#[tokio::test] +async fn holding_text_back_without_changing_it_is_not_a_change() { + for stream in [true, false] { + for (id, source, text, want) in [ + // 规则一处都没命中 + ("never", NEVER, "plain words, 12 of them", "unchanged"), + ("never", NEVER, "a zebra and 123", "changed"), + // 整块扣到最后原样放出 + ("hoard", HOARD, "plain words", "unchanged"), + ("shout", SHOUT_AT_THE_END, "plain words", "changed"), + // 整块模式 + ("same", SAME_BLOCK, "plain words", "unchanged"), + ("same", SAME_BLOCK, "a zebra", "changed"), + ] { + let up = Upstream::start(vec![Answer::Text(text.into())]).await; + let gw = Gateway::start( + config(&up, Security::default()), + vec![Plug::new(id, source)], + ) + .await; + let r = gw.ask(body("hi", stream)).await; + assert_eq!(r.status, 200, "{id}/{stream}: {}", r.body); + assert_eq!(gw.outcomes(id), [want], "{id}/{stream}: {text}"); + } + } +} diff --git a/crates/tw-plugin/guest/Cargo.toml b/crates/tw-plugin/guest/Cargo.toml index 54b3e8a3..b3672be7 100644 --- a/crates/tw-plugin/guest/Cargo.toml +++ b/crates/tw-plugin/guest/Cargo.toml @@ -1,9 +1,7 @@ # 插件沙箱里跑的那个 QuickJS:编成 wasm32-unknown-unknown,没有 WASI。 # -# **它不是工作区成员**,平时的 `cargo build` / `cargo test` 不编它 —— 编它要一个 -# 能出 wasm 的 clang,而从源码编 core 只该需要 Rust。编好的 `qjs.wasm` 提交在 -# 旁边,`build.sh` 用钉死的工具链重编,CI 核对重编出来的和提交的逐字节相同。 -# 见 `build.sh` 和 `qjs.wasm.manifest`。 +# **它不是工作区成员**:tw-plugin 的 `build.rs` 另起一个 cargo 编它(C 那一半要一个 +# 能出 wasm 的 clang),再做成快照、预编译进二进制。见 `build.rs`。 [package] name = "tw-plugin-guest" version = "0.0.0" From 17723fa8e5f9844aaac04bec75912313ba500fd9 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:30:06 +0800 Subject: [PATCH 3/4] tw.replace: much faster on dense text A 1 MB request with about 108,000 matches took about 130 ms of sandbox CPU in tw.replace, close to the 200 ms request-hook limit and over it on slower machines. - All-string rules on a longer text: when no two find strings can overlap (neither contains the other and no end of one starts another), the single left-to-right scan picks exactly each rule's own leftmost non-overlapping occurrences, so every rule is applied with one native split/join. A replacement that shares no UTF-16 code unit with the find strings still to come is written directly; otherwise a private-use character absent from the text and the rules holds its place until all rules are done, so replaced text is never rescanned and a removal cannot join its neighbours into a new match. Duplicate find strings keep the first rule. Other rule sets fall back to the scan. - tw.streamReplace uses the same path when a section ends (at a placeholder or the end of the block) and, when no find string overlaps itself, for the part of a piece before the hold-back point. - The general scan calls captured builtins through bound functions instead of Reflect.apply wrappers, keeps each rule's next match on the rule itself and writes plain replacements inline. The check for a trailing partial placeholder looks for "<" natively. Sandbox CPU on a 1 MB text with about 108,000 matches, before / after: string rules 132 / 20 ms, mixed with a regular expression 147 / 112 ms, streamed in 64-character pieces 477 / 181 ms (strings) and 502 / 210 ms (mixed). The randomized split property test now also generates all-string rule sets on long texts with coarse splits, which covers the new path; the dense test no longer depends on machine speed. Co-Authored-By: Claude Opus 5.5 --- crates/tw-plugin/examples/latency.rs | 56 +++- crates/tw-plugin/src/helpers.js | 296 ++++++++++++++---- .../tw-plugin/tests/corpus/helpers-split.js | 39 ++- crates/tw-plugin/tests/helpers.rs | 57 +++- 4 files changed, 369 insertions(+), 79 deletions(-) diff --git a/crates/tw-plugin/examples/latency.rs b/crates/tw-plugin/examples/latency.rs index 32ad7224..003427c4 100644 --- a/crates/tw-plugin/examples/latency.rs +++ b/crates/tw-plugin/examples/latency.rs @@ -106,9 +106,62 @@ fn main() { }, ); } + + // tw.replace / tw.streamReplace 在 1 MB、命中很密的文字上(每 29 个字三处命中,约 10.8 + // 万处)。CPU 上限放宽,量的是要多久;减去只造文字的那一份就是替换本身 + let roomy = Runtime::new(Limits { + request_cpu: Duration::from_secs(30), + ..Limits::default() + }) + .expect("runtime"); + let dense = roomy + .load( + br##"export const manifest = { name: "dense", api: 1, permissions: ["system", "reply.text"], reply: "stream" }; + export const { onReplyText, onReplyTextEnd } = tw.streamReplace([]); + const RULES = { + strings: [["widget", "gadget"], ["12", "#"], ["dollars", "USD"]], + mixed: [["widget", "gadget"], [/\d+/, "#"], ["dollars", "USD"]], + }; + export function onRequest(req, ctx) { + const text = "the widget costs 12 dollars; ".repeat(36000); + const [op, kind] = ctx.settings.op.split(":"); + let out = ""; + if (op === "replace") out = tw.replace(text, RULES[kind]); + if (op === "stream") { + const h = tw.streamReplace(RULES[kind]); + for (let i = 0; i < text.length; i += 64) out += h.onReplyText(text.slice(i, i + 64), ctx); + out += h.onReplyTextEnd(ctx); + } + req.system = String(out.length); + }"##, + ) + .expect("load"); + let base = json!({ "format": "anthropic", "model": "m", "system": "" }); + let cost = |op: &str| { + let c = json!({ "client": null, "model": "m", "format": "anthropic", "upstream": "u", "settings": { "op": op } }); + report(&format!("1 MB dense text, {op}, sandbox CPU"), 10, || { + let inv = dense.on_request(base.clone(), c.clone()); + assert!(inv.result.is_ok(), "{op}: {:?}", inv.result); + inv.cpu + }) + }; + let none = cost("none:"); + for op in [ + "replace:strings", + "replace:mixed", + "stream:strings", + "stream:mixed", + ] { + let t = cost(op); + println!( + " {op}: {:.1?} more than building the text", + t.saturating_sub(none) + ); + } } -fn report(label: &str, n: usize, mut f: impl FnMut() -> Duration) { +/// 打印中位数和 p90/p99,返回中位数 +fn report(label: &str, n: usize, mut f: impl FnMut() -> Duration) -> Duration { for _ in 0..(n / 10).max(3) { f(); } @@ -121,6 +174,7 @@ fn report(label: &str, n: usize, mut f: impl FnMut() -> Duration) { q(0.9), q(0.99) ); + q(0.5) } fn big_view(target: usize) -> Value { diff --git a/crates/tw-plugin/src/helpers.js b/crates/tw-plugin/src/helpers.js index d76cfb64..19cc0221 100644 --- a/crates/tw-plugin/src/helpers.js +++ b/crates/tw-plugin/src/helpers.js @@ -29,14 +29,18 @@ const ArrayPrototype = Array.prototype; const ArrayJoin = Array.prototype.join; const ReflectApply = Reflect.apply; + const FunctionCall = Function.prototype.call; + const FunctionBind = Function.prototype.bind; const NumberIsInteger = Number.isInteger; const MathMin = Math.min; const MathMax = Math.max; const StringCtor = String; + const StringFromCharCode = String.fromCharCode; const StringIndexOf = String.prototype.indexOf; const StringSlice = String.prototype.slice; const StringTrim = String.prototype.trim; const StringCharCodeAt = String.prototype.charCodeAt; + const StringSplit = String.prototype.split; const RegExpCtor = RegExp; const RegExpPrototype = RegExp.prototype; const RegExpExec = RegExp.prototype.exec; @@ -77,25 +81,26 @@ // ── 小工具 ─────────────────────────────────────────────────── - function slice(s, a, b) { - return ReflectApply(StringSlice, s, b === undefined ? [a] : [a, b]); - } - - function indexOf(s, x, from) { - return ReflectApply(StringIndexOf, s, [x, from]); - } - - function code(s, i) { - return ReflectApply(StringCharCodeAt, s, [i]); - } - - function trim(s) { - return ReflectApply(StringTrim, s, []); - } + // 内建方法绑成普通函数:slice(s, a, b) 就是 s.slice(a, b),绑的是上面拿住的那个,不经原型。 + // 比每次 Reflect.apply 再包一层快得多 —— 替换时每处命中都要调好几次 + const unbind = (f) => ReflectApply(FunctionBind, FunctionCall, [f]); + const slice = unbind(StringSlice); + const indexOf = unbind(StringIndexOf); + const code = unbind(StringCharCodeAt); + const trim = unbind(StringTrim); + // 分隔符是字符串时 QuickJS 不去找 Symbol.split,插件改不了它 + const split = unbind(StringSplit); + const joinWith = unbind(ArrayJoin); + const execAt = unbind(RegExpExec); + const mapGet = unbind(MapGet); + const mapSet = unbind(MapSet); + const mapDelete = unbind(MapDelete); + const setAdd = unbind(SetAdd); + const setHas = unbind(SetHas); function exec(re, s, from) { re.lastIndex = from; - return ReflectApply(RegExpExec, re, [s]); + return execAt(re, s); } // 拼字、攒结果用的数组:没有原型 @@ -112,7 +117,7 @@ } function join(a) { - return ReflectApply(ArrayJoin, a, [""]); + return joinWith(a, ""); } // 建一个普通的自有属性。`__proto__` 这样的键也是一个普通的键,不会去改原型 @@ -202,11 +207,12 @@ const PARTIAL = /^<(?:<(?:T(?:W(?:_[A-Z0-9_]{0,64}>?)?)?)?)?$/; const PARTIAL_MAX = 70; - // `s` 末尾那个可能还没收全的占位符从哪儿起;没有就是 `s.length` + // `s` 末尾那个可能还没收全的占位符从哪儿起;没有就是 `s.length`。只看末尾那几十个字里 + // 的 `<`(逐段时每段都要查一次,多半一个 `<` 都没有) function partial(s) { const n = s.length; - for (let p = MathMax(0, n - PARTIAL_MAX); p < n; p++) { - if (code(s, p) === 60 && exec(PARTIAL, slice(s, p), 0) !== null) return p; + for (let p = indexOf(s, "<", MathMax(0, n - PARTIAL_MAX)); p !== -1; p = indexOf(s, "<", p + 1)) { + if (exec(PARTIAL, slice(s, p), 0) !== null) return p; } return n; } @@ -261,12 +267,14 @@ const pairs = pairsOf(rules, who); const out = list(); let regex = false; + let literal = true; let longest = 0; for (let i = 0; i < pairs.length; i++) { const n = i + 1; const find = pairs[i][0]; const to = pairs[i][1]; - const r = { __proto__: null, n, find: null, re: null, to: null, fn: null, plain: true }; + // at / end / hit:扫描时这条规则从游标起的下一处命中(见 scan) + const r = { __proto__: null, n, find: null, re: null, to: null, fn: null, plain: true, at: -2, end: 0, hit: null }; if (typeof find === "string") { if (find === "") throw new TypeErrorCtor(who + ": rule " + n + " looks for an empty string"); r.find = find; @@ -293,12 +301,13 @@ r.plain = r.re === null || indexOf(to, "$", 0) === -1; } else if (typeof to === "function") { r.fn = to; + literal = false; } else { throw new TypeErrorCtor(who + ": rule " + n + " replaces with " + typeName(to) + "; use a string or a function"); } out[out.length] = r; } - return { __proto__: null, who, rules: out, regex, longest, prefixes: null }; + return { __proto__: null, who, rules: out, regex, longest, prefixes: null, literal: literal && !regex, split: undefined }; } // 逐段要的:每个查找字符串的真前缀(文字末尾是不是某个查找字符串的开头,查一次) @@ -307,7 +316,7 @@ for (let k = 0; k < c.rules.length; k++) { const f = c.rules[k].find; if (f === null) continue; - for (let n = 1; n < f.length; n++) ReflectApply(SetAdd, set, [slice(f, 0, n)]); + for (let n = 1; n < f.length; n++) setAdd(set, slice(f, 0, n)); } c.prefixes = set; return c; @@ -386,20 +395,13 @@ return `${ReflectApply(r.fn, undefined, args)}`; } - // 第 k 条规则从 `cur` 起的下一处命中,记进 at / end / hit。没有是 -1 - function locate(c, k, s, cur, at, end, hit) { - const r = c.rules[k]; - if (r.re === null) { - const i = indexOf(s, r.find, cur); - at[k] = i; - end[k] = i + r.find.length; - return i; - } + // 正则规则从 `cur` 起的下一处命中,记进它的 at / end / hit。没有是 -1 + function locate(c, r, s, cur) { let m = exec(r.re, s, cur); // 带 u 的正则从一个代理对中间起找时,命中可能落在 cur 前面一格:从下一格重找 if (m !== null && m.index < cur) m = exec(r.re, s, cur + 1); if (m === null || m.index < cur) { - at[k] = -1; + r.at = -1; return -1; } const a = m.index; @@ -407,41 +409,48 @@ if (e === a) { throw new TypeErrorCtor(c.who + ": rule " + r.n + " matched an empty string; a rule has to match at least one character"); } - at[k] = a; - end[k] = e; - hit[k] = m; + r.at = a; + r.end = e; + r.hit = m; return a; } // 扫 `s` 里 [from, limit) 这一段,换好的追加到 `out`。起点在 limit 之前的命中都算(它可以 - // 越过 limit);limit 之前没有命中的原样追加。返回扫到了哪儿(不小于 limit) + // 越过 limit);limit 之前没有命中的原样追加。返回扫到了哪儿(不小于 limit)。 + // + // 每条规则记着它从游标起的下一处命中(at:-2 还没找过,-1 往后再也没有),只有被别的命中 + // 盖过去的才重找:每处命中只多花一次 indexOf 或 exec function scan(c, s, from, limit, out, base) { - const n = c.rules.length; - const at = list(); - const end = list(); - const hit = list(); - // -2:还没找过;-1:往后再也没有 - for (let k = 0; k < n; k++) at[k] = -2; + const rules = c.rules; + const n = rules.length; + for (let k = 0; k < n; k++) rules[k].at = -2; let cur = from; while (cur < limit) { - let best = -1; + let best = null; let bestAt = limit; for (let k = 0; k < n; k++) { - let a = at[k]; + const r = rules[k]; + let a = r.at; if (a === -1) continue; if (a < cur) { - a = locate(c, k, s, cur, at, end, hit); + if (r.re === null) { + a = indexOf(s, r.find, cur); + r.at = a; + r.end = a + r.find.length; + } else { + a = locate(c, r, s, cur); + } if (a === -1) continue; } if (a < bestAt) { bestAt = a; - best = k; + best = r; } } - if (best === -1) break; + if (best === null) break; if (bestAt > cur) out[out.length] = slice(s, cur, bestAt); - out[out.length] = replacement(c.rules[best], hit[best], s, bestAt, base); - cur = end[best]; + out[out.length] = best.fn === null && best.plain ? best.to : replacement(best, best.hit, s, bestAt, base); + cur = best.end; } if (cur < limit) { out[out.length] = slice(s, cur, limit); @@ -450,23 +459,163 @@ return cur; } - // 整段替换:按占位符分节,一节一节地扫 + // ── 全是字符串的快路径 ─────────────────────────────────────── + // + // 规则全是「字符串 → 字符串」、两两之间不会在文字里交叠(谁也不是谁的一部分,谁的结尾也 + // 不是谁的开头)时:每处命中都只可能是一条规则的,一次扫描挑出来的就是每条规则各自从左 + // 到右、不重叠地找到的那些。于是每条规则用一次原生的 split 就找全了,不必一处一处在 + // JavaScript 里挑。 + // + // 规则两两不交叠时先后无所谓,排一个遍数最少的顺序:一条规则的替换文字不空、和还没换的 + // 那些查找字符串没有一个共同的字(按 UTF-16 码元比),就直接换 —— 后面的规则在它里面找 + // 不到东西,也不能拿它和旁边的字拼出新的命中。不行的先放一个文字里没有的私用区字符占位, + // 全部规则找完再换成替换文字。最后剩下的一条后面没有规则了,直接换。 + + // 走快路径的最短文字和最多规则:短文字逐个挑也很快,规则多了 split 的遍数跟着多 + const LITERAL_MIN = 256; + const LITERAL_MAX_RULES = 16; + // 同一套规则判断一次(tw.texts 里每段文字都会调一次 tw.replace)。攒多了整个换掉 + let splitPlans = new MapCtor(); + let splitPlanCount = 0; + + // 这套规则能不能走快路径:去掉重复的查找字符串(同一个查找字符串取第一条),再看两两之间 + // 会不会交叠。不能就是 null + function splitPlan(c) { + if (c.split !== undefined) return c.split; + const rules = c.rules; + let key = ""; + for (let k = 0; k < rules.length; k++) key += rules[k].find.length + ":" + rules[k].find + rules[k].to.length + ":" + rules[k].to; + let plan = mapGet(splitPlans, key); + if (plan === undefined) { + plan = makePlan(rules); + if (splitPlanCount >= 32) { + splitPlans = new MapCtor(); + splitPlanCount = 0; + } + mapSet(splitPlans, key, plan); + splitPlanCount++; + } + c.split = plan; + return plan; + } + + function makePlan(rules) { + const finds = list(); + const tos = list(); + let chars = ""; + for (let k = 0; k < rules.length; k++) { + const f = rules[k].find; + let seen = false; + for (let j = 0; j < finds.length; j++) if (finds[j] === f) seen = true; + if (seen) continue; + if (finds.length >= LITERAL_MAX_RULES) return null; + finds[finds.length] = f; + tos[tos.length] = rules[k].to; + chars += f + rules[k].to; + } + let selfOverlap = false; + for (let a = 0; a < finds.length; a++) { + for (let b = 0; b < finds.length; b++) { + if (a !== b && (indexOf(finds[b], finds[a], 0) !== -1 || endStarts(finds[a], finds[b]))) return null; + } + if (endStarts(finds[a], finds[a])) selfOverlap = true; + } + // 排顺序:steps 里每一步是 [查找, 替换, 要不要占位] + const steps = list(); + const left = list(); + for (let k = 0; k < finds.length; k++) left[k] = k; + while (left.length > 0) { + let pick = -1; + for (let i = 0; i < left.length && pick === -1; i++) { + const to = tos[left[i]]; + let others = ""; + for (let j = 0; j < left.length; j++) if (j !== i) others += finds[left[j]]; + if (left.length === 1 || (to !== "" && !sharesUnit(to, others))) pick = i; + } + const marked = pick === -1; + if (marked) pick = 0; + const k = left[pick]; + steps[steps.length] = [finds[k], tos[k], marked]; + for (let i = pick; i < left.length - 1; i++) left[i] = left[i + 1]; + left.length = left.length - 1; + } + // selfOverlap:有查找字符串自己和自己交叠(aa、abab)。逐段时它的一处命中可能跨过 + // frontier,块中间那几段不走快路径(见 drain) + return { __proto__: null, steps, chars, selfOverlap }; + } + + // a 里有没有 b 里也有的 UTF-16 码元 + function sharesUnit(a, b) { + for (let i = 0; i < a.length; i++) if (indexOf(b, slice(a, i, i + 1), 0) !== -1) return true; + return false; + } + + // a 的一段真后缀是 b 的开头 + function endStarts(a, b) { + const first = slice(b, 0, 1); + for (let i = indexOf(a, first, 1); i !== -1; i = indexOf(a, first, i + 1)) { + const tail = slice(a, i); + if (tail.length < b.length && slice(b, 0, tail.length) === tail) return true; + } + return false; + } + + // 用快路径换一节文字。找不到够用的占位字符时是 null(回到逐个挑) + function splitReplace(plan, seg) { + const steps = plan.steps; + const marks = list(); + const later = list(); + let ch = 0xe000; + let t = seg; + for (let k = 0; k < steps.length; k++) { + const step = steps[k]; + if (!step[2]) { + t = joinWith(split(t, step[0]), step[1]); + continue; + } + // 占位字符:文字里没有,查找、替换文字里也没有 + let m = ""; + for (; ch < 0xf900 && m === ""; ch++) { + const c = StringFromCharCode(ch); + if (indexOf(seg, c, 0) === -1 && indexOf(plan.chars, c, 0) === -1) m = c; + } + if (m === "") return null; + t = joinWith(split(t, step[0]), m); + marks[marks.length] = m; + later[later.length] = step[1]; + } + for (let k = 0; k < marks.length; k++) t = joinWith(split(t, marks[k]), later[k]); + return t; + } + + // 一节文字(两个占位符之间那一段)整个换掉 + function replaceSection(c, seg, out) { + if (c.literal && seg.length >= LITERAL_MIN) { + const plan = splitPlan(c); + const t = plan === null ? null : splitReplace(plan, seg); + if (t !== null) { + out[out.length] = t; + return; + } + } + scan(c, seg, 0, seg.length, out, 0); + } + + // 整段替换:按占位符分节,一节一节地换 function replaceWhole(c, text) { + if (c.rules.length === 0) return text; const out = list(); let from = 0; - if (c.rules.length === 0) return text; if (indexOf(text, "< 1) { for (let p = MathMax(cur, n - c.longest + 1); p < f; p++) { - if (ReflectApply(SetHas, c.prefixes, [slice(a, p)])) { + if (setHas(c.prefixes, slice(a, p))) { f = p; break; } @@ -502,6 +651,13 @@ return f; } + // 一节到头了(遇到占位符或者块结束),从 from 起整个换掉。全是字符串的规则没有前文 + // (ctx 总是空的),能走快路径 + function finishSection(c, a, from, out, base) { + if (c.literal && from === 0) replaceSection(c, a, out); + else scan(c, a, from, a.length, out, base); + } + function drain(st, max, end) { const c = st.c; const out = list(); @@ -509,16 +665,14 @@ while (indexOf(st.pending, "<= LITERAL_MIN) { + const plan = splitPlan(c); + const t = plan === null || plan.selfOverlap ? null : splitReplace(plan, slice(a, cur, lim)); + if (t !== null) { + out[out.length] = t; + cur = lim; + break; + } + } const next = scan(c, a, cur, lim, out, st.base); // 一处命中越过了 frontier:从它后面再算一次 if (next <= lim) { @@ -688,19 +854,19 @@ if (!ends) { throw new ErrorCtor(who + ": export its onReplyTextEnd as well; without it the text held back at the end of a block is lost"); } - let st = ReflectApply(MapGet, lanes, [lane]); + let st = mapGet(lanes, lane); if (st === undefined) { const c = rulesFor(ctx); st = { __proto__: null, c: c.prefixes === null ? withPrefixes(c) : c, pending: "", ctx: "", base: 0 }; - ReflectApply(MapSet, lanes, [lane, st]); + mapSet(lanes, lane, st); } st.pending += text; return drain(st, max, false); }, onReplyTextEnd() { - const st = ReflectApply(MapGet, lanes, [lane]); + const st = mapGet(lanes, lane); if (st === undefined) return ""; - ReflectApply(MapDelete, lanes, [lane]); + mapDelete(lanes, lane); return drain(st, max, true); }, }; diff --git a/crates/tw-plugin/tests/corpus/helpers-split.js b/crates/tw-plugin/tests/corpus/helpers-split.js index 58c9ae32..a2bf5bab 100644 --- a/crates/tw-plugin/tests/corpus/helpers-split.js +++ b/crates/tw-plugin/tests/corpus/helpers-split.js @@ -6,6 +6,9 @@ // 3. 任意切分之后逐段交给 tw.streamReplace,拼起来的结果。 // 另外查两样:逐段交出的每一段都不会把输入里的占位符切开,也不会把一个代理对切开。 // +// 三成的组是「全是字符串的规则 + 几百上千字的长文字 + 切得粗」:规则全是字符串、两两不交叠 +// 时 tw.replace 和逐段替换都走 split 的快路径,这几组就是盯着它的。 +// // 在 onRequest 里跑(lane 一直是 0),每种切分是同一个 lane 上的一块文字,块和块之间 // 调 onReplyTextEnd。ctx.settings:seed(随机种子)、cases(几组规则和文字)、splits // (每组切几种)。结果写进系统提示词:{ checked, failures }。 @@ -32,6 +35,9 @@ const PIECES = ["a", "b", "c", "ab", "A", "1", "2", " ", "\n", "<", ">", "<<", " const PLACEHOLDERS = ["<>", "<>", "<>", "<>"]; const LOOKALIKES = ["<>", "<", ">", "<<>", "<>>", "<", "<<", ">>", "T", "W", "_", "TW_", "SECRET", "_1", "é", "😀"]; +// 多半两两不交叠的查找字符串(快路径能用);<< 和两个换行自己和自己交叠 +const WORDS = ["cat", "dog", "TW_", "SECRET", "é", "😀", "xyz", "\n\n", "<<", "12", "\ue000"]; +const LONG_PIECES = [...PIECES, ...WORDS, "the ", "cat ", "dog ", "xy", "z", "\ue001"]; const TOS = ["", "x", "$&", "$1", "<", "TW_", "<>", "😀", "[]"]; // 每一条一次命中连同前后断言看到的范围都不超过 7 个字(maxMatch 至少 8) const REGEXES = [ @@ -70,16 +76,28 @@ const FUNCTIONS = [ (...a) => "@" + a[a.length - (typeof a[a.length - 1] === "object" ? 3 : 2)], ]; -function text() { +function text(long) { let s = ""; - const n = int(0, 30); + const n = long ? int(100, 700) : int(0, 30); for (let i = 0; i < n; i++) { const r = rand(); - s += r < 0.08 ? pick(PLACEHOLDERS) : r < 0.13 ? pick(LOOKALIKES) : pick(PIECES); + if (long) s += r < 0.01 ? pick(PLACEHOLDERS) : r < 0.02 ? pick(LOOKALIKES) : pick(LONG_PIECES); + else s += r < 0.08 ? pick(PLACEHOLDERS) : r < 0.13 ? pick(LOOKALIKES) : pick(PIECES); } return s; } +// 全是「字符串 → 字符串」的规则:多半取自 WORDS(不交叠),偶尔混一条会交叠的 +function literalRules() { + const out = []; + const n = int(1, 5); + for (let i = 0; i < n; i++) { + const f = rand() < 0.85 ? pick(WORDS) : pick(FINDS) + pick(FINDS); + out.push([f, pick(TOS)]); + } + return out; +} + function rules() { const out = []; const n = int(0, 4); @@ -147,15 +165,17 @@ function section(t, rs) { return out; } -// 随机切开,不切在代理对中间(宿主交来的每一段都是完整的 UTF-8) -function split(s) { +// 随机切开,不切在代理对中间(宿主交来的每一段都是完整的 UTF-8)。coarse:切得粗(块中间 +// 那几段也够长,走得到快路径) +function split(s, coarse) { const mode = rand(); + const p = coarse ? 0.004 : 0.35; const chunks = []; let start = 0; for (let i = 1; i < s.length; i++) { const c = s.charCodeAt(i); if (c >= 0xdc00 && c <= 0xdfff) continue; - if (mode < 0.2 || rand() < 0.35) { + if ((!coarse && mode < 0.2) || rand() < p) { chunks.push(s.slice(start, i)); start = i; } @@ -175,9 +195,10 @@ export function onRequest(req, ctx) { const failures = []; let checked = 0; for (let k = 0; k < ctx.settings.cases && failures.length < 5; k++) { - const rs = rules(); + const long = rand() < 0.3; + const rs = long ? literalRules() : rules(); const max = int(8, 16); - const input = text(); + const input = text(long); const ref = reference(input, rs); const finds = rs.map(([f]) => f); const asMap = rand() < 0.2 && new Set(finds).size === finds.length; @@ -188,7 +209,7 @@ export function onRequest(req, ctx) { } const h = tw.streamReplace(rand() < 0.3 ? () => rs : rs, { maxMatch: max }); for (let n = 0; n < ctx.settings.splits; n++) { - const chunks = split(input); + const chunks = split(input, long && rand() < 0.7); const outs = chunks.map((c) => h.onReplyText(c, ctx)); outs.push(h.onReplyTextEnd(ctx)); const got = outs.join(""); diff --git a/crates/tw-plugin/tests/helpers.rs b/crates/tw-plugin/tests/helpers.rs index 9d43d430..2ae46f36 100644 --- a/crates/tw-plugin/tests/helpers.rs +++ b/crates/tw-plugin/tests/helpers.rs @@ -602,15 +602,64 @@ fn replace_rejects_bad_rules() { ); } +/// 命中很密的大文字在请求钩子的 CPU 上限(200 ms)之内换完。不量时间(CI 的机器慢好几倍), +/// 只要求不撞上限: +/// +/// - 全是字符串的规则走 split 的快路径:1 MB、约 10.8 万处命中,本机约 20 ms; +/// - 有正则的规则一处一处地挑:规模小一些,100 KB、约 1 万处命中,本机约 15 ms #[test] -fn replace_handles_a_large_text_quickly() { - // 1 MB 的文字、几条规则:远在请求钩子的 CPU 上限之内 +fn dense_replacements_stay_well_inside_the_cpu_limit() { let inv = run(r#" const text = "the widget costs 12 dollars; ".repeat(36000); + const out = tw.replace(text, [["widget", "gadget"], ["12", '#'], ["dollars", "USD"]]); + req.system = out.length + "|" + out.slice(0, 24); + "#); + assert_eq!(changed(&inv)["system"], "864000|the gadget costs # USD; "); + let inv = run(r#" + const text = "the widget costs 12 dollars; ".repeat(3600); const out = tw.replace(text, [["widget", "gadget"], [/\d+/, '#'], ["dollars", "USD"]]); - req.system = out.length + "|" + out.slice(0, 22); + req.system = out.length + "|" + out.slice(0, 24); "#); - assert_eq!(changed(&inv)["system"], "864000|the gadget costs # USD"); + assert_eq!(changed(&inv)["system"], "86400|the gadget costs # USD; "); +} + +/// 全是字符串的快路径(文字够长时)和一处一处地挑的结果一样:重复的查找字符串取第一条; +/// 换进去的字不再参与匹配;换成空串也不会让两边的字拼出新的命中;自己和自己交叠的查找 +/// 字符串从左到右、不重叠;会交叠的规则回到一处一处地挑;文字里本来就有私用区字符 +#[test] +fn the_fast_path_for_string_rules_keeps_the_same_meaning() { + for (rules, text, want) in [ + ( + r#"[["cat", "1"], ["cat", "2"], ["dog", "cat"]]"#, + "cat dog", + "1 cat", + ), + (r#"[["X", ""], ["ab", "Y"]]"#, "aXb ab", "ab Y"), + (r#"[["aa", "b"]]"#, "aaaaa", "bba"), + ( + r#"[["cat", "x"], ["category", "y"]]"#, + "category cat", + "xegory x", + ), + (r#"[["ab", "1"], ["bc", "2"]]"#, "abc bc", "1c 2"), + ( + r#"[["a", "\ue000"], ["b", "\ue001"]]"#, + "a\u{e000}b\u{e001}", + "\u{e000}\u{e000}\u{e001}\u{e001}", + ), + (r#"[["😀", "a"], ["é", "b"]]"#, "😀é😀", "aba"), + ] { + let pad = "-".repeat(300); + let got = eval(&format!( + r#"(() => {{ + const t = "{pad}" + {text:?} + "{pad}"; + const fast = tw.replace(t, {rules}); + const slow = tw.replace({text:?}, {rules}); + return [fast.slice(300, fast.length - 300), slow]; + }})()"# + )); + assert_eq!(got, json!([want, want]), "{rules} on {text:?}"); + } } // ── lines / pairs ──────────────────────────────────────────────── From 6b5ce1774abb6e250b72895e6f3520f8de7d850f Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:42:00 +0800 Subject: [PATCH 4/4] v0.75.0 Plugin helpers (tw) and in-place edits; every choice of an answer is checked by the dangerous-command rules and restored per choice; reply plugin fixes for split placeholders, skipped plugins and answers with several choices. Protocol 48 and the request store schema unchanged. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 38 +++++++++++++++++++------------------- Cargo.toml | 2 +- release-notes/0.75.0.md | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 release-notes/0.75.0.md diff --git a/Cargo.lock b/Cargo.lock index f871a3b8..69b9599a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3143,7 +3143,7 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.74.0" +version = "0.75.0" dependencies = [ "serde", "serde_json", @@ -3154,7 +3154,7 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.74.0" +version = "0.75.0" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -3175,7 +3175,7 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.74.0" +version = "0.75.0" dependencies = [ "serde", "serde_json", @@ -3183,7 +3183,7 @@ dependencies = [ [[package]] name = "tw-config" -version = "0.74.0" +version = "0.75.0" dependencies = [ "blake3", "libc", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "tw-control" -version = "0.74.0" +version = "0.75.0" dependencies = [ "axum", "base64", @@ -3251,7 +3251,7 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.74.0" +version = "0.75.0" dependencies = [ "memchr", "serde", @@ -3260,7 +3260,7 @@ dependencies = [ [[package]] name = "tw-engine" -version = "0.74.0" +version = "0.75.0" dependencies = [ "chrono", "serde", @@ -3274,7 +3274,7 @@ dependencies = [ [[package]] name = "tw-gateway" -version = "0.74.0" +version = "0.75.0" dependencies = [ "arc-swap", "async-stream", @@ -3325,7 +3325,7 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.74.0" +version = "0.75.0" dependencies = [ "aho-corasick", "base64", @@ -3343,7 +3343,7 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.74.0" +version = "0.75.0" dependencies = [ "serde", "serde_json", @@ -3357,7 +3357,7 @@ dependencies = [ [[package]] name = "tw-observe" -version = "0.74.0" +version = "0.75.0" dependencies = [ "tokio", "tracing", @@ -3366,7 +3366,7 @@ dependencies = [ [[package]] name = "tw-plugin" -version = "0.74.0" +version = "0.75.0" dependencies = [ "libc", "rand 0.10.2", @@ -3381,7 +3381,7 @@ dependencies = [ [[package]] name = "tw-pricing" -version = "0.74.0" +version = "0.75.0" dependencies = [ "arc-swap", "flate2", @@ -3394,14 +3394,14 @@ dependencies = [ [[package]] name = "tw-secret" -version = "0.74.0" +version = "0.75.0" dependencies = [ "thiserror", ] [[package]] name = "tw-store" -version = "0.74.0" +version = "0.75.0" dependencies = [ "blake3", "bytes", @@ -3421,7 +3421,7 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.74.0" +version = "0.75.0" dependencies = [ "serde", "serde_json", @@ -3430,7 +3430,7 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.74.0" +version = "0.75.0" dependencies = [ "libc", "notify", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.74.0" +version = "0.75.0" dependencies = [ "saphyr-parser", "serde_yaml_ng", @@ -3451,7 +3451,7 @@ dependencies = [ [[package]] name = "twcore" -version = "0.74.0" +version = "0.75.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index b48580fd..436260ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ categories = ["network-programming", "web-programming::http-server"] # 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。 # 这里是 crate 的版本。 -version = "0.74.0" +version = "0.75.0" [workspace.dependencies] # ── 内部 crate ─────────────────────────────────────────── diff --git a/release-notes/0.75.0.md b/release-notes/0.75.0.md new file mode 100644 index 00000000..2049f471 --- /dev/null +++ b/release-notes/0.75.0.md @@ -0,0 +1,29 @@ +This release gives plugins a set of helpers for common edits and lets a hook change the object it receives without returning it. It also makes the dangerous-command check and placeholder restoring look at every choice of an answer, not only the first. + +**Upgrade notes** + +- The control-plane protocol version stays at 48; ThinkWatch Lite releases that include 0.74.0 connect to 0.75.0 and the other way round. +- The request store's schema and the configuration are unchanged; nothing is cleared. +- Plugins: a hook that changes the request (`onRequest`) or the tool call (`onToolCall`) it receives and returns nothing now applies those changes. They used to be dropped unless the hook returned the object. +- The two built-in plugins are rewritten with the helpers and behave as before. Copies whose code was not edited are updated. +- For crates that embed the gateway: `tw_plugin::Reply::on_text` and `on_text_end` take the text block's lane, and the sandbox guest ABI is `tw_abi_2`. The public API of `tw_guard` is unchanged. + +**Plugin helpers.** Plugins have a global `tw`: +- `tw.appendSystem(req, text)` adds a line to the end of the system prompt unless it is already there. +- `tw.texts(req, fn)` passes each piece of editable text (system prompt, message text, tool results) to `fn` and replaces it with what `fn` returns. +- `tw.toolCalls(req, fn)` passes each earlier tool call in the conversation to `fn`; `tw.mapStrings(value, fn)` copies a value with every string mapped. +- `tw.replace(text, rules)` applies `[find, replacement]` rules in one left-to-right pass: strings match literally, regular expressions match globally, the rule listed first wins at a position, replaced text is not scanned again, and placeholders are never matched or split. +- `tw.streamReplace(rules)` returns `onReplyText` and `onReplyTextEnd` for stream mode. The streamed result equals `tw.replace` on the whole block; it holds back only what could still become a match (with regular expressions, the last 256 characters by default). +- `tw.lines` and `tw.pairs` read multi-line settings. + +The helpers are plain JavaScript in the sandbox snapshot, with no new host imports; their results are checked like any other plugin result. + +**Every choice of an answer is checked.** In a streamed Chat Completions answer the dangerous-command check read only the first element of `choices` in each frame, and Gemini answers only their first candidate. An upstream could get a tool call past the check by placing it in a second choice, or by spreading its arguments across two choices. Every choice and candidate is now checked, and streamed Chat arguments are assembled both by choice index and by position in the frame, matching both ways clients read them. Placeholders in a streamed answer with several choices are restored per choice. + +**Reply plugins** +- A stream-mode plugin that cut a placeholder in two left the placeholder text in the answer instead of the original value. The unfinished half is now held and restored together with the next piece. +- A stream-mode plugin that failed and was skipped could lose the text it was holding (when it failed in `onReplyTextEnd`) or release it out of order (when it failed in another text block). Both are fixed. +- Answers with several choices (`n` greater than 1, `candidateCount` greater than 1) mixed the choices' text and tool calls in plugins, and only the first choice of a whole answer went through them. Each choice is now kept apart, and every choice goes through the plugins. +- A stream-mode plugin that holds text back without changing it is no longer recorded as having changed the text; the run record compares each block as a whole. + +**Dependencies:** wasmtime 49.0.3 and rustls 0.23.46 (RustSec advisories).