Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
8 changes: 7 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 @@ -127,6 +132,7 @@ function SubagentMetadata(props: {
<Text className="min-w-0 shrink text-xs text-foreground-muted" numberOfLines={1}>
{provider?.displayName ? `${provider.displayName} · ` : ""}
{modelLabel}
{usage ? ` · ${usage}` : ""}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</Text>
{workspace.map(({ label, value }) => (
// The row reads this label in place of the icon. collapsable keeps the
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
67 changes: 67 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,73 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("records subagent usage from task progress and completion", () =>
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,
}),
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 },
]);
}).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
19 changes: 13 additions & 6 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 @@ -4085,6 +4086,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 @@ -5903,16 +5912,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 +5972,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
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,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
8 changes: 8 additions & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,14 @@ export const OrchestrationV2Subagent = Schema.Struct({
"interrupted",
]),
progress: Schema.optional(Schema.String),
// Latest provider-reported snapshot. contextTokens is the subagent's current
// context size, not a cumulative total, so it must never be summed.
usage: Schema.optional(
Schema.Struct({
contextTokens: NonNegativeInt,
toolUses: NonNegativeInt,
}),
),
result: Schema.NullOr(Schema.String),
startedAt: Schema.NullOr(Schema.DateTimeUtc),
completedAt: Schema.NullOr(Schema.DateTimeUtc),
Expand Down
Loading