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:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user