feat(coding-agent): added TTSR injection tracking and deduplication

- Added TTSR injection tracking with per-turn recording and deduplication to prevent repeated rule injections within the same turn.
- Changed TTSR message format to use custom message type with metadata fields for improved injection tracking and session persistence.
- Fixed TTSR repeat-after-gap mode to correctly restore injected rules from previous sessions and recalculate gap thresholds.
- Added test suite with 6 test cases covering TTSR repeat modes (once, after-gap, restored) and injection deduplication behavior.
This commit is contained in:
can1357
2026-02-18 01:38:22 +01:00
parent bc69fd207d
commit 2249529e70
5 changed files with 191 additions and 21 deletions
+4 -1
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Added
- Added `condition` and `scope` fields to rule frontmatter for advanced TTSR matching and stream filtering
@@ -13,6 +12,9 @@
### Changed
- Changed TTSR injection tracking to record all turns where rules were injected (instead of only the last turn) to support repeat-after-gap mode across resumed sessions
- Changed TTSR injection messages to use custom message type with metadata instead of synthetic user messages for better session tracking
- Changed TTSR rule injection to persist injected rule names in session state for restoration when resuming sessions
- Changed model discovery to automatically discover built-in provider models (Anthropic, OpenAI, Groq, Cerebras, Xai, Mistral, OpenCode, OpenRouter, Vercel AI Gateway, Kimi Code, GitHub Copilot, Google, Cursor, Google Antigravity, Google Gemini CLI, OpenAI Codex) when credentials are configured
- Changed `getModel()` and `getModels()` imports to `getBundledModel()` and `getBundledModels()` across test utilities
- Changed TTSR rule matching from single `ttsrTrigger` regex to multiple `condition` patterns with scope filtering
@@ -23,6 +25,7 @@
### Fixed
- Fixed TTSR repeat-after-gap mode to correctly calculate gaps when rules are restored from previous sessions
- Fixed TTSR matching to respect tool-specific scope filters, preventing cross-tool rule contamination
- Fixed path normalization in TTSR glob matching to handle both relative and absolute path variants
+12 -6
View File
@@ -42,10 +42,10 @@ interface TtsrEntry {
globalPathGlobs?: Bun.Glob[];
}
/** Tracks when a rule was last injected (for repeat-after-gap mode). */
/** Tracks the turns where a rule was injected (for repeat gating). */
interface InjectionRecord {
/** Message count when the rule was last injected. */
lastInjectedAt: number;
/** Message counts (turn indexes) when the rule was injected. */
injectedAtTurns: number[];
}
const DEFAULT_SETTINGS: Required<TtsrSettings> = {
@@ -85,7 +85,8 @@ export class TtsrManager {
return false;
}
const gap = this.#messageCount - record.lastInjectedAt;
const lastInjectedAt = record.injectedAtTurns[record.injectedAtTurns.length - 1];
const gap = this.#messageCount - lastInjectedAt;
return gap >= this.#settings.repeatGap;
}
@@ -369,7 +370,12 @@ export class TtsrManager {
/** Mark rules as injected (won't trigger again until conditions allow). */
markInjected(rulesToMark: Rule[]): void {
for (const rule of rulesToMark) {
this.#injectionRecords.set(rule.name, { lastInjectedAt: this.#messageCount });
const record = this.#injectionRecords.get(rule.name);
if (!record) {
this.#injectionRecords.set(rule.name, { injectedAtTurns: [this.#messageCount] });
} else if (record.injectedAtTurns[record.injectedAtTurns.length - 1] !== this.#messageCount) {
record.injectedAtTurns.push(this.#messageCount);
}
logger.debug("TTSR rule marked as injected", {
ruleName: rule.name,
messageCount: this.#messageCount,
@@ -386,7 +392,7 @@ export class TtsrManager {
/** Restore injected state from a list of rule names. */
restoreInjected(ruleNames: string[]): void {
for (const name of ruleNames) {
this.#injectionRecords.set(name, { lastInjectedAt: 0 });
this.#injectionRecords.set(name, { injectedAtTurns: [0] });
}
if (ruleNames.length > 0) {
logger.debug("TTSR injected state restored", { ruleNames });
+3
View File
@@ -680,6 +680,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
}
}
if (existingSession.injectedTtsrRules.length > 0) {
ttsrManager.restoreInjected(existingSession.injectedTtsrRules);
}
time("discoverTtsrRules");
// Filter rules for the rulebook (non-TTSR, non-alwaysApply, with descriptions)
@@ -494,13 +494,11 @@ export class AgentSession {
return;
}
const latestMessage = this.agent.state.messages[this.agent.state.messages.length - 1];
const targetAssistantIndex = this.#findTtsrAssistantIndex(targetMessageTimestamp);
if (
!this.#ttsrAbortPending ||
this.#promptGeneration !== generation ||
!latestMessage ||
latestMessage.role !== "assistant" ||
(targetMessageTimestamp !== undefined && latestMessage.timestamp !== targetMessageTimestamp)
targetAssistantIndex === -1
) {
this.#ttsrAbortPending = false;
this.#pendingTtsrInjections = [];
@@ -509,19 +507,23 @@ export class AgentSession {
this.#ttsrAbortPending = false;
const ttsrSettings = this.#ttsrManager?.getSettings();
if (ttsrSettings?.contextMode === "discard") {
// Remove the partial/aborted message from agent state
this.agent.popMessage();
// Remove the partial/aborted assistant turn from agent state
this.agent.replaceMessages(this.agent.state.messages.slice(0, targetAssistantIndex));
}
// Inject TTSR rules as system reminder before retry
const injection = this.#getTtsrInjectionContent();
if (injection) {
const details = { rules: injection.rules.map(rule => rule.name) };
this.agent.appendMessage({
role: "user",
content: [{ type: "text", text: injection.content }],
role: "custom",
customType: "ttsr-injection",
content: injection.content,
display: false,
details,
timestamp: Date.now(),
synthetic: true,
});
this.#ttsrManager?.markInjected(injection.rules);
this.sessionManager.appendCustomMessageEntry("ttsr-injection", injection.content, false, details);
this.#markTtsrInjected(injection.rules);
}
this.agent.continue().catch(() => {});
}, 50);
@@ -671,6 +673,28 @@ export class AgentSession {
}
}
#markTtsrInjected(rules: Rule[]): void {
if (rules.length === 0) {
return;
}
this.#ttsrManager?.markInjected(rules);
this.sessionManager.appendTtsrInjection(rules.map(rule => rule.name));
}
#findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
const messages = this.agent.state.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role !== "assistant") {
continue;
}
if (targetTimestamp === undefined || message.timestamp === targetTimestamp) {
return i;
}
}
return -1;
}
#shouldInterruptForTtsrMatch(matchContext: TtsrMatchContext): boolean {
const mode = this.#ttsrManager?.getSettings().interruptMode ?? "always";
if (mode === "never") {
@@ -699,12 +723,21 @@ export class AgentSession {
return;
}
this.agent.followUp({
role: "user",
content: [{ type: "text", text: injection.content }],
role: "custom",
customType: "ttsr-injection",
content: injection.content,
display: false,
details: { rules: injection.rules.map(rule => rule.name) },
timestamp: Date.now(),
synthetic: true,
});
this.#ttsrManager?.markInjected(injection.rules);
this.#markTtsrInjected(injection.rules);
// followUp() only enqueues; resume on the next tick once streaming settles.
setTimeout(() => {
if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
return;
}
this.agent.continue().catch(() => {});
}, 0);
}
/** Build TTSR match context for tool call argument deltas. */
+125
View File
@@ -272,3 +272,128 @@ describe("TtsrManager scope matching", () => {
).toEqual([rule]);
});
});
describe("TtsrManager repeat behavior", () => {
const turnContext = { source: "text" as const };
function createRepeatRule(name = "repeat-rule"): Rule {
return makeRule({
name,
condition: ["forbidden"],
scope: ["text"],
});
}
function runTurn(manager: TtsrManager, rule: Rule): Rule[] {
manager.resetBuffer();
const matches = manager.checkDelta("forbidden", turnContext);
if (matches.length > 0) {
manager.markInjected([rule]);
}
manager.incrementMessageCount();
return matches;
}
it("never repeats when repeat mode is once", () => {
const manager = new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "once",
repeatGap: 10,
});
const rule = createRepeatRule("once");
manager.addRule(rule);
expect(runTurn(manager, rule)).toEqual([rule]);
expect(runTurn(manager, rule)).toEqual([]);
expect(runTurn(manager, rule)).toEqual([]);
});
it("repeats every turn when repeat mode is after-gap and gap is 1", () => {
const manager = new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "after-gap",
repeatGap: 1,
});
const rule = createRepeatRule("gap-1");
manager.addRule(rule);
expect(runTurn(manager, rule)).toEqual([rule]);
expect(runTurn(manager, rule)).toEqual([rule]);
expect(runTurn(manager, rule)).toEqual([rule]);
});
it("respects repeat gap when repeat mode is after-gap", () => {
const manager = new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "after-gap",
repeatGap: 2,
});
const rule = createRepeatRule("gap-2");
manager.addRule(rule);
expect(runTurn(manager, rule)).toEqual([rule]);
expect(runTurn(manager, rule)).toEqual([]);
expect(runTurn(manager, rule)).toEqual([rule]);
expect(runTurn(manager, rule)).toEqual([]);
expect(runTurn(manager, rule)).toEqual([rule]);
});
it("blocks restored rules in once mode across resumed sessions", () => {
const manager = new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "once",
repeatGap: 10,
});
const rule = createRepeatRule("restored-once");
manager.addRule(rule);
manager.restoreInjected([rule.name]);
expect(runTurn(manager, rule)).toEqual([]);
expect(runTurn(manager, rule)).toEqual([]);
});
it("applies repeat gap to restored rules in after-gap mode", () => {
const manager = new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "after-gap",
repeatGap: 2,
});
const rule = createRepeatRule("restored-gap");
manager.addRule(rule);
manager.restoreInjected([rule.name]);
expect(runTurn(manager, rule)).toEqual([]);
expect(runTurn(manager, rule)).toEqual([]);
expect(runTurn(manager, rule)).toEqual([rule]);
});
it("tracks only one injection record per rule per turn", () => {
const manager = new TtsrManager({
enabled: true,
contextMode: "discard",
interruptMode: "always",
repeatMode: "after-gap",
repeatGap: 1,
});
const rule = createRepeatRule("single-record");
manager.addRule(rule);
manager.markInjected([rule]);
manager.markInjected([rule]);
manager.markInjected([rule]);
expect(manager.getInjectedRuleNames()).toEqual([rule.name]);
manager.incrementMessageCount();
expect(manager.checkDelta("forbidden", turnContext)).toEqual([rule]);
});
});