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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user