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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user