feat(coding-agent): added matcherDigest hook for TTSR wire-format normalization

- Added optional `AgentTool.matcherDigest(args)` hook so tools can expose plain source text instead of wire-encoded arguments to TTSR rule matchers.
- Implemented `matcherDigest` on edit (all modes: hashline, patch, apply_patch, replace) and write tools, stripping patch prefixes and JSON escaping.
- Added `TtsrManager.checkSnapshot()` to replace the scoped buffer with a tool digest rather than appending raw deltas.
- Fixed TTSR conditions never matching streamed edit/write calls whose wire format obscured real content.
This commit is contained in:
can1357
2026-06-03 10:05:24 +02:00
parent b92aa30b24
commit eda1a1056b
10 changed files with 272 additions and 9 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added optional `AgentTool.matcherDigest(args)` hook: tools whose streamed arguments encode content in a wire grammar (patch formats, escaped strings) can expose the real content they introduce, so stream-content matchers (e.g. TTSR rules) run against plain source text instead of the wire format.
## [15.8.0] - 2026-06-02
### Fixed
+9
View File
@@ -441,6 +441,15 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
*/
intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
/**
* Normalize (potentially partial) streamed arguments into the plain text that
* stream-content matchers (e.g. TTSR rules) should inspect — the real content
* the call introduces, without wire grammar such as patch prefixes or JSON
* string escaping. Return `undefined` to fall back to raw argument-delta
* matching.
*/
matcherDigest?: (args: unknown) => string | undefined;
/** Capability tier declaration used by approval gates. Omitted means "exec". */
approval?: ToolApproval;
+1
View File
@@ -10,6 +10,7 @@
- Fixed `/review`'s uncommitted-change mode in Jujutsu repositories to read `jj diff --git` from the current workspace, so non-default JJ workspaces include their working-copy changes instead of falling back to the colocated Git checkout.
- Fixed empty assistant stop retry continuations preserving auto-retry state until a non-empty assistant turn completes or recovery reaches its retry cap.
- Fixed TTSR rule conditions never matching streamed `edit`/`write` tool calls whose wire format obscures the real content (hashline `+` body rows, apply_patch envelopes, JSON-escaped `write` content). The edit and write tools now expose a `matcherDigest` normalization and TTSR matches against the introduced source text, so rule regexes stay universal regardless of the active edit mode.
### Changed
+10
View File
@@ -24,6 +24,7 @@ import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
import { executePatchSingle, type PatchEditEntry, type PatchParams, patchEditSchema } from "./modes/patch";
import { executeReplaceSingle, type ReplaceEditEntry, type ReplaceParams, replaceEditSchema } from "./modes/replace";
import { type EditToolDetails, type EditToolPerFileResult, getLspBatchRequest, type LspBatchRequest } from "./renderer";
import { EDIT_MODE_STRATEGIES } from "./streaming";
export * from "@oh-my-pi/hashline";
export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode";
@@ -360,6 +361,15 @@ export class EditTool implements AgentTool<TInput> {
return "apply_patch";
}
/**
* Normalize streamed args into the source text this edit introduces, so
* stream matchers (TTSR rules) run against real file content instead of the
* mode-specific patch grammar.
*/
matcherDigest(args: unknown): string | undefined {
return EDIT_MODE_STRATEGIES[this.mode].matcherDigest(args);
}
async execute(
_toolCallId: string,
params: EditParams,
@@ -69,6 +69,14 @@ export interface EditStreamingStrategy<Args = unknown> {
* compute returned `null` because args are still too partial).
*/
renderStreamingFallback(args: Args, uiTheme: Theme): string;
/**
* Project the (potentially partial) args onto the plain text the edit
* introduces into files — added lines without patch grammar — so stream
* matchers (TTSR rules) can run source-level patterns against real content
* instead of the mode-specific wire format. Returns `undefined` when the
* args don't yet carry any content.
*/
matcherDigest(args: Args): string | undefined;
}
// -----------------------------------------------------------------------------
@@ -161,6 +169,28 @@ function groupApplyPatchEntriesByPath(entries: readonly ApplyPatchEntry[]): Map<
return groups;
}
/**
* Extract the lines a patch-style payload adds (`+` prefix, excluding `+++ `
* file headers), stripped of the prefix. When the text carries no added lines,
* returns the whole text if `fallbackToWhole` (full-content payloads such as a
* `create` op), otherwise an empty string (grammar-only payloads).
*/
function extractAddedLines(text: string, fallbackToWhole: boolean): string {
let added: string | undefined;
let lineStart = 0;
while (lineStart <= text.length) {
let lineEnd = text.indexOf("\n", lineStart);
if (lineEnd === -1) lineEnd = text.length;
if (text.charCodeAt(lineStart) === 43 /* + */ && !text.startsWith("+++ ", lineStart)) {
const line = text.slice(lineStart + 1, lineEnd);
added = added === undefined ? line : `${added}\n${line}`;
}
lineStart = lineEnd + 1;
}
if (added === undefined) return fallbackToWhole ? text : "";
return added;
}
// -----------------------------------------------------------------------------
// Strategies
// -----------------------------------------------------------------------------
@@ -196,6 +226,16 @@ const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
renderStreamingFallback() {
return "";
},
matcherDigest(args) {
const edits = args?.edits;
if (!Array.isArray(edits)) return undefined;
let digest: string | undefined;
for (const edit of edits) {
if (typeof edit?.new_text !== "string") continue;
digest = digest === undefined ? edit.new_text : `${digest}\n${edit.new_text}`;
}
return digest;
},
};
interface PatchArgs {
@@ -225,6 +265,19 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
renderStreamingFallback() {
return "";
},
matcherDigest(args) {
const edits = args?.edits;
if (!Array.isArray(edits)) return undefined;
let digest: string | undefined;
for (const edit of edits) {
if (typeof edit?.diff !== "string") continue;
// `create` ops carry full file content in `diff` with no +/- markers;
// pass that content through whole.
const added = extractAddedLines(edit.diff, true);
digest = digest === undefined ? added : `${digest}\n${added}`;
}
return digest;
},
};
interface HashlineArgs {
@@ -378,6 +431,12 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
// than a sigil dump.
return "";
},
matcherDigest(args) {
const input = args?.input;
if (typeof input !== "string") return undefined;
// Body rows are `+TEXT`; headers and op lines are grammar, never content.
return extractAddedLines(input, false);
},
};
interface ApplyPatchArgs {
@@ -430,6 +489,12 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
renderStreamingFallback() {
return "";
},
matcherDigest(args) {
const input = args?.input;
if (typeof input !== "string") return undefined;
// Envelope markers and `@@` hunk headers are grammar, never content.
return extractAddedLines(input, false);
},
};
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
replace: replaceStrategy as EditStreamingStrategy<unknown>,
+18 -1
View File
@@ -339,7 +339,24 @@ export class TtsrManager {
const bufferKey = this.#bufferKey(context);
const nextBuffer = `${this.#buffers.get(bufferKey) ?? ""}${delta}`;
this.#buffers.set(bufferKey, nextBuffer);
return this.#matchBuffer(nextBuffer, context);
}
/**
* Replace the scoped buffer with a tool-provided normalized snapshot and
* return matching rules.
*
* Used for tools exposing `matcherDigest`: the digest is recomputed from the
* full (partial) arguments on every delta, so it replaces the buffer instead
* of being appended to it.
*/
checkSnapshot(snapshot: string, context: TtsrMatchContext): Rule[] {
const bufferKey = this.#bufferKey(context);
this.#buffers.set(bufferKey, snapshot);
return this.#matchBuffer(snapshot, context);
}
#matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] {
const matches: Rule[] = [];
for (const [name, entry] of this.#rules) {
if (!this.#canTrigger(name)) {
@@ -351,7 +368,7 @@ export class TtsrManager {
if (!this.#matchesGlobalPaths(entry, context)) {
continue;
}
if (!this.#matchesCondition(entry, nextBuffer)) {
if (!this.#matchesCondition(entry, buffer)) {
continue;
}
@@ -1604,17 +1604,19 @@ export class AgentSession {
if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
const assistantEvent = event.assistantMessageEvent;
let matchContext: TtsrMatchContext | undefined;
let streamingToolCall: ToolCall | undefined;
if (assistantEvent.type === "text_delta") {
matchContext = { source: "text" };
} else if (assistantEvent.type === "thinking_delta") {
matchContext = { source: "thinking" };
} else if (assistantEvent.type === "toolcall_delta") {
matchContext = this.#getTtsrToolMatchContext(event.message, assistantEvent.contentIndex);
streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex);
matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex);
}
if (matchContext && "delta" in assistantEvent) {
const matches = this.#ttsrManager.checkDelta(assistantEvent.delta, matchContext);
const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall);
if (matches.length > 0) {
// Decide first: a non-interrupting tool-source match attaches to the
// specific tool call's result instead of driving a loop-wide follow-up.
@@ -2285,30 +2287,64 @@ export class AgentSession {
});
}
/** Build TTSR match context for tool call argument deltas. */
#getTtsrToolMatchContext(message: AgentMessage, contentIndex: number): TtsrMatchContext {
const context: TtsrMatchContext = { source: "tool" };
/** Extract the tool-call block a toolcall_delta event refers to, if present. */
#getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined {
if (message.role !== "assistant") {
return context;
return undefined;
}
const content = message.content;
if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
return context;
return undefined;
}
const block = content[contentIndex];
if (!block || typeof block !== "object" || block.type !== "toolCall") {
return undefined;
}
return block as ToolCall;
}
/** Build TTSR match context for tool call argument deltas. */
#getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext {
const context: TtsrMatchContext = { source: "tool" };
if (!toolCall) {
return context;
}
const toolCall = block as ToolCall;
context.toolName = toolCall.name;
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
return context;
}
/**
* Match a stream delta against TTSR rules.
*
* Tool argument streams prefer the tool's `matcherDigest` normalization — the
* real content the call introduces — over the raw argument delta, so rule
* conditions written against source text keep working regardless of the
* tool's wire format (hashline patches, JSON-escaped strings, ...).
*/
#checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] {
const manager = this.#ttsrManager;
if (!manager) {
return [];
}
if (toolCall) {
const tools = this.agent.state.tools;
const tool =
tools.find(t => t.name === toolCall.name) ??
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
const digest = tool?.matcherDigest?.(toolCall.arguments ?? {});
if (digest !== undefined) {
return manager.checkSnapshot(digest, matchContext);
}
}
return manager.checkDelta(delta, matchContext);
}
/** Extract path-like arguments from tool call payload for TTSR glob matching. */
#extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
if (!args || typeof args !== "object" || Array.isArray(args)) {
+6
View File
@@ -273,6 +273,12 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
readonly loadMode = "discoverable";
readonly summary = "Write content to a file (creates or overwrites)";
/** Stream matchers should see the real file content, not its JSON-escaped argument encoding. */
matcherDigest(args: unknown): string | undefined {
const content = (args as Partial<WriteParams>).content;
return typeof content === "string" ? content : undefined;
}
readonly #writethrough: WritethroughCallback;
constructor(private readonly session: ToolSession) {
@@ -242,3 +242,55 @@ describe("apply_patch streaming preview (trailing partial line)", () => {
expect(cIdx).toBeGreaterThan(posB);
});
});
describe("matcherDigest", () => {
test("hashline: digests stripped `+` body rows only, never headers or op lines", () => {
const input = ["¶a.ts#AB12", "replace 1..2:", "+const x = 1;", "+const y = 2;", "delete 5", ""].join("\n");
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input })).toBe("const x = 1;\nconst y = 2;");
});
test("hashline: grammar-only payload digests to empty, missing input to undefined", () => {
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: "¶a.ts#AB12\ndelete 3\n" })).toBe("");
expect(EDIT_MODE_STRATEGIES.hashline.matcherDigest({})).toBeUndefined();
});
test("apply_patch: digests added lines, never envelope markers or context", () => {
const input = [
"*** Begin Patch",
"*** Update File: a.ts",
"@@",
" const a = 1;",
"-const old = 1;",
"+const fresh = 2;",
"*** End Patch",
"",
].join("\n");
expect(EDIT_MODE_STRATEGIES.apply_patch.matcherDigest({ input })).toBe("const fresh = 2;");
});
test("patch: digests added lines from diffs and passes create content through whole", () => {
expect(
EDIT_MODE_STRATEGIES.patch.matcherDigest({
edits: [{ diff: " ctx\n-removed line\n+added line\n" }],
}),
).toBe("added line");
const createContent = "full file content\nwith no diff markers\n";
expect(
EDIT_MODE_STRATEGIES.patch.matcherDigest({
edits: [{ op: "create", diff: createContent }],
}),
).toBe(createContent);
});
test("replace: digests new_text of every edit", () => {
expect(
EDIT_MODE_STRATEGIES.replace.matcherDigest({
edits: [
{ old_text: "a", new_text: "const b = 1;" },
{ old_text: "c", new_text: "const d = 2;" },
],
}),
).toBe("const b = 1;\nconst d = 2;");
expect(EDIT_MODE_STRATEGIES.replace.matcherDigest({})).toBeUndefined();
});
});
+63
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import * as path from "node:path";
import { parseRuleConditionAndScope, type Rule } from "@oh-my-pi/pi-coding-agent/capability/rule";
import { EDIT_MODE_STRATEGIES } from "@oh-my-pi/pi-coding-agent/edit";
import { TtsrManager } from "@oh-my-pi/pi-coding-agent/export/ttsr";
function makeRule(partial: Partial<Rule>): Rule {
@@ -273,6 +274,68 @@ describe("TtsrManager scope matching", () => {
});
});
describe("TtsrManager snapshot matching", () => {
it("matches source-level conditions against a tool digest where the raw patch grammar fails", () => {
const manager = new TtsrManager();
const rule = makeRule({
name: "ts-no-tiny-functions",
condition: ["\\{\\s*return [^;{}\\n]+;?\\s*\\}"],
scope: ["tool:edit(*.ts)"],
});
manager.addRule(rule);
const context = {
source: "tool" as const,
toolName: "edit",
filePaths: ["src/repo.ts"],
streamKey: "toolcall:tc-1",
};
const patch = [
"¶src/repo.ts#AB12",
"replace block 1:",
"+export async function isRepository(cwd: string): Promise<boolean> {",
"+\treturn repo.isRepository(cwd);",
"+}",
"",
].join("\n");
// Raw patch grammar: `+` body-row prefixes break source-level regexes.
expect(manager.checkDelta(patch, context)).toEqual([]);
// The edit tool's digest of the same patch is real source text and matches.
const digest = EDIT_MODE_STRATEGIES.hashline.matcherDigest({ input: patch });
expect(digest).toBe(
[
"export async function isRepository(cwd: string): Promise<boolean> {",
"\treturn repo.isRepository(cwd);",
"}",
].join("\n"),
);
expect(manager.checkSnapshot(digest as string, context)).toEqual([rule]);
});
it("replaces the scoped buffer instead of appending snapshots", () => {
const manager = new TtsrManager();
const rule = makeRule({
name: "no-as-any",
condition: ["as any"],
scope: ["tool:edit(*.ts)"],
});
manager.addRule(rule);
const context = {
source: "tool" as const,
toolName: "edit",
filePaths: ["src/main.ts"],
streamKey: "toolcall:tc-2",
};
expect(manager.checkSnapshot("const x = y as any;", context)).toEqual([rule]);
// A later digest without the pattern must not match stale buffered text.
expect(manager.checkSnapshot("const x = y as string;", context)).toEqual([]);
});
});
describe("TtsrManager repeat behavior", () => {
const turnContext = { source: "text" as const };