Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
12 changes: 11 additions & 1 deletion apps/mobile/src/features/threads/SubagentRow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { resolveSubagentMetadata } from "@t3tools/client-runtime/state/subagent-display";
import {
formatSubagentUsage,
resolveSubagentMetadata,
} from "@t3tools/client-runtime/state/subagent-display";
import type { EnvironmentId, OrchestrationV2Subagent } from "@t3tools/contracts";
import type { ReactNode } from "react";
import { View } from "react-native";
Expand All @@ -24,6 +27,7 @@ type SubagentRowSubagent = Pick<
| "prompt"
| "status"
| "progress"
| "usage"
| "result"
>;

Expand Down Expand Up @@ -109,6 +113,7 @@ function SubagentMetadata(props: {
parent ? scopeProjectRef(environmentId, parent.projectId) : null,
);
const childProject = useProject(child ? scopeProjectRef(environmentId, child.projectId) : null);
const usage = formatSubagentUsage(subagent.usage ?? null);
const { modelLabel, workspace } = resolveSubagentMetadata({
model: subagent.model,
provider,
Expand All @@ -128,6 +133,11 @@ function SubagentMetadata(props: {
{provider?.displayName ? `${provider.displayName} · ` : ""}
{modelLabel}
</Text>
{usage ? (
<Text className="shrink-0 text-xs text-foreground-muted" numberOfLines={1}>
· {usage}
</Text>
) : null}
{workspace.map(({ label, value }) => (
// The row reads this label in place of the icon. collapsable keeps the
// view (and label) from being flattened away without making it a
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/thread-subagent-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export function ThreadSubagentGroup(props: {
result: live?.result ?? item.result,
progress: live?.progress ?? item.progress,
model: live?.model ?? null,
usage: live?.usage,
};
});
const grouped = agents.length > 1;
Expand Down
74 changes: 74 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6230,6 +6230,80 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("records subagent usage from task frames and clears it on resume", () =>
Effect.scoped(
Effect.gen(function* () {
const TASK_ID = "task-subagent-usage";
const TOOL_USE_ID = "toolu-subagent-usage";
const harness = yield* makeWakeHarness;
const now = yield* DateTime.now;
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-subagent-usage"),
text: "Run an auditor.",
attachments: [],
}),
);
const frames = [
makeSubagentTaskStartedFrame({
taskId: TASK_ID,
toolUseId: TOOL_USE_ID,
uuid: "00000000-0000-4000-8000-000000000361",
}),
claudeSdkFrame({
type: "system",
subtype: "task_progress",
task_id: TASK_ID,
tool_use_id: TOOL_USE_ID,
description: "Reading files",
usage: { total_tokens: 12_000, tool_uses: 2, duration_ms: 900 },
uuid: "00000000-0000-4000-8000-000000000362",
session_id: WAKE_NATIVE_SESSION,
}),
claudeSdkFrame({
type: "system",
subtype: "task_notification",
task_id: TASK_ID,
tool_use_id: TOOL_USE_ID,
status: "completed",
output_file: "",
summary: "Audit done.",
usage: { total_tokens: 18_500, tool_uses: 5, duration_ms: 2_400 },
uuid: "00000000-0000-4000-8000-000000000363",
session_id: WAKE_NATIVE_SESSION,
}),
// A resume reports fresh usage on its own frames.
makeSubagentTaskStartedFrame({
taskId: TASK_ID,
toolUseId: TOOL_USE_ID,
uuid: "00000000-0000-4000-8000-000000000365",
}),
makeResultFrame({
uuid: "00000000-0000-4000-8000-000000000364",
result: "The auditor finished.",
}),
];
for (const frame of frames) {
yield* Queue.offer(harness.sdkMessages, frame);
}
yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");

const usages = harness.events.flatMap((event) =>
event.type === "subagent.updated" ? [event.subagent.usage] : [],
);
assert.deepEqual(usages, [
undefined,
{ contextTokens: 12_000, toolUses: 2 },
{ contextTokens: 18_500, toolUses: 5 },
undefined,
]);
}).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
),
);

it.effect("extracts text from direct content-block subagent results", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
36 changes: 24 additions & 12 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3966,6 +3966,7 @@ export function makeClaudeAdapterV2(
// when this call registers the subagent.
readonly owner?: ActiveClaudeSubagent;
readonly progress?: string;
readonly usage?: { readonly total_tokens: number; readonly tool_uses: number };
Comment thread
Gigioxx marked this conversation as resolved.
Resolved
readonly result?: string;
readonly status: Extract<
OrchestrationV2ExecutionNode["status"],
Expand Down Expand Up @@ -4034,16 +4035,17 @@ export function makeClaudeAdapterV2(
const turnItemOrdinal =
existingSubagent?.turnItemOrdinal ??
(yield* resolveItemOrdinal(input.context, `${nativeItemId}:subagent`));
// A resumed subagent's previous final answer and progress no longer
// represent its outcome; the next task_progress/task_notification
// A resumed subagent's previous final answer, progress, and usage no
// longer represent its outcome; the next task_progress/task_notification
// carry the new ones.
const priorTask =
existingSubagent === undefined
? undefined
: isReopen
? (({ progress: _staleProgress, ...rest }) => ({ ...rest, result: null }))(
existingSubagent.task,
)
? (({ progress: _staleProgress, usage: _staleUsage, ...rest }) => ({
...rest,
result: null,
}))(existingSubagent.task)
: existingSubagent.task;
const task = {
...(priorTask ?? {
Expand Down Expand Up @@ -4085,6 +4087,14 @@ export function makeClaudeAdapterV2(
...(input.title === undefined ? {} : { title: input.title }),
...(input.model === undefined ? {} : { model: input.model }),
...(input.progress === undefined ? {} : { progress: input.progress }),
...(input.usage === undefined
? {}
: {
usage: {
contextTokens: input.usage.total_tokens,
toolUses: input.usage.tool_uses,
},
}),
...(input.result === undefined ? {} : { result: input.result }),
...(isReopen ? { startedAt: now } : {}),
completedAt: input.status === "running" ? null : (priorTask?.completedAt ?? now),
Expand Down Expand Up @@ -5118,7 +5128,11 @@ export function makeClaudeAdapterV2(
if (registered === undefined || registered.task.status === "running") {
return current;
}
const { progress: _staleProgress, ...priorTask } = registered.task;
const {
progress: _staleProgress,
usage: _staleUsage,
...priorTask
} = registered.task;
return new Map(current).set(message.task_id, {
...registered,
task: {
Expand Down Expand Up @@ -5903,16 +5917,13 @@ export function makeClaudeAdapterV2(
liveQuery.nativeThreadId,
message.task_id,
);
if (
progress.length > 0 &&
!context.ignoredTaskIds.has(message.task_id) &&
!isBackgroundTask
) {
if (!context.ignoredTaskIds.has(message.task_id) && !isBackgroundTask) {
yield* updateClaudeSubagentNode({
context,
taskId: message.task_id,
...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
progress,
...(progress.length === 0 ? {} : { progress }),
usage: message.usage,
status: "running",
});
}
Expand Down Expand Up @@ -5966,6 +5977,7 @@ export function makeClaudeAdapterV2(
context,
taskId: message.task_id,
...(message.tool_use_id === undefined ? {} : { toolUseId: message.tool_use_id }),
...(message.usage === undefined ? {} : { usage: message.usage }),
result: message.summary,
status:
message.status === "completed"
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/components/chat/SubagentTooltipContent.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type {
OrchestrationV2Subagent,
OrchestrationV2ThreadShell,
OrchestrationV2TurnItemStatus,
OrchestrationProjectShell,
ServerProvider,
ProviderDriverKind,
} from "@t3tools/contracts";
import {
formatSubagentUsage,
resolveSubagentMetadata,
subagentDetailPreview,
} from "@t3tools/client-runtime/state/subagent-display";
Expand All @@ -16,6 +18,7 @@ import {
CircleDashedIcon,
CircleXIcon,
FolderIcon,
GaugeIcon,
GitBranchIcon,
TerminalIcon,
} from "lucide-react";
Expand All @@ -38,9 +41,11 @@ export function SubagentTooltipContent(props: {
status: OrchestrationV2TurnItemStatus;
result?: string | null | undefined;
progress?: string | null | undefined;
usage?: OrchestrationV2Subagent["usage"] | null;
}) {
const { modelLabel, workspace: metadata } = resolveSubagentMetadata(props);
const preview = subagentDetailPreview(props);
const usage = formatSubagentUsage(props.usage ?? null);
const driver = props.provider?.driver ?? props.driver;
const working = ["running", "in_progress", "pending", "waiting"].includes(props.status);
const failed = ["failed", "error"].includes(props.status);
Expand Down Expand Up @@ -94,6 +99,12 @@ export function SubagentTooltipContent(props: {
</div>
);
})}
{usage ? (
<div className="flex min-w-0 items-center gap-2">
<GaugeIcon aria-hidden className="size-3 shrink-0" />
<span className="min-w-0 truncate tabular-nums">{usage}</span>
</div>
) : null}
{preview ? (
<div className="flex min-w-0 items-center gap-2">
<TerminalIcon aria-hidden className="size-3 shrink-0" />
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/ThreadRelationshipsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SubagentTooltipContent } from "./SubagentTooltipContent";
import { PullRequestGlyph } from "../pullRequest/pullRequestIcons";
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import {
isTerminalSubagentStatus,
projectedSubagentsToRuntime,
type RuntimeSubagent,
} from "@t3tools/client-runtime/state/subagentRuntime";
Expand Down Expand Up @@ -187,6 +188,8 @@ function liveSubagent<Agent extends RuntimeSubagent>(
progress: null,
result: null,
error: null,
// A settled task's usage is from that run too; a follow-up reports none.
usage: isTerminalSubagentStatus(agent.status) ? null : agent.usage,
};
}

Expand Down Expand Up @@ -389,6 +392,7 @@ export function ThreadRelationshipsPanel(props: {
status={agent.status}
result={agent.result}
progress={agent.progress}
usage={agent.usage}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
parentThread={currentThread ?? undefined}
childThread={node?.thread ?? undefined}
parentProject={currentProject}
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/chat/V2LifecycleRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subage
import {
ProviderDriverKind,
type OrchestrationV2Notification,
type OrchestrationV2Subagent,
type OrchestrationV2TurnItem,
type ProviderInstanceId,
type ServerProvider,
Expand Down Expand Up @@ -515,14 +516,19 @@ function SubagentTimelineLink(props: {
status={liveStatus}
result={agent?.result ?? props.result}
progress={agent?.progress ?? props.progress}
usage={agent?.usage}
/>
</ThreadHoverCardPopup>
</Tooltip>
);
}

function SubagentTimelineTooltip(
props: Parameters<typeof SubagentTimelineLink>[0] & { model: string | null; elapsed: ReactNode },
props: Parameters<typeof SubagentTimelineLink>[0] & {
model: string | null;
elapsed: ReactNode;
usage: OrchestrationV2Subagent["usage"];
},
) {
const environmentId = props.parentRef.environmentId;
const parent = useThreadShell(props.parentRef)?.source;
Expand All @@ -543,6 +549,7 @@ function SubagentTimelineTooltip(
status={props.status}
result={props.result}
progress={props.progress}
usage={props.usage}
parentThread={parent}
childThread={child}
parentProject={parentProject ?? undefined}
Expand Down
9 changes: 9 additions & 0 deletions packages/client-runtime/src/state/subagentDisplay.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type {
OrchestrationV2Subagent,
OrchestrationV2TurnItemStatus,
OrchestrationV2ThreadShell,
OrchestrationProjectShell,
ServerProvider,
} from "@t3tools/contracts";
import { formatModelSlugName, resolveSelectableModel } from "@t3tools/shared/model";
import { formatTokens } from "@t3tools/shared/usageFormat";
import { fileBasename } from "../markdownLinks.ts";
import { isTerminalSubagentStatus } from "./subagentRuntime.ts";

Expand Down Expand Up @@ -130,3 +132,10 @@ export function subagentDetailPreview(input: {
const compact = detail.replace(/\s+/gu, " ");
return compact.length > 280 ? `${compact.slice(0, 280).trimEnd()}…` : compact || null;
}

/** Context size, not a running total, so callers must not sum it across agents. */
export function formatSubagentUsage(usage: OrchestrationV2Subagent["usage"] | null): string | null {
if (!usage) return null;
const tools = `${usage.toolUses} ${usage.toolUses === 1 ? "tool use" : "tool uses"}`;
return `${formatTokens(usage.contextTokens)} context · ${tools}`;
}
15 changes: 3 additions & 12 deletions packages/client-runtime/src/state/subagentRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,6 @@ export type RuntimeSubagentStatus =
| "cancelled"
| "interrupted";

export interface SubagentUsage {
readonly totalTokens: number;
readonly inputTokens?: number;
readonly cachedInputTokens?: number;
readonly outputTokens?: number;
readonly reasoningOutputTokens?: number;
readonly toolUses?: number;
readonly durationMs?: number;
}

export interface SubagentActivityEntry {
readonly at: string;
readonly summary: string;
Expand All @@ -52,7 +42,7 @@ export interface RuntimeSubagent {
readonly effort: string | null;
readonly status: RuntimeSubagentStatus;
readonly activationCount: number;
readonly usage: SubagentUsage | null;
readonly usage: OrchestrationV2Subagent["usage"] | null;
readonly progress: string | null;
readonly lastToolName: string | null;
readonly result: string | null;
Expand Down Expand Up @@ -103,6 +93,7 @@ export function projectedSubagentsToRuntime(
readonly model: string | null;
readonly status: OrchestrationV2Subagent["status"];
readonly progress?: string | undefined;
readonly usage?: OrchestrationV2Subagent["usage"];
readonly result: string | null;
readonly startedAt: DateTime.Utc | null;
readonly completedAt: DateTime.Utc | null;
Expand All @@ -123,7 +114,7 @@ export function projectedSubagentsToRuntime(
effort: null,
status: subagent.status,
activationCount: 1,
usage: null,
usage: subagent.usage ?? null,
progress: subagent.progress ?? null,
lastToolName: null,
result: subagent.result,
Expand Down
Loading
Loading