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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Pause active goals when an automatic continuation only inspects goal status and makes no actionable progress, preventing blocked goals from requeueing hidden continuations indefinitely.
- Add regression coverage for goal-only inspection continuation loops.

## 0.1.38 - 2026-07-19

- Make Pi the sole compaction owner: remove the extension's hardcoded 50k `turn_end` trigger so Pi's effective compaction settings determine threshold behavior and host/extension races cannot produce `Already compacted` or interrupt an active goal.
Expand Down
36 changes: 36 additions & 0 deletions src/goal-runtime-agent-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@ import {
} from "./goal-runtime-event-utils.js";
import type { GoalRuntimeAgentHandlerContext } from "./goal-runtime-event-handler-types.js";

function isGoalInspectionToolName(name: unknown): boolean {
return name === "get_goal" || (typeof name === "string" && name.endsWith("__get_goal"));
}

function hasOnlyGoalInspectionToolCalls(messages: readonly { role?: string; content?: unknown }[]): boolean {
let toolCallCount = 0;

for (const message of messages) {
if (message.role !== "assistant" || !Array.isArray(message.content)) {
continue;
}
for (const block of message.content) {
if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "toolCall") {
continue;
}
toolCallCount += 1;
if (!isGoalInspectionToolName((block as { name?: unknown }).name)) {
return false;
}
}
}

return toolCallCount > 0;
}

const BLOCKED_GOAL_INSPECTION_REASON =
"active goal continuation made no actionable progress; user input is required";

export function createAgentEventHandlers(deps: GoalRuntimeAgentHandlerContext) {
const { runtimeState, stateController, continuation, goalAccounting, resetErrorRecovery } = deps;

Expand Down Expand Up @@ -48,6 +76,14 @@ export function createAgentEventHandlers(deps: GoalRuntimeAgentHandlerContext) {
if (lastAssistant && recordAssistantContextOverflow(lastAssistant, ctx, deps)) {
return;
}
if (hasOnlyGoalInspectionToolCalls(event.messages)) {
stateController.applyGoalTransition(
{ kind: "recovery_pause", recoveryReason: BLOCKED_GOAL_INSPECTION_REASON },
ctx,
);
return;
}

resetErrorRecovery();
continuation.maybeContinue(ctx);
}) satisfies ExtensionHandler<AgentEndEvent>,
Expand Down
5 changes: 4 additions & 1 deletion src/goal-runtime-event-handler-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ export interface GoalRuntimeTurnHandlerContext extends StaleQueuedWorkEffectCont

export interface GoalRuntimeAgentHandlerContext extends StaleQueuedWorkEffectContext {
runtimeState: Pick<GoalRuntimeState, "agentRunSequence" | "staleQueuedWorkGuard">;
stateController: Pick<GoalStateController, "beginOverflowRecovery" | "flushGoalPersistence" | "pauseForAbort">;
stateController: Pick<
GoalStateController,
"applyGoalTransition" | "beginOverflowRecovery" | "flushGoalPersistence" | "pauseForAbort"
>;
continuation: Pick<
GoalRuntimeContinuationPort,
"clearPassthroughContinuationInput" | "maybeContinue"
Expand Down
30 changes: 30 additions & 0 deletions test/continuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,36 @@ test("agent_end, not agent_settled, drives deliberate per-run goal continuation"
assert.equal(harness.sentMessages.length, 1);
});

test("goal-only inspection turns pause active goals instead of looping", async () => {
const harness = createRuntimeHarness();
await harness.runCommand("ship it");
harness.sentMessages.length = 0;

const inspectionTurn = {
...assistantMessage("toolUse", { input: 20, output: 4 }),
content: [
{
type: "toolCall" as const,
id: "get-goal-call",
name: "get_goal",
arguments: {},
},
],
};

await harness.emit("agent_end", {
type: "agent_end",
messages: [inspectionTurn, assistantMessage("stop", { input: 30, output: 12 })],
});

assert.equal(harness.snapshot().goal?.status, "paused");
assert.equal(harness.sentMessages.length, 0);
assert.match(
harness.footerStatuses.at(-1) ?? "",
/Goal needs attention.*no actionable progress/,
);
});

test("agent end waits for idle before continuing active goals", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
try {
Expand Down