Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@

### Changed

- `AgentLoopConfig.toolCallLeakRetries` is removed. The low-level loop no longer guesses a tool intent from markup in text; it records the structural classification (`classifyIncompleteAssistantTurn()`, now in `@step-harness/providers`) on every finished assistant message as `incomplete`, and hosts decide what to do about it.
- Plan mode is now entered only by the user, with `/plan` or `--plan`; the `enter_plan_mode` tool is removed. The default system prompt no longer carries a `# Planning` section. Entering plan mode appends one hidden `<collaboration_mode>` block with the plan method and plan path, and leaving it appends a short Default block, so the system prompt and tool block stay byte-identical across a plan cycle. Sessions recorded with an agent-initiated plan still restore. See [plan approval](docs/plan-approval.md#mode-announcements).
- Subagents are no longer an automatic escalation. The `subagent` tool description now carries an explicit-request gate and a delegation method (critical path first, self-contained briefs, disjoint write sets, no redoing delegated work), replacing the system prompt's `# Delegation` section; `# Coordination primitives` only routes between authorized primitives. Session-standing `/ultracode on` also authorizes proactive delegation. See [orchestration lifecycle](docs/orchestration-lifecycle.md#subagent-delegation-authorization).
- `--mode json` and `--mode rpc` now write only session **facts**: `message_update` and `tool_execution_update` are live frames — each superseded by `message_end` / `tool_execution_end` — and no longer appear in that stream. A harness that redirects one of them to a file therefore gets a record proportional to the work done, not to the tokens streamed; a subagent call that wrote 4,992 MiB of repeated child transcripts now writes a few KiB. Pass the new `--include-live-events` flag to get the frames back: a live viewer wants them, a log does not. See [JSON output](packages/coding-agent/docs/json.md) and [RPC](packages/coding-agent/docs/rpc.md).
- Replace permission tiers with Ask, Approve for Me (default), and Full Access. Full Access runs dangerous and unanalyzable commands after a session-only or remembered risk acknowledgment. Saved legacy permission modes reset to Ask for users to choose again; `--approval-mode strict` is no longer accepted. Both automatic tiers enable bounded model-error continuation by default; Ask does not.

### Fixed

- Recover a normal assistant response that contains neither visible text nor an executable tool call: keep the original message in history unchanged, append one hidden user-role recovery prompt, and continue. Recovery is bounded by a streak of consecutive empty turns. Only a turn with visible text or an executed tool call resets it, and so does any user input, so a long autonomous run is not ended by one transient empty response and a provider error in between does not refill the budget. Automatic compaction runs first when the response also exceeded the context window.
- A run that ends without a usable answer reports it on `agent_settled` as `failure` (reason and message), instead of rewriting the assistant message's provider stop reason, so a settled run no longer means success by itself. Subagent lanes and goal continuation act on it, and print/JSON pick their exit code from the same classification, so no consumer decides success from `stopReason` alone. `assistantTerminalFailure()` in `@step-harness/providers` applies the same rule to a message that has not settled yet. The hidden recovery message records the entry it answers in `details.recoversEntryId`.
- Summary requests now use the provider's model output limit and remaining-context sizing instead of fixed compaction/branch caps or reserve-derived budgets. Existing reserve arguments remain accepted for call compatibility; automatic trigger and branch source-selection settings are unchanged.
- Compaction and branch-summary inputs omit provider thinking blocks while retaining visible replies, tool calls, diagnostic output, and existing skill-retention rules. Original session history is unchanged. These changes adapt Pi commits `15e7bdc` and `6fcb486` (upstream PR #11036).

Expand Down
25 changes: 25 additions & 0 deletions apps/cli/src/ui/runtime/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
type AgentSessionEvent,
createCompactionSummaryMessage,
getStepGoalStatus,
INCOMPLETE_TURN_RECOVERY_ATTEMPTS,
INCOMPLETE_TURN_RECOVERY_CUSTOM_TYPE,
theme,
} from "@step-harness/coding-agent";
import { Spacer, Text } from "@step-harness/pi-tui";
Expand Down Expand Up @@ -135,6 +137,26 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio

case "message_start":
if (event.message.role === "custom") {
// This event starts the actual recovery input, unlike the earlier turn
// classification. Use past tense because showStatus adds a history row.
if (event.message.customType === INCOMPLETE_TURN_RECOVERY_CUSTOM_TYPE) {
const details = event.message.details;
const recordedAttempt =
typeof details === "object" && details !== null && "attempt" in details ? details.attempt : undefined;
const recordedMax =
typeof details === "object" && details !== null && "maxAttempts" in details
? details.maxAttempts
: undefined;
const attempt =
typeof recordedAttempt === "number" && Number.isInteger(recordedAttempt) && recordedAttempt > 0
? recordedAttempt
: 1;
const maxAttempts =
typeof recordedMax === "number" && Number.isInteger(recordedMax) && recordedMax > 0
? recordedMax
: INCOMPLETE_TURN_RECOVERY_ATTEMPTS;
ctx.showStatus(`Retry attempt ${attempt}/${maxAttempts} started after an empty model turn.`);
}
ctx.addMessageToChat(event.message);
ctx.redraw.requestRender();
} else if (event.message.role === "user") {
Expand Down Expand Up @@ -346,6 +368,9 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio
break;

case "agent_settled":
// A settled run is not automatically a successful one, and the transcript line
// alone reads as a remark about one turn. Say the run ended, and why.
if (event.failure) ctx.showError(event.failure.message);
await ctx.checkShutdownRequested();
break;

Expand Down
8 changes: 8 additions & 0 deletions apps/cli/src/ui/view/transcript/assistant-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Text,
} from "@step-harness/pi-tui";
import type { AssistantMessage } from "@step-harness/providers";
import { INCOMPLETE_TURN_MESSAGES } from "@step-harness/providers";
import { createLiveMarkdownTransform } from "./markdown-transform.ts";

const OSC133_ZONE_START = "\x1b]133;A\x07";
Expand Down Expand Up @@ -219,6 +220,13 @@ export class AssistantMessageComponent extends Container {
const errorMsg = message.errorMessage || "Unknown error";
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), this.outputPad, 0));
} else if (message.incomplete && INCOMPLETE_TURN_MESSAGES[message.incomplete]) {
// A turn's classification cannot prove that recovery started or the run
// ended. The live host presents those actions from their own events.
this.contentContainer.addChild(new Spacer(1));
this.contentContainer.addChild(
new Text(theme.fg("dim", INCOMPLETE_TURN_MESSAGES[message.incomplete]), this.outputPad, 0),
);
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion apps/cli/src/ui/view/transcript/step-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ function makeNativeRunMessage(message: AssistantMessage, content: VisibleAssista
// an extra error/length row of its own.
stopReason: "stop",
errorMessage: undefined,
incomplete: undefined,
};
}

Expand Down Expand Up @@ -707,7 +708,8 @@ export class StepAssistantMessageComponent extends AssistantMessageComponent {
const hasToolCalls = message.content.some((content) => content.type === "toolCall");
const needsStatus =
message.stopReason === "length" ||
(!hasToolCalls && (message.stopReason === "aborted" || message.stopReason === "error"));
(!hasToolCalls &&
(message.stopReason === "aborted" || message.stopReason === "error" || !!message.incomplete));
if (!needsStatus) {
this.nativeStatusComponent = undefined;
return;
Expand All @@ -716,6 +718,7 @@ export class StepAssistantMessageComponent extends AssistantMessageComponent {
const statusMessage = makeNativeRunMessage(message, []);
statusMessage.stopReason = message.stopReason;
statusMessage.errorMessage = message.errorMessage;
statusMessage.incomplete = message.incomplete;
if (!this.nativeStatusComponent) {
this.nativeStatusComponent = new AssistantMessageComponent(
statusMessage,
Expand Down
45 changes: 44 additions & 1 deletion apps/cli/test/assistant-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";

function createAssistantMessage(
content: AssistantMessage["content"],
overrides: Partial<Pick<AssistantMessage, "stopReason">> = {},
overrides: Partial<Pick<AssistantMessage, "stopReason" | "incomplete">> = {},
): AssistantMessage {
return {
role: "assistant",
Expand All @@ -28,6 +28,7 @@ function createAssistantMessage(
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: overrides.stopReason ?? "stop",
incomplete: overrides.incomplete,
timestamp: Date.now(),
};
}
Expand Down Expand Up @@ -73,6 +74,48 @@ describe("AssistantMessageComponent", () => {
expect(rendered).toContain("Response was truncated before completion.");
});

test("renders an incomplete turn without assuming a run outcome", () => {
initTheme("dark");

const component = new AssistantMessageComponent(
createAssistantMessage([{ type: "thinking", thinking: "private reasoning" }], {
stopReason: "stop",
incomplete: "no_visible_output",
}),
true,
);
const rendered = stripAnsi(component.render(80).join("\n"));

expect(rendered).toContain("The model returned no visible answer or executable tool call.");
expect(rendered).not.toMatch(/retried|retry limit|run ended/);
});

test.each([1, 2])("classification streak %s does not prove recovery or termination", (consecutiveTurns) => {
initTheme("dark");

const message = createAssistantMessage([{ type: "thinking", thinking: "private reasoning" }], {
stopReason: "stop",
incomplete: "no_visible_output",
});
message.diagnostics = [
{ type: "incomplete_turn", timestamp: 1, details: { consecutiveTurns, maxAttempts: 1 } },
];
const rendered = stripAnsi(new AssistantMessageComponent(message, true).render(80).join("\n"));

expect(rendered).toContain("The model returned no visible answer or executable tool call.");
expect(rendered).not.toMatch(/retried|retry limit|run ended/);
});

test("renders a completed turn without a status line", () => {
initTheme("dark");

const component = new AssistantMessageComponent(createAssistantMessage([{ type: "text", text: "answer" }]), true);
const rendered = stripAnsi(component.render(80).join("\n"));

expect(rendered).toContain("answer");
expect(rendered).not.toContain("no visible answer");
});

test("coalesces adjacent thinking blocks into one hidden thinking label", () => {
initTheme("dark");

Expand Down
50 changes: 50 additions & 0 deletions apps/cli/test/session-events-working-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,56 @@ import type { AssistantMessage } from "@step-harness/providers";
import { WorkingOutputTracker } from "../src/ui/view/chrome/status-indicator.ts";
import type { RuntimeContext } from "../src/ui/runtime/context.ts";
import { handleSessionEvent } from "../src/ui/runtime/session-events.ts";
import { INCOMPLETE_TURN_RECOVERY_CUSTOM_TYPE } from "@step-harness/coding-agent";

test("announces only a recovery that actually starts and uses its attempt metadata", async () => {
const showStatus = vi.fn();
const ctx = {
isInitialized: true,
footer: { invalidate: vi.fn() },
showStatus,
addMessageToChat: vi.fn(),
redraw: { requestRender: vi.fn() },
} as unknown as RuntimeContext;
await handleSessionEvent(ctx, {
type: "message_start",
message: { role: "custom", customType: "other", content: "", display: false, timestamp: 1 },
});
expect(showStatus).not.toHaveBeenCalled();
await handleSessionEvent(ctx, {
type: "message_start",
message: {
role: "custom",
customType: INCOMPLETE_TURN_RECOVERY_CUSTOM_TYPE,
content: "",
display: false,
timestamp: 2,
details: { attempt: 2, maxAttempts: 3 },
},
});
expect(showStatus).toHaveBeenCalledExactlyOnceWith("Retry attempt 2/3 started after an empty model turn.");
expect(showStatus.mock.calls[0][0]).not.toContain("retrying");
});

test("settlement displays a final failure even when it equals the turn explanation", async () => {
const showError = vi.fn();
const ctx = {
isInitialized: true,
footer: { invalidate: vi.fn() },
showError,
checkShutdownRequested: vi.fn(),
} as unknown as RuntimeContext;
const failure = {
kind: "incomplete_turn" as const,
reason: "no_visible_output" as const,
message: "The model returned no visible answer or executable tool call.",
};
await handleSessionEvent(ctx, { type: "agent_settled", failure });
expect(showError).toHaveBeenCalledExactlyOnceWith(failure.message);
showError.mockClear();
await handleSessionEvent(ctx, { type: "agent_settled" });
expect(showError).not.toHaveBeenCalled();
});

test.each(["agent_end", "auto_clm_end", "compaction_end"] as const)(
"clears terminal progress on %s even after the setting is disabled",
Expand Down
12 changes: 8 additions & 4 deletions docs/completion-check.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,14 @@ review is not sent. Budget exhaustion and final-output exit codes are unchanged.
After the follow-up budget is exhausted, a valid final answer still returns exit
code **0** even if Git conditions remain unsatisfied. The canonical task verifier
owns the score; an ordinary failed task must not become an infrastructure error
that resamples the attempt. Missing/thinking-only final output returns **2** with
an explicit incomplete diagnostic. Existing terminal denials and final assistant
errors keep exit code **1**. A successful external-approval handoff produced by the
latest prompt keeps exit code **0** even when the last message is its tool result.
that reruns the attempt. Missing/thinking-only final output returns **2** with
an explicit incomplete diagnostic. When either completion feature is enabled,
print mode disables the generic incomplete-turn recovery for the whole
invocation so the checker's bounded follow-up budget remains the only recovery
path, including a mid-run empty turn. Existing terminal
denials and final assistant errors keep exit code **1**. A successful
external-approval handoff produced by the latest prompt keeps exit code **0**
even when the last message is its tool result.
An earlier handoff does not excuse missing output from a later explicit prompt:
that prompt must provide final text or a fresh handoff, otherwise the exit code is
**2**. Automatic follow-ups stay blocked for the rest of the invocation.
Expand Down
2 changes: 1 addition & 1 deletion docs/orchestration-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ subagent runner passes no ACL: its children read as top-level and each started a
further wave of workflow agents, multiplying concurrent provider streams well past
the account limit and producing cascading rate-limit failures.

A turn settles on `agent_settled`, a failed prompt ack, or child exit. Because a
A turn settles on `agent_settled`, a failed prompt ack, or child exit. Settlement is not success: a run that produced no usable answer reports it on `agent_settled.failure`, and the lane's exit code follows that rather than the absence of an error in the transcript. Because a
child that stays alive without emitting any of those would strand its parent
indefinitely, and `executeSubagent` awaits every lane, each turn also carries an
idle watchdog. The budget is measured against child output rather than wall clock:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ a reader reconstructs the transcript from `message_end` exactly as before.
and the loop can emit more `message_end` events than it lists (a resampled
tool-call leak, a throwing `shouldStopAfterTurn`). A reader detects a truncated
run by a message that never got its `message_end`, not by comparing counts.
(The leak resampling has since been removed; an incomplete turn is now reported
on the message and the settled run, so only the throwing hook can still do this.)

This is boundary-payload deduplication, **not** an exactly-once record. What still
repeats, and why it stays:
Expand Down
6 changes: 6 additions & 0 deletions docs/tui-rendering-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@

产品侧接线在 `apps/cli/src/ui/`:`interactive-mode.ts`(组合根)、`runtime/input-dispatch.ts`(键位动作注册与提交路由)、`runtime/session-events.ts`(会话事件 → 组件树)、`runtime/redraw.ts`(渲染门面)。

### 空回合与运行失败

`AssistantMessage.incomplete` 仅说明回合没有可见回答或可执行调用,transcript 用中性说明展示;分类诊断不证明重试已开始,也不证明运行已结束。

`runtime/session-events.ts` 在隐藏恢复消息的 `message_start` 时展示开始重试的历史行,次数来自消息的 `details.attempt/maxAttempts`。`agent_settled.failure` 则通过 `showError` 展示红色终态错误,即使其文案与回合说明相同也不省略。历史重载保留回合分类,不从连续空回合数推断运行终态。

## 1. 终端协商与输出原语

`ProcessTerminal.start`(`terminal.ts:161`)按顺序做:
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ const stream = agentLoop(

`shouldStopAfterTurn` runs after `turn_end` is emitted and after the assistant response and any tool executions have completed normally. If it returns `true`, the loop emits `agent_end` and exits before polling steering or follow-up queues, and before starting another LLM call. It does not abort the provider stream, does not cancel running tools, and does not alter the assistant message stop reason. The `AgentOptions` callback also receives the active run's `AbortSignal` as its second argument.

To change the context right before a provider request instead of stopping, set `beforeModelRequest` (on `AgentLoopConfig`, `AgentOptions`, or the `Agent` instance). It runs before every provider request: `phase: "initial"` for the first request of a prompt or `continue()`, and `phase: "follow-up"` for each later request. The context it receives already contains the prompts, completed tool results, and steering injected for that request. Return `{ context, model, thinkingLevel }` replacements, or `undefined` to send the context unchanged. Tool-call markup resampling of the same request does not call it again. On follow-up requests that have not received steering yet, the loop polls steering once more after the hook, so input queued during a long hook such as compaction reaches the same request. `prepareNextTurn` keeps its existing timing: after `turn_end`, before steering is polled.
To change the context right before a provider request instead of stopping, set `beforeModelRequest` (on `AgentLoopConfig`, `AgentOptions`, or the `Agent` instance). It runs before every provider request: `phase: "initial"` for the first request of a prompt or `continue()`, and `phase: "follow-up"` for each later request. The context it receives already contains the prompts, completed tool results, and steering injected for that request. Return `{ context, model, thinkingLevel }` replacements, or `undefined` to send the context unchanged. On follow-up requests that have not received steering yet, the loop polls steering once more after the hook, so input queued during a long hook such as compaction reaches the same request. `prepareNextTurn` keeps its existing timing: after `turn_end`, before steering is polled.

An assistant turn is incomplete when it ends normally without visible text or an executable tool call, or when it reports `toolUse` without a tool call. The loop records that on the finished message as `AssistantMessage.incomplete`, using `classifyIncompleteAssistantTurn()` from `@step-harness/providers`; it reads text only to decide whether any is visible, never to interpret it, and it does not retry the turn or change the stop reason. Product sessions decide whether to recover them, and report a terminal one as their own run outcome. The former `toolCallLeakRetries` loop option was removed because text matching cannot prove that a turn meant to call a tool.

When you use the `Agent` class, assistant `message_end` processing is treated as a barrier before tool preflight begins. That means `beforeToolCall` sees agent state that already includes the assistant message that requested the tool call.

Expand Down
Loading
Loading