diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 04b655eeb..a93ed021e 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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 diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index c8b1b82e5..9d7f33230 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -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 ( diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d1ad895e4..bac444188 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md index 74eddee21..3e62ca5b0 100644 --- a/packages/coding-agent/src/prompts/system/workflow-notice.md +++ b/packages/coding-agent/src/prompts/system/workflow-notice.md @@ -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}} diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 3b4274e5f..9fbe9f3a0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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; + 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() diff --git a/packages/coding-agent/test/agent-session-advisor-suppression.test.ts b/packages/coding-agent/test/agent-session-advisor-suppression.test.ts index 2ac1780ed..363232740 100644 --- a/packages/coding-agent/test/agent-session-advisor-suppression.test.ts +++ b/packages/coding-agent/test/agent-session-advisor-suppression.test.ts @@ -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 { + 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");