fix(agent): stopped terminal yield wake loops

- Aborted the active agent loop synchronously when a terminal yield tool result finishes, so IRC-wake turns stop before another provider call.
- Added a regression covering idle IRC wake handling after a terminal yield.

Fixes #4963
This commit is contained in:
roboomp
2026-07-09 19:13:29 +00:00
parent 10846c255a
commit 0f3cf73a85
6 changed files with 148 additions and 9 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed aborted tool-result hooks from continuing into another provider call before the abort settled. ([#4963](https://github.com/can1357/oh-my-pi/issues/4963))
## [16.3.12] - 2026-07-08
### Added
+6
View File
@@ -1084,6 +1084,12 @@ async function runLoopBody(
}
}
// A tool hook may abort to mark the tool result as terminal (e.g. subagent yield).
// Stop before the next provider call; event listeners observe the result too late.
if (signal?.aborted) {
hasMoreToolCalls = false;
}
if (toolCalls.length > 0) {
pausedTurnContinuations = 0;
} else if (
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed kept-alive task subagents entering a repeated provider-call loop after an IRC wake and terminal `yield`. ([#4963](https://github.com/can1357/oh-my-pi/issues/4963))
## [16.3.13] - 2026-07-09
### Fixed
@@ -51,20 +51,20 @@ Decompose first, then {{#if taskBatch}}batch the independent leaves{{else}}issue
{{#if taskBatch}}
task(
context: "# Goal\nReview the auth diff...\n# Constraints\nRead-only...\n# Contract\nReturn findings as severity/file/line/fix...",
context: "# Goal\nReview the auth diff…\n# Constraints\nRead-only…\n# Contract\nReturn findings as severity/file/line/fix…",
tasks: [
{ id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection...\n# Acceptance\nReturn confirmed findings only..." },
{ id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance...\n# Acceptance\nReturn mismatches and exact prompt lines..." },
{ id: "AuthOwner", role: "Auth Storage Reviewer", assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nTrace credential selection…\n# Acceptance\nReturn confirmed findings only…" },
{ id: "PromptOwner", role: "Prompt Contract Reviewer", assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance…\n# Acceptance\nReturn mismatches and exact prompt lines…" },
]
)
{{else}}
task(
role: "Auth Storage Reviewer",
assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only..."
assignment: "# Target\npackages/ai/src/auth-storage.ts\n# Change\nReview the auth diff. Shared contract: read-only; return findings as severity/file/line/fix.\n# Acceptance\nReturn confirmed findings only…"
)
task(
role: "Prompt Contract Reviewer",
assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only..."
assignment: "# Target\npackages/coding-agent/src/prompts/**\n# Change\nCheck active-tool guidance. Shared contract: read-only; return mismatches and exact prompt lines.\n# Acceptance\nReturn confirmed findings only…"
)
{{/if}}
@@ -2222,8 +2222,8 @@ export class AgentSession {
this.#maybeAbortStreamingEdit(event);
this.#maybeInterruptGeminiHeaderRunaway(message, assistantMessageEvent);
});
// Per-tool TTSR reminders are folded into the matched tool's result via this hook.
this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
// Tool-result hook owns synchronous post-tool actions that must affect the current loop.
this.agent.afterToolCall = ctx => this.#afterToolCall(ctx);
this.agent.providerSessionState = this.#providerSessionState;
this.#syncAgentSessionId();
this.#syncTodoPhasesFromBranch();
@@ -3671,9 +3671,10 @@ export class AgentSession {
this.#planModeReminderAwaitingProgress = false;
}
}
if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
if (event.type === "tool_execution_end" && this.#isTerminalYieldToolResult(event)) {
this.#lastSuccessfulYieldToolCallId = event.toolCallId;
this.#yieldTerminationPending = true;
this.agent.abort();
}
// TTSR: Check for pattern matches on assistant text/thinking and tool argument deltas
@@ -4418,6 +4419,19 @@ export class AgentSession {
}
}
#afterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined {
if (
this.#isTerminalYieldToolResult({
toolName: ctx.toolCall.name,
isError: ctx.isError,
result: ctx.result,
})
) {
this.agent.abort();
}
return this.#ttsrAfterToolCall(ctx);
}
/** `afterToolCall` hook: fold any per-tool TTSR reminders into the result. */
#ttsrAfterToolCall(ctx: AfterToolCallContext): AfterToolCallResult | undefined {
const rules = this.#perToolTtsrInjections.get(ctx.toolCall.id);
@@ -10589,6 +10603,19 @@ export class AgentSession {
}
return COMPACTION_CHECK_NONE;
}
#isTerminalYieldToolResult(event: { toolName: string; isError?: boolean; result?: { details?: unknown } }): boolean {
if (event.toolName !== "yield" || event.isError) return false;
const details = event.result?.details;
if (!details || typeof details !== "object") return true;
const record = details as Record<string, unknown>;
return !(
record.status === "success" &&
Array.isArray(record.type) &&
record.type.length > 0 &&
record.type.every(item => typeof item === "string")
);
}
#assistantMessageHasSuccessfulYieldToolCall(assistantMessage: AssistantMessage, toolCallId: string): boolean {
const lastToolCall = assistantMessage.content
.slice()
@@ -18,7 +18,8 @@
* follow-up stays queued for the next explicit resume rather than auto-running.
*/
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core";
import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core";
import type { ToolCall } from "@oh-my-pi/pi-ai";
import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
@@ -29,6 +30,18 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { Snowflake, TempDir } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
interface MockYieldDetails {
status: "success";
data?: unknown;
type?: string | string[];
}
const mockYieldParameters = type({
result: "unknown",
"type?": "unknown",
});
const ADVISOR_TYPE = "advisor";
@@ -94,6 +107,56 @@ describe("AgentSession advisor auto-resume suppression", () => {
return { session, sessionManager, mock, streamStarted: started.promise };
}
function readYieldResultData(result: unknown): unknown {
if (!result || typeof result !== "object" || !("data" in result)) return undefined;
return result.data;
}
function isYieldType(value: unknown): value is string | string[] {
return (
typeof value === "string" ||
(Array.isArray(value) && value.length > 0 && value.every(item => typeof item === "string"))
);
}
function createMockYieldTool(): AgentTool<typeof mockYieldParameters, MockYieldDetails> {
return {
name: "yield",
label: "Yield",
description: "Mock yield tool",
parameters: mockYieldParameters,
execute: async (_toolCallId, params) => {
const details: MockYieldDetails = { status: "success", data: readYieldResultData(params.result) };
if (isYieldType(params.type)) details.type = params.type;
return {
content: [{ type: "text", text: "Result submitted." }],
details,
};
},
};
}
function createYieldMockResponse(args: { result: { data: unknown }; type?: string | string[] }): MockResponse {
const toolCall: ToolCall = {
type: "toolCall",
id: `call_yield_${Snowflake.next()}`,
name: "yield",
arguments: args,
};
return {
content: [toolCall],
stopReason: "toolUse",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
};
}
function advisorCard(content: string) {
return {
customType: ADVISOR_TYPE,
@@ -325,6 +388,41 @@ describe("AgentSession advisor auto-resume suppression", () => {
expect(mock.calls.length).toBe(2);
});
it("stops an idle IRC wake after a terminal yield", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
let providerCalls = 0;
const mock = createMockModel({
handler: () => {
providerCalls++;
if (providerCalls > 1) {
throw new Error("terminal yield must not start a second provider call");
}
return createYieldMockResponse({ result: { data: { ok: true } } });
},
});
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["Test"], tools: [createMockYieldTool()] },
streamFn: mock.stream,
});
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated({ "compaction.enabled": false });
const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`));
authStorages.push(authStorage);
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml"));
session = new AgentSession({ agent, sessionManager, settings, modelRegistry });
const msg: IrcMessage = { id: "m-yield", from: "peer", to: "me", body: "status?", ts: Date.now() };
const outcome = await session.deliverIrcMessage(msg);
await session.waitForIdle();
expect(outcome).toBe("woken");
expect(providerCalls).toBe(1);
expect(mock.calls.length).toBe(1);
});
it("flushes an accepted IRC aside on dispose instead of dropping it", async () => {
const { session, streamStarted } = await createParkedSession();
const running = session.prompt("do the thing");