Skip to content

Commit f040a92

Browse files
fylornclaude
andauthored
v0.75.0: plugin helpers (tw), in-place edits that count, per-block streaming (#341)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4184876 commit f040a92

28 files changed

Lines changed: 3363 additions & 168 deletions

File tree

‎Cargo.lock‎

Lines changed: 19 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ categories = ["network-programming", "web-programming::http-server"]
5454

5555
# 二进制走 CalVer,crate 走 SemVer —— 两者是两回事,卖给不同的人。
5656
# 这里是 crate 的版本。
57-
version = "0.74.0"
57+
version = "0.75.0"
5858

5959
[workspace.dependencies]
6060
# ── 内部 crate ───────────────────────────────────────────

‎crates/tw-gateway/src/plugin/defaults/manifests.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
}
3333
]
3434
},
35-
"sha256": "f34e95b9db72c98eadac320f7c3f7af9acb8beae9fa3acd8eda1d0459e18bb6e"
35+
"sha256": "30ca8624472ebdbeb6793b19226402ff3934ff832fb690e2ed1b513496e49322"
3636
},
3737
"wsl-paths": {
3838
"manifest": {
@@ -68,6 +68,6 @@
6868
}
6969
]
7070
},
71-
"sha256": "6aaf3646438f024a6482413eca7fe9f17230d3f5177458476d070485cb333a99"
71+
"sha256": "06adbac883676e7587394437c4f483e2020449515a62fb2db69f682495b303df"
7272
}
7373
}

‎crates/tw-gateway/src/plugin/defaults/reply-language.js‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
// 权限:system,只读写系统提示词。
1010
// 设置:回答语言,装上时是简体中文。出错时(比如语言名写得不对)拒绝这个请求。
1111
//
12+
// 附加用的是辅助函数 tw.appendSystem:和原文之间空一行,原文已经有这句就不再附加;改的是
13+
// 传入的 req,不用返回。
14+
//
1215
// manifest 是纯数据,写成 core 改写它时的样子:界面改设置时只换这一段,改出来的和原来
1316
// 只差改了的那一行。
1417
//
@@ -36,8 +39,5 @@ export function onRequest(req, ctx) {
3639
"The answer language setting accepts only the name of a language, such as English or Deutsch.",
3740
);
3841
}
39-
const line = `Always respond in ${language}, unless the user explicitly asks for another language.`;
40-
if (req.system.includes(line)) return undefined;
41-
req.system = req.system ? `${req.system}\n\n${line}` : line;
42-
return req;
42+
tw.appendSystem(req, `Always respond in ${language}, unless the user explicitly asks for another language.`);
4343
}

‎crates/tw-gateway/src/plugin/defaults/wsl-paths.js‎

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
//
1414
// 设置只有一个开关:客户端在哪一侧。设置改不出任何别的改写。
1515
//
16+
// 遍历和复制交给辅助函数:tw.toolCalls 逐个交出对话历史里的工具调用,tw.mapStrings 复制
17+
// 参数、把其中每个字符串换一遍(参数里有 __proto__ 这样的键也照常复制)。改的是传入的
18+
// 对象,不用返回;什么都没换时请求原样发出,一个字节都不动。
19+
//
1620
// 权限:messages(对话历史),reply.tool_calls(回答里的工具调用)。reply.tool_calls 是
1721
// 高风险权限:插件能改动模型要执行的操作;改过的工具调用照样经过 Lite 的工具调用审查。
1822
// 设置:客户端运行在 Windows 上(关闭时按客户端在 WSL 里处理)。出错时拒绝这个请求。
@@ -52,39 +56,14 @@ function convert(value, windows) {
5256
return m ? `/mnt/${m[1].toLowerCase()}${(m[2] ?? "/").replaceAll("\\", "/")}` : value;
5357
}
5458

55-
function rewrite(value, windows) {
56-
if (typeof value === "string") return convert(value, windows);
57-
if (Array.isArray(value)) return value.map((item) => rewrite(item, windows));
58-
if (value !== null && typeof value === "object") {
59-
// fromEntries 按原样建出每个键,参数里有 __proto__ 这样的键也不会出错
60-
return Object.fromEntries(
61-
Object.entries(value).map(([key, item]) => [key, rewrite(item, windows)]),
62-
);
63-
}
64-
return value;
65-
}
66-
67-
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
68-
6959
export function onRequest(req, ctx) {
7060
const windows = ctx.settings.windows_client === true;
71-
let changed = false;
72-
for (const m of req.messages) {
73-
for (const p of m.parts) {
74-
if (p.type !== "tool_call") continue;
75-
const input = rewrite(p.input, windows);
76-
if (!same(input, p.input)) {
77-
p.input = input;
78-
changed = true;
79-
}
80-
}
81-
}
82-
// 没改就不返回:请求原样发出,一个字节都不动
83-
return changed ? req : undefined;
61+
tw.toolCalls(req, (call) => {
62+
call.input = tw.mapStrings(call.input, (s) => convert(s, windows));
63+
});
8464
}
8565

8666
export function onToolCall(call, ctx) {
87-
const input = rewrite(call.input, ctx.settings.windows_client === true);
88-
if (same(input, call.input)) return undefined;
89-
return { id: call.id, name: call.name, input };
67+
const windows = ctx.settings.windows_client === true;
68+
call.input = tw.mapStrings(call.input, (s) => convert(s, windows));
9069
}

‎crates/tw-gateway/src/plugin/host.rs‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use std::time::Duration;
1010
use serde_json::Value;
1111

1212
use crate::plugin::engine::Manifest;
13+
use crate::plugin::reply::Lane;
1314
use crate::plugin::set::LogLine;
1415

1516
pub trait PluginHost: Send + Sync {
@@ -43,11 +44,14 @@ pub trait PluginHost: Send + Sync {
4344
}
4445

4546
/// 一次回答的插件实例。**只给这一次回答用**。
47+
///
48+
/// `lane` 是这块文字在这次回答里的编号([`Lane`]):同时进行的几块文字分开缓冲。它只交给
49+
/// 沙箱里的 `tw.streamReplace`,插件代码看不到
4650
pub trait ReplyHost: Send {
4751
/// `None` 是没改
48-
fn on_text(&mut self, text: &str) -> Invocation<Option<String>>;
52+
fn on_text(&mut self, lane: Lane, text: &str) -> Invocation<Option<String>>;
4953
/// 流式一块文字结束。`None` 是什么都不补
50-
fn on_text_end(&mut self) -> Invocation<Option<String>>;
54+
fn on_text_end(&mut self, lane: Lane) -> Invocation<Option<String>>;
5155
fn on_tool_call(&mut self, call: Value) -> Invocation<ToolCallOutcome>;
5256
}
5357

@@ -302,19 +306,20 @@ pub mod double {
302306
type EndFn = dyn FnMut() -> Invocation<Option<String>> + Send;
303307
type ToolFn = dyn FnMut(Value) -> Invocation<ToolCallOutcome> + Send;
304308

305-
/// 一个回答实例的替身:三个闭包,可以带状态(流式扣住文字的插件就要)。
309+
/// 一个回答实例的替身:三个闭包,可以带状态(流式扣住文字的插件就要)。块编号不交给
310+
/// 闭包:插件代码本来就看不到它
306311
pub struct Closures {
307312
pub text: Box<TextFn>,
308313
pub end: Box<EndFn>,
309314
pub tool: Box<ToolFn>,
310315
}
311316

312317
impl ReplyHost for Closures {
313-
fn on_text(&mut self, text: &str) -> Invocation<Option<String>> {
318+
fn on_text(&mut self, _lane: Lane, text: &str) -> Invocation<Option<String>> {
314319
(self.text)(text)
315320
}
316321

317-
fn on_text_end(&mut self) -> Invocation<Option<String>> {
322+
fn on_text_end(&mut self, _lane: Lane) -> Invocation<Option<String>> {
318323
(self.end)()
319324
}
320325

0 commit comments

Comments
 (0)