fix(agents): silence empty group model turns

This commit is contained in:
Peter Steinberger
2026-04-26 06:25:55 +01:00
parent d531760898
commit 0e58654dba
6 changed files with 116 additions and 11 deletions

View File

@@ -272,7 +272,7 @@ Notes:
- Surfaces that provide explicit mentions still pass; patterns are a fallback.
- Per-agent override: `agents.list[].groupChat.mentionPatterns` (useful when multiple agents share a group).
- Mention gating is only enforced when mention detection is possible (native mentions or `mentionPatterns` are configured).
- Always-on groups where silent replies are allowed treat a clean empty model reply as silent, equivalent to `NO_REPLY`. Mention-gated groups and direct chats still treat empty replies as a failed agent turn.
- Groups where silent replies are allowed treat clean empty or reasoning-only model turns as silent, equivalent to `NO_REPLY`. Direct chats still treat empty replies as a failed agent turn.
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.

View File

@@ -298,6 +298,44 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
);
});
it("returns NO_REPLY without retrying reasoning-only assistant turns when silence is allowed", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
stopReason: "end_turn",
provider: "openai-codex",
model: "gpt-5.5",
content: [
{
type: "thinking",
thinking: "internal reasoning",
thinkingSignature: JSON.stringify({ id: "rs_silent_group", type: "reasoning" }),
},
],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
}),
);
const result = await runEmbeddedPiAgent({
...overflowBaseRunParams,
allowEmptyAssistantReplyAsSilent: true,
provider: "openai-codex",
model: "gpt-5.5",
runId: "run-reasoning-only-silent",
});
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(mockedLog.warn).not.toHaveBeenCalledWith(
expect.stringContaining("reasoning-only assistant turn detected"),
);
expect(result.payloads).toEqual([{ text: "NO_REPLY" }]);
expect(result.meta.terminalReplyKind).toBe("silent-empty");
expect(result.meta.livenessState).toBe("working");
});
it("does not retry or warn on reasoning-only turns when a messaging tool already delivered", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
@@ -1184,6 +1222,44 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
).toBe(false);
});
it("treats reasoning-only assistant turns as silent only when the caller allows it", () => {
const attempt = makeAttemptResult({
assistantTexts: [],
lastAssistant: {
role: "assistant",
stopReason: "end_turn",
provider: "openai-codex",
model: "gpt-5.5",
content: [
{
type: "thinking",
thinking: "internal reasoning",
thinkingSignature: JSON.stringify({ id: "rs_silent_helper", type: "reasoning" }),
},
],
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
});
expect(
shouldTreatEmptyAssistantReplyAsSilent({
allowEmptyAssistantReplyAsSilent: true,
payloadCount: 0,
aborted: false,
timedOut: false,
attempt,
}),
).toBe(true);
expect(
shouldTreatEmptyAssistantReplyAsSilent({
allowEmptyAssistantReplyAsSilent: false,
payloadCount: 0,
aborted: false,
timedOut: false,
attempt,
}),
).toBe(false);
});
it("does not treat error or side-effect empty turns as silent", () => {
const errorAttempt = makeAttemptResult({
assistantTexts: [],

View File

@@ -329,6 +329,37 @@ function isEmptyResponseAssistantTurn(params: {
return true;
}
function isNonVisibleAssistantTurnEligibleForSilentReply(params: {
payloadCount: number;
attempt: Pick<
IncompleteTurnAttempt,
"assistantTexts" | "currentAttemptAssistant" | "lastAssistant"
>;
}): boolean {
if (isEmptyResponseAssistantTurn(params)) {
return true;
}
if (params.payloadCount !== 0) {
return false;
}
if (params.attempt.assistantTexts.join("\n\n").trim().length > 0) {
return false;
}
const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant;
if (!assistant || assistant.stopReason === "error") {
return false;
}
if (
isIncompleteTerminalAssistantTurn({
hasAssistantVisibleText: false,
lastAssistant: assistant,
})
) {
return false;
}
return isReasoningOnlyAssistantTurn(assistant);
}
function shouldSkipPlanningOnlyRetry(params: {
aborted: boolean;
timedOut: boolean;
@@ -358,7 +389,7 @@ export function shouldTreatEmptyAssistantReplyAsSilent(params: {
if (hasCommittedUserVisibleToolDelivery(params.attempt)) {
return false;
}
return isEmptyResponseAssistantTurn({
return isNonVisibleAssistantTurnEligibleForSilentReply({
payloadCount: params.payloadCount,
attempt: params.attempt,
});

View File

@@ -90,8 +90,7 @@ vi.mock("./groups.js", () => ({
return {
activation,
canUseSilentReply,
allowEmptyAssistantReplyAsSilent:
activation === "always" && params.silentReplyPolicy === "allow",
allowEmptyAssistantReplyAsSilent: params.silentReplyPolicy === "allow",
};
},
),
@@ -284,7 +283,7 @@ describe("runPreparedReply media-only handling", () => {
);
});
it("propagates empty-assistant silence only for always-on group runs", async () => {
it("propagates non-visible assistant silence for group runs", async () => {
await runPreparedReply(baseParams());
let call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0];
@@ -297,7 +296,7 @@ describe("runPreparedReply media-only handling", () => {
);
call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0];
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(false);
expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true);
});
it("does not propagate empty-assistant silence for direct runs", async () => {

View File

@@ -124,7 +124,7 @@ describe("group runtime loading", () => {
expect(rewritten).not.toContain("Be extremely selective");
});
it("marks empty assistant replies silent only for always-on groups with silence allowed", async () => {
it("marks non-visible assistant replies silent for groups with silence allowed", async () => {
const groups = await import("./groups.js");
expect(
@@ -139,7 +139,7 @@ describe("group runtime loading", () => {
defaultActivation: "mention",
silentReplyPolicy: "allow",
}).allowEmptyAssistantReplyAsSilent,
).toBe(false);
).toBe(true);
expect(
groups.resolveGroupSilentReplyBehavior({
@@ -147,7 +147,7 @@ describe("group runtime loading", () => {
defaultActivation: "always",
silentReplyPolicy: "allow",
}).allowEmptyAssistantReplyAsSilent,
).toBe(false);
).toBe(true);
expect(
groups.resolveGroupSilentReplyBehavior({

View File

@@ -269,8 +269,7 @@ export function resolveGroupSilentReplyBehavior(params: {
return {
activation,
canUseSilentReply,
allowEmptyAssistantReplyAsSilent:
activation === "always" && params.silentReplyPolicy === "allow",
allowEmptyAssistantReplyAsSilent: params.silentReplyPolicy === "allow",
};
}