Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions changes/unreleased/4538.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!-- changes-log: packages/coding-agent/src/core/extensions/builtin/ttsr/changes.md -->
## 2026-10-27 - repetitive-turns never cuts a reply off and ignores summaries separated by tool work (senpi#4537)

### What changed

- `repetitive-turns-lane.ts`: the lane compares each completed text-only reply with the previous one; a reply that calls a tool resets the comparison (`noteToolWork`, and `readPersistedAssistantTexts` restores only the text-only replies since the last tool call). The streaming arm path `observeTextDelta` is removed.
- `index.ts`: repetitive-turns no longer aborts the stream; it is judged at `message_end` and nudges after the complete reply. `queueRuleNudge` writes the nudge activation only when the nudge will be sent.
- `follow-up-limit.ts`: `ruleAlreadyCorrected` ends the one-correction episode at the next user message or when the agent calls a tool after the correction.

### Why

- In a long autonomous goal, a progress summary in the mandated status format was compared with the previous summary across hours of tool work, cut off mid-sentence on one near-duplicate pair, and the session was paused because one correction stayed spent until the next user message (senpi#4537, reported by jamestesting).

### Why an extension could not handle it

- The rule is a built-in extension; its lane, follow-up bound and nudge path are internal to it.

### Expected merge conflict zones

- LOW: `message_update` / `message_end` handlers in `ttsr/index.ts`.

<!-- changes-log: packages/coding-agent/src/core/extensions/builtin/changes.md -->
## 2026-10-27 - The repetitive-turns activation no longer says output was interrupted (senpi#4537)

### What changed

- `rule-activation/renderer.ts`: a `nudge` activation owned by `repetitive-turns` reads "Reply repeated the previous one; a corrective nudge was queued." Other stream rules keep "Output interrupted; a corrective nudge was queued."

### Why

- repetitive-turns now judges a completed reply and never cuts it off, so "Output interrupted" would be false (senpi#4537).

### Why an extension could not handle it

- The renderer belongs to the built-in rule-activation extension.

### Expected merge conflict zones

- LOW: `summaryLine` in `rule-activation/renderer.ts`.

<!-- changes-log: packages/coding-agent/CHANGELOG.md -->
### Fixed
- The repetitive-turns stream rule no longer cuts off a reply or pauses a long autonomous session when similar progress summaries are separated by tool work; it nudges after the complete reply and renews its one correction once the agent acts again ([#4538](https://github.com/code-yeongyu/senpi/pull/4538))
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ function summaryLine(details: RuleActivationDetails): string {
return `${details.rules.length} ${noun} matched and injected for this tool result.`;
}
case "ttsr":
return details.remediation === "nudge"
? "Output interrupted; a corrective nudge was queued."
: "Corrupted generation discarded; bounded provider retry started.";
if (details.remediation !== "nudge") return "Corrupted generation discarded; bounded provider retry started.";
// repetitive-turns judges a completed reply and never cuts it off (senpi#4537).
return details.owner === "repetitive-turns"
? "Reply repeated the previous one; a corrective nudge was queued."
: "Output interrupted; a corrective nudge was queued.";
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SessionEntry } from "../../../session-manager.ts";
import { hasToolCall } from "./repetitive-turns-lane.ts";
import { TTSR_INJECTION_CUSTOM_TYPE } from "./types.ts";

export const TTSR_LOOP_STOPPED_ENTRY_TYPE = "ttsr-loop-stopped";
Expand All @@ -16,14 +17,16 @@ function nudgedRules(entry: SessionEntry): readonly unknown[] {
}

/**
* Whether this rule already sent its one corrective follow-up since the user's last message. Read from the
* session, so a host that rebuilds the extension between turns cannot re-arm it (senpi#2967); the engine-wide
* turn bound covers every other source.
* Whether this rule already sent its one corrective follow-up in the current episode. Read from the session, so a
* host that rebuilds the extension between turns cannot re-arm it (senpi#2967). An episode ends at the user's next
* message, or when the agent acts again (calls a tool) after the correction: the correction worked, so a later
* violation in a long autonomous run gets its own correction instead of a pause (senpi#4537).
*/
export function ruleAlreadyCorrected(entries: readonly SessionEntry[], rule: string): boolean {
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index];
if (entry.type === "message" && entry.message.role === "user") return false;
if (entry.type === "message" && entry.message.role === "assistant" && hasToolCall(entry.message)) return false;
if (nudgedRules(entry).includes(rule)) return true;
}
return false;
Expand Down
53 changes: 27 additions & 26 deletions packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import { TtsrManager } from "./manager.ts";
import { getTtsrStreamDelta } from "./message-update.ts";
import { REPETITIVE_TURNS_RULE_CONTENT } from "./prompts.ts";
import { buildNudgeMessage, type TtsrNudgeMessage } from "./remediation.ts";
import { collectAssistantText, RepetitiveTurnsLane, readPersistedAssistantTexts } from "./repetitive-turns-lane.ts";
import {
collectAssistantText,
hasToolCall,
RepetitiveTurnsLane,
readPersistedAssistantTexts,
} from "./repetitive-turns-lane.ts";
import { compileRuleCondition } from "./rule-condition.ts";
import { buildStreamRemediation } from "./stream-remediation.ts";
import {
Expand Down Expand Up @@ -90,7 +95,6 @@ export default function ttsrExtension(pi: ExtensionAPI): void {
pendingRemediation = null;
pendingRuleNudge = null;
pendingNudge = null;
repetitiveTurns.disarm();
}
}

Expand All @@ -99,7 +103,6 @@ export default function ttsrExtension(pi: ExtensionAPI): void {
genState = createGenerationState();
pendingRemediation = null;
pendingRuleNudge = null;
repetitiveTurns.resetTurn();
watcher?.reset();
}

Expand All @@ -112,6 +115,17 @@ export default function ttsrExtension(pi: ExtensionAPI): void {
});
}

/**
* Queues a rule's corrective nudge for the settle point. The activation notice says a nudge was queued, so it is
* written only when the nudge will be sent; a rule already corrected in this episode is stopped at settle instead
* (senpi#4537).
*/
function queueRuleNudge(ctx: ExtensionContext, rule: string, content: string): void {
manager?.markInjectedByNames([rule]);
if (!ruleAlreadyCorrected(ctx.sessionManager.getEntries(), rule)) recordInjection(rule, [rule], "nudge");
pendingNudge = buildNudgeMessage(rule, content);
}

function ensureInitialized(ctx: ExtensionContext): void {
if (manager !== null) return;
disabled = pi.getFlag("ttsr-disabled") === true;
Expand Down Expand Up @@ -204,16 +218,6 @@ export default function ttsrExtension(pi: ExtensionAPI): void {
ctx.abort("system");
return;
}
if (source === "text") {
const canArm = pendingRuleNudge === null && !genState.abortClaimed;
if (repetitiveTurns.observeTextDelta(delta, canArm) && !genState.abortClaimed) {
genState.abortClaimed = true;
genState.abortOwner = "collapse-repetition";
genState.selfAbortAt = Date.now();
ctx.abort("system");
return;
}
}
const interrupting = outcome.ruleMatches.filter((rule) => rule.interruptMode === "always");
const rule = interrupting[0];
if (rule !== undefined && pendingRuleNudge === null && !genState.abortClaimed) {
Expand All @@ -225,31 +229,22 @@ export default function ttsrExtension(pi: ExtensionAPI): void {
}
});

pi.on("message_end", (event) => {
pi.on("message_end", (event, ctx) => {
// A completed message ends its stream: nothing it buffered can match a
// later message's deltas, so drop the tail windows now instead of holding
// them until the next turn_start reset.
manager?.resetBuffers();
if (genState.userCancelled) return undefined;
if (event.message.role !== "assistant") return undefined;
const turnText = collectAssistantText(event.message);
if (repetitiveTurns.armed) {
repetitiveTurns.commitArmedTurn(turnText);
manager?.markInjectedByNames([REPETITIVE_TURNS_RULE_NAME]);
recordInjection(REPETITIVE_TURNS_RULE_NAME, [REPETITIVE_TURNS_RULE_NAME], "nudge");
pendingNudge = buildNudgeMessage(REPETITIVE_TURNS_RULE_NAME, REPETITIVE_TURNS_RULE_CONTENT);
return undefined;
}
if (pendingRuleNudge !== null) {
const pending = pendingRuleNudge;
pendingRuleNudge = null;
manager?.markInjectedByNames([pending.rule.name]);
recordInjection(pending.rule.name, [pending.rule.name], "nudge");
pendingNudge = buildNudgeMessage(pending.rule.name, pending.rule.content);
queueRuleNudge(ctx, pending.rule.name, pending.rule.content);
return undefined;
}
if (pendingRemediation !== null) {
if (turnText !== null) repetitiveTurns.recordCompletedTurn(turnText);
if (turnText !== null) repetitiveTurns.completeTurn(turnText);
const pending = pendingRemediation;
pendingRemediation = null;
try {
Expand All @@ -268,7 +263,13 @@ export default function ttsrExtension(pi: ExtensionAPI): void {
return undefined;
}
}
if (turnText !== null) repetitiveTurns.recordCompletedTurn(turnText);
if (hasToolCall(event.message)) {
repetitiveTurns.noteToolWork();
return undefined;
}
if (turnText !== null && repetitiveTurns.completeTurn(turnText)) {
queueRuleNudge(ctx, REPETITIVE_TURNS_RULE_NAME, REPETITIVE_TURNS_RULE_CONTENT);
}
return undefined;
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import {
createRepetitiveTurnsState,
isNearDuplicateOfPreviousTurn,
normalizeTurnText,
REPETITIVE_TURNS_MIN_NORMALIZED_CHARS,
REPETITIVE_TURNS_RULE_NAME,
type RepetitiveTurnsState,
recordTurnText,
} from "./detectors/repetitive-turns.ts";

export function collectAssistantText(message: { readonly content: unknown }): string | null {
Expand All @@ -20,97 +17,76 @@ export function collectAssistantText(message: { readonly content: unknown }): st
return combined.length > 0 ? combined : null;
}

/** Whether an assistant message called a tool: the agent acted instead of only reporting (senpi#4537). */
export function hasToolCall(message: { readonly content: unknown }): boolean {
if (!Array.isArray(message.content)) return false;
return message.content.some(
(block: unknown) => typeof block === "object" && block !== null && "type" in block && block.type === "toolCall",
);
}

const RESTORED_TURN_LIMIT = 8;

interface SessionEntrySource {
readonly sessionManager: { getEntries(): readonly unknown[] };
}

/** The text-only replies since the agent last called a tool, the window the lane compares within. */
export function readPersistedAssistantTexts(ctx: SessionEntrySource): string[] {
const texts: string[] = [];
let texts: string[] = [];
for (const entry of ctx.sessionManager.getEntries()) {
if (typeof entry !== "object" || entry === null) continue;
if (!("type" in entry) || entry.type !== "message") continue;
const message: unknown = Reflect.get(entry, "message");
if (typeof message !== "object" || message === null) continue;
if (Reflect.get(message, "role") !== "assistant") continue;
const text = collectAssistantText({ content: Reflect.get(message, "content") });
const content: unknown = Reflect.get(message, "content");
if (hasToolCall({ content })) {
texts = [];
continue;
}
const text = collectAssistantText({ content });
if (text !== null) texts.push(text);
}
return texts.slice(-RESTORED_TURN_LIMIT);
}

/**
* Compares each completed text-only reply with the previous one. The rule is for a model that repeats its status
* instead of acting, so a reply that calls a tool resets the comparison: summaries separated by real work are
* progress reports, however similar their format (senpi#4537). The verdict comes after the reply has completed;
* the lane never cuts a reply off mid-stream.
*/
export class RepetitiveTurnsLane {
#state: RepetitiveTurnsState = createRepetitiveTurnsState();
#armed = false;
#recovering = false;
#currentTurnText = "";
#lastCompletedTurnText: string | null = null;
#previous: string | null = null;
#enabled = true;

configure(disabledRules: ReadonlySet<string>): void {
this.#enabled = !disabledRules.has(REPETITIVE_TURNS_RULE_NAME);
}

resetSession(): void {
this.#state = createRepetitiveTurnsState();
this.#armed = false;
this.#recovering = false;
this.#currentTurnText = "";
this.#lastCompletedTurnText = null;
this.#previous = null;
}

restoreFromHistory(assistantTexts: readonly string[]): void {
for (const text of assistantTexts) {
this.recordCompletedTurn(text);
}
this.#armed = false;
}

resetTurn(): void {
this.#armed = false;
this.#currentTurnText = "";
}

disarm(): void {
this.#armed = false;
}

get armed(): boolean {
return this.#armed;
}

observeTextDelta(delta: string, canArm: boolean): boolean {
if (this.#enabled && !this.#armed && !this.#recovering && canArm && this.#lastCompletedTurnText !== null) {
const candidate = normalizeTurnText(this.#currentTurnText + delta);
if (
candidate.length >= REPETITIVE_TURNS_MIN_NORMALIZED_CHARS &&
isNearDuplicateOfPreviousTurn(candidate, this.#lastCompletedTurnText)
) {
this.#armed = true;
}
this.#previous = normalizeTurnText(text);
}
this.#currentTurnText += delta;
return this.#armed;
}

commitArmedTurn(turnText: string | null): void {
this.#armed = false;
this.#recovering = true;
if (turnText !== null) {
this.#lastCompletedTurnText = normalizeTurnText(turnText);
}
noteToolWork(): void {
this.#previous = null;
}

recordCompletedTurn(turnText: string): void {
const normalized = normalizeTurnText(turnText);
this.#lastCompletedTurnText = normalized;
if (!this.#enabled) return;
const match = recordTurnText(this.#state, turnText);
if (match === null) {
if (this.#recovering) this.#recovering = false;
return;
}
this.#armed = true;
/** Records a completed text-only reply; true when it near-duplicates the previous one with no tool work between. */
completeTurn(turnText: string): boolean {
const candidate = normalizeTurnText(turnText);
const previous = this.#previous;
this.#previous = candidate;
if (!this.#enabled || previous === null) return false;
return (
candidate.length >= REPETITIVE_TURNS_MIN_NORMALIZED_CHARS && isNearDuplicateOfPreviousTurn(candidate, previous)
);
}
}
Loading
Loading