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
16 changes: 12 additions & 4 deletions docs/orchestration-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,13 @@ not hide active tasks. Phase and log updates use the same live update path.

Each `onUpdate` has readable text and a complete `WorkflowProgress` snapshot in
`details`, including for RPC consumers. Snapshots are independent of later
mutations. The final result remains `WorkflowRunResult`; the current tool row
retains its last progress snapshot when displaying that result or an error.
Updates from late child cleanup are ignored once the tool call has settled.
mutations. The final result remains `WorkflowRunResult`; its `usage` field
contains the run's aggregate delegated usage, and `childSessionIds` identifies
the child transcripts covered by that parent-side total. Terminal workflow
errors carry the same fields on the thrown error, which the agent loop preserves
on the error tool result. The current tool row retains its last progress
snapshot when displaying that result or an error. Updates from late child
cleanup are ignored once the tool call has settled.
This projection describes assigned tasks and lifecycle states; child tool and
model output remain in the child trajectories.

Expand Down Expand Up @@ -171,7 +175,11 @@ it finishes, fails, or is stopped. It wakes an idle parent. The body opens with
summary such as `Lane lane_1a2b3c4d finished: 2 completed, 1 failed.`. That
summary is followed by duration, usage, child session ids and JSONL paths, any
dialogs the child raised that were auto-cancelled, failure reasons, and the
output. The text passes through the shared [tool-output retention
output. Usage accounting is independent of these notifications: each settled
lane turn writes one `step-subagent-usage` entry with its aggregate usage and
covered child session ids. Queued turns, interrupted turns and shutdown settle
usage without requiring a terminal notification, and the lane/turn key prevents
duplicate accounting. The text passes through the shared [tool-output retention
boundary](../packages/coding-agent/docs/tool-output-retention.md): oversized
output is saved under the session's `tool-output` directory and replaced by a
preview with its path. `subscribe: "progress"` adds throttled progress messages
Expand Down
17 changes: 12 additions & 5 deletions packages/agent-core/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
classifyProviderError,
EventStream,
type ToolResultMessage,
type Usage,
validateToolArguments,
} from "@step-harness/providers";
import { getDefaultStreamFn } from "./stream-fn.ts";
Expand Down Expand Up @@ -775,7 +776,7 @@ async function prepareToolCall(
} catch (error) {
return {
kind: "immediate",
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
result: createThrownToolResult(error),
isError: true,
};
}
Expand Down Expand Up @@ -816,7 +817,7 @@ async function executePreparedToolCall(
acceptingUpdates = false;
await Promise.all(updateEvents);
return {
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
result: createThrownToolResult(error),
isError: true,
};
} finally {
Expand Down Expand Up @@ -859,7 +860,7 @@ async function finalizeExecutedToolCall(
isError = afterResult.isError ?? isError;
}
} catch (error) {
result = createErrorToolResult(error instanceof Error ? error.message : String(error));
result = createThrownToolResult(error);
isError = true;
}
}
Expand All @@ -871,10 +872,16 @@ async function finalizeExecutedToolCall(
};
}

function createErrorToolResult(message: string): AgentToolResult<any> {
function createThrownToolResult(error: unknown): AgentToolResult<any> {
const fields = error && typeof error === "object" ? (error as { usage?: Usage; details?: unknown }) : undefined;
return createErrorToolResult(error instanceof Error ? error.message : String(error), fields?.usage, fields?.details);
}

function createErrorToolResult(message: string, usage?: Usage, details: unknown = {}): AgentToolResult<any> {
return {
content: [{ type: "text", text: message }],
details: {},
details,
...(usage ? { usage } : {}),
};
}

Expand Down
51 changes: 51 additions & 0 deletions packages/agent-core/test/agent-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,57 @@ describe("agentLoop with AgentMessage", () => {
});
});

it("preserves usage and details from a tool that throws after nested model work", async () => {
const usage = {
input: 10,
output: 20,
cacheRead: 2,
cacheWrite: 3,
totalTokens: 35,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.5 },
};
const tool: AgentTool = {
name: "workflow",
label: "Workflow",
description: "Run nodes",
parameters: Type.Object({}),
execute: async () => {
throw Object.assign(new Error("workflow failed"), { usage, details: { childSessionIds: ["child-1"] } });
},
};
let calls = 0;
const stream = agentLoop(
[createUserMessage("run")],
{ systemPrompt: "", messages: [], tools: [tool] },
{ model: createModel(), convertToLlm: identityConverter },
undefined,
() => {
const response = new MockAssistantStream();
response.push({
type: "done",
reason: calls++ === 0 ? "toolUse" : "stop",
message:
calls === 1
? createAssistantMessage(
[{ type: "toolCall", id: "wf", name: "workflow", arguments: {} }],
"toolUse",
)
: createAssistantMessage([{ type: "text", text: "failed" }]),
});
return response;
},
);
for await (const _event of stream) {
// consume
}
expect((await stream.result()).find((message) => message.role === "toolResult")).toMatchObject({
isError: true,
usage,
details: { childSessionIds: ["child-1"] },
content: [{ type: "text", text: "workflow failed" }],
});
});

it("should not execute tool calls from a length-truncated assistant message", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = [];
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/account-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Account queries the personal Step Plan usage endpoint with the stored CN/Global

CODING plans show 5-hour and weekly percentages **remaining** and reset times. Nonpositive upstream limits mean Unlimited. TOKEN plans show the remaining percentage for each subscription/top-up Credit bucket and reset/expiry times, without displaying raw Credit balances. Account and Status use compact 12-column bars with reduced-height blocks to represent remaining quota. A missing percentage, including a zero-total bucket, is shown as unavailable rather than 0% or 100%. Credit quantities remain exact decimal strings in the snapshot; percentages use the API's validated `used_percent` without converting Credit quantities to floating point. Unknown future bucket types are marked unsupported; partial bucket errors are not displayed as zero balance.

The current main-session summary is local and includes restored/inherited history and incremental compression usage. It excludes separate child-session files. Cache hit is cache reads divided by complete input; an empty denominator is Unknown.
The current main-session summary is local and includes restored/inherited history, incremental compression usage, and delegated usage recorded on parent tool results and per-turn background usage entries. It does not scan separate child-session files for this summary. Cache hit is cache reads divided by complete input; an empty denominator is Unknown.

Account responses have a 30-second memory cache and a 5-minute display lifetime. Identity/profile/endpoint/key changes invalidate it. Requests are single-flight, time out after 12 seconds and are cancelled when the panel closes or changes tabs/sessions. Attempt generations reject late responses. TTL/cooldown use a monotonic clock; HTTP Retry-After supports seconds and HTTP-date. Visible temporary failures retain a stale snapshot; 401/403 clear the snapshot without deleting credentials. No background polling runs.

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2035,7 +2035,7 @@ pi.registerTool({
});
```

**Usage accounting:** If a tool makes nested LLM calls, return their combined `Usage` as `usage`. Step persists it on the tool result and includes it in footer, `/session`, and RPC session totals. `tool_result` handlers can inspect or replace this value.
**Usage accounting:** If a tool makes nested LLM calls, return their combined `Usage` as `usage`. Step persists it on the tool result and includes it in footer, `/session`, and RPC session totals. `tool_result` handlers can inspect or replace this value. If nested work has already started when `execute` throws, the error object may carry `usage` and `details`; the agent loop preserves them on the error tool result.

**Signaling errors:** To mark a tool execution as failed (sets `isError: true` on the result and reports it to the LLM), throw an error from `execute`. Returning a value never sets the error flag regardless of what properties you include in the return object.

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ Response:
}
```

`tokens` and `cost` include assistant messages, usage reported by tools, and compaction/branch-summary generation across the full session. `contextUsage` contains the actual current context-window estimate used for compaction and footer display.
`tokens` and `cost` include assistant messages, usage reported by tools, per-turn background subagent usage entries, and compaction/branch-summary generation across the full session. `contextUsage` contains the actual current context-window estimate used for compaction and footer display.

`contextUsage` is omitted when no model or context window is available. `contextUsage.tokens` and `contextUsage.percent` are `null` immediately after compaction until a fresh post-compaction assistant response provides valid usage data.

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/session-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ interface Usage {

The exported `StopReason` type also includes `"pending"`, but that value is reserved for partial messages in streaming events. Terminal `done`/`error` messages replace it with a completion reason before step persists the assistant message, so `"pending"` should never appear in session JSONL.

Session totals and the cost breakdown use the shared [`usage-totals.ts`](../src/core/usage-totals.ts) ledger. It includes assistant, tool, compaction, branch-summary and incremental `step-auto-clm-usage` records; cumulative maintenance summaries are excluded. Token increments must contain nonnegative safe integers and must not overflow the accumulated total. A missing/invalid cost does not hide valid tokens. `SessionStats.tokens.input` excludes caches, which are reported separately; the [Account summary](account-usage.md) combines them into complete input.
Session totals and the cost breakdown use the shared [`usage-totals.ts`](../src/core/usage-totals.ts) ledger. It includes assistant, tool, compaction, branch-summary, per-turn `step-subagent-usage` and incremental `step-auto-clm-usage` records; cumulative maintenance summaries are excluded. Delegated runners may report only aggregate cost: in that case `cost.total` is authoritative and zero component fields mean the split was not provided, not that the total cost is zero. Token increments must contain nonnegative safe integers and must not overflow the accumulated total. A missing/invalid cost does not hide valid tokens. `SessionStats.tokens.input` excludes caches, which are reported separately; the [Account summary](account-usage.md) combines them into complete input.

### Extended Message Types (from the coding agent)

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ The interface has four main areas:
- **Startup header** - shortcuts, loaded context files, prompt templates, skills, and extensions
- **Messages** - user messages, assistant responses, tool calls, tool results, notifications, errors, and extension UI
- **Editor** - where you type; border color indicates the current thinking level
- **Footer** - working directory, session name, token/cache usage, cost, context usage, and current model. Totals include assistant responses, usage reported by tools, and summary generation.
- **Footer** - working directory, session name, token/cache usage, cost, context usage, and current model. Totals include assistant responses, delegated usage reported by subagents and workflows, and summary generation.

The editor can be replaced temporarily by built-in UI such as `/settings` or by custom extension UI.

Expand Down
47 changes: 47 additions & 0 deletions packages/coding-agent/src/core/usage-totals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,49 @@ export interface UsageTotals {
cost: number;
}

/** Common usage shape reported by delegated agents and workflows. */
export interface DelegatedUsage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
}

/** Sum usage defensively because restored and test records may predate delegated usage. */
export function sumDelegatedUsage(records: readonly { usage?: DelegatedUsage }[]): DelegatedUsage {
return records.reduce(
(total, record) => {
if (!record.usage) return total;
return {
input: total.input + record.usage.input,
output: total.output + record.usage.output,
cacheRead: total.cacheRead + record.usage.cacheRead,
cacheWrite: total.cacheWrite + record.usage.cacheWrite,
cost: total.cost + record.usage.cost,
};
},
{ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
);
}

export function hasDelegatedUsage(usage: DelegatedUsage): boolean {
return usage.input + usage.output + usage.cacheRead + usage.cacheWrite > 0 || usage.cost > 0;
}

/** Adapt delegated usage to the provider Usage persisted on tool results and custom entries. */
export function delegatedUsageToUsage(usage: DelegatedUsage): Usage {
return {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
totalTokens: usage.input + usage.output + usage.cacheRead + usage.cacheWrite,
// Runners report aggregate cost only; preserve that total without inventing a component split.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: usage.cost },
};
}

export function createUsageTotals(): UsageTotals {
return {
input: 0,
Expand Down Expand Up @@ -65,6 +108,10 @@ export function entryUsage(value: unknown): { raw?: unknown; expected: boolean;
return { raw: message.usage, expected: message.usage !== undefined, assistant: false };
if (entry.type === "compaction" || entry.type === "branch_summary")
return { raw: entry.usage, expected: entry.usage !== undefined, assistant: false };
if (entry.type === "custom" && entry.customType === "step-subagent-usage") {
const data = entry.data as Record<string, unknown> | undefined;
return { raw: data?.usage, expected: data?.usage !== undefined, assistant: false };
}
if (entry.type === "custom" && entry.customType === "step-auto-clm-usage") {
const data = entry.data as Record<string, unknown> | undefined;
return { raw: data?.usage, expected: true, assistant: false };
Expand Down
6 changes: 4 additions & 2 deletions packages/coding-agent/src/features/step-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { CONFIG_DIR_NAME } from "../config.ts";
import { CHILD_SESSION_ORIGIN } from "../core/child-session.ts";
import type { ExtensionAPI, ExtensionContext, ExtensionFactory, InlineExtension } from "../core/extensions/types.ts";
import type { NewSessionOptions } from "../core/session-manager.ts";
import { delegatedUsageToUsage, sumDelegatedUsage } from "../core/usage-totals.ts";
import { resolveStepAgentDir, resolveStepConfigDir } from "../step/environment.ts";
import {
isValidSubagentBackgroundTimeout,
Expand Down Expand Up @@ -548,6 +549,7 @@ export function makeToolResult(
return {
content: [{ type: "text", text }],
details,
...(details.results.length > 0 ? { usage: delegatedUsageToUsage(sumDelegatedUsage(details.results)) } : {}),
...(isError || details.results.some((record) => record.status === "failed" || record.status === "aborted")
? { isError: true }
: {}),
Expand Down Expand Up @@ -872,8 +874,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
);
}
const details: StepSubagentDetails = { ...lane.details, results: [...lane.details.results] };
return makeToolResult(
details,
return controlResult(
[
`Started background lane ${lane.id} (${preflight.run.mode}, ${preflight.run.tasks.length} task${preflight.run.tasks.length === 1 ? "" : "s"}; ${Math.round(lane.turnTimeoutMs / 1000)}s limit per turn).`,
// Without this the parent, told to "wait for the lane", has no wait
Expand All @@ -886,6 +887,7 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
]
.filter(Boolean)
.join(" "),
details,
);
},
renderCall: (params, theme) => {
Expand Down
22 changes: 21 additions & 1 deletion packages/coding-agent/src/features/subagent/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,27 @@ export async function runPreparedSubagent(
} catch (error) {
if (worktree && task.retainWorktree === false) await worktree.cleanup().catch(() => undefined);
const message = error instanceof Error ? error.message : String(error);
const failed = makeFailureResult(agent.name, task.task, message, agent.source);
const observed = records[index];
const failed = observed
? resultRecord(
agent.name,
task.task,
{
...observed,
stderr: message,
exitCode: 1,
stopReason: "error",
errorMessage: message,
},
{
agentSource: agent.source,
worktreePath: worktree?.path,
worktreeBranch: worktree?.branch,
},
)
: makeFailureResult(agent.name, task.task, message, agent.source);
// The observed record carries its previous status through resultRecord; the catch is the terminal failure.
failed.status = "failed";
records[index] = failed;
emit(mode);
reportFinished(failed, index);
Expand Down
Loading
Loading