@bermudi/pi-delegate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/timer.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Node clamps timer delays larger than the signed 32-bit limit (~24.8 days)
2
+ // to ~1ms. Long waits are therefore scheduled against an absolute deadline in
3
+ // safe-sized chunks, so an intentionally patient timeout or inactivity window
4
+ // cannot be collapsed into an immediate fire.
5
+
6
+ /** Maximum delay a single `setTimeout` can represent before Node clamps it to
7
+ * ~1ms (the signed 32-bit millisecond limit). */
8
+ export const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
9
+
10
+ /**
11
+ * Invoke `onDeadline` at the absolute `deadline` timestamp, re-arming the timer
12
+ * in sub-`MAX_TIMER_DELAY_MS` chunks so Node's clamp cannot turn a long wait
13
+ * into an immediate fire.
14
+ *
15
+ * Returns a `clear()` that cancels the pending timer. `clear()` is idempotent
16
+ * and also suppresses a callback already dequeued but not yet run, so a deadline
17
+ * firing after cancellation is a no-op rather than a spurious action — callers
18
+ * therefore do not need their own "already settled / already aborted" re-check
19
+ * inside the terminal callback.
20
+ */
21
+ export function scheduleDeadline(
22
+ deadline: number,
23
+ onDeadline: () => void,
24
+ ): () => void {
25
+ let timer: ReturnType<typeof setTimeout> | undefined;
26
+ let cleared = false;
27
+ const arm = (): void => {
28
+ if (cleared) return;
29
+ const remaining = deadline - Date.now();
30
+ const delay = Math.min(Math.max(remaining, 0), MAX_TIMER_DELAY_MS);
31
+ timer = setTimeout(() => {
32
+ if (cleared) return;
33
+ if (Date.now() < deadline) {
34
+ arm();
35
+ } else {
36
+ onDeadline();
37
+ }
38
+ }, delay);
39
+ };
40
+ arm();
41
+ return () => {
42
+ cleared = true;
43
+ if (timer !== undefined) clearTimeout(timer);
44
+ timer = undefined;
45
+ };
46
+ }
package/tools.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ import {
3
+ createBashTool,
4
+ createEditTool,
5
+ createFindTool,
6
+ createGrepTool,
7
+ createLsTool,
8
+ createReadTool,
9
+ createWriteTool,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { DEFAULT_TOOLS, READONLY_TOOLS } from "./constants.ts";
12
+
13
+ /** Shorthand → concrete tool list. `*` = full agent (bash subsumes search);
14
+ * `ro` = read-only scout (search without shell). */
15
+ const TOOL_GROUPS: Record<string, string[]> = {
16
+ "*": DEFAULT_TOOLS,
17
+ ro: READONLY_TOOLS,
18
+ };
19
+
20
+ export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> = {
21
+ read: createReadTool,
22
+ write: createWriteTool,
23
+ edit: createEditTool,
24
+ bash: createBashTool,
25
+ grep: createGrepTool,
26
+ find: createFindTool,
27
+ ls: createLsTool,
28
+ };
29
+
30
+ /** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
31
+ * Unknown names pass through unchanged for the caller to validate.
32
+ * Returns a deduped list. */
33
+ export function resolveToolGroups(tools: string[]): string[] {
34
+ const resolved: string[] = [];
35
+ for (const t of tools) {
36
+ const group = TOOL_GROUPS[t];
37
+ if (group) resolved.push(...group);
38
+ else resolved.push(t);
39
+ }
40
+ return [...new Set(resolved)];
41
+ }
package/types.ts ADDED
@@ -0,0 +1,243 @@
1
+ import type {
2
+ ThinkingLevel,
3
+ AgentToolResult,
4
+ AgentToolUpdateCallback,
5
+ } from "@earendil-works/pi-agent-core";
6
+ import type { Api, Model, Usage } from "@earendil-works/pi-ai";
7
+ import type {
8
+ AgentSession,
9
+ ModelRegistry,
10
+ SessionManager,
11
+ SessionEntry,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import type { Static } from "@sinclair/typebox";
14
+ import type { delegateArgumentsSchema } from "./schema.ts";
15
+
16
+ export interface AgentConfig {
17
+ name: string;
18
+ description: string;
19
+ model?: string;
20
+ thinking: ThinkingLevel;
21
+ tools: string[];
22
+ systemPrompt: string;
23
+ /** Origin of the profile. `claude` denotes imported .claude/agents files. */
24
+ scope?: "project" | "global" | "claude";
25
+ }
26
+
27
+ // ── Tool parameter types — derived from the TypeBox schema ────────────────
28
+ // `delegateArgumentsSchema` in schema.ts is the single source of truth; these are
29
+ // projections of it, so schema and types cannot drift. Field semantics live
30
+ // in the schema's `description`s (which the calling model also sees).
31
+ // The import is type-only, so the schema.ts ↔ types.ts cycle is erased at
32
+ // compile time.
33
+
34
+ export type DelegateArguments = Static<typeof delegateArgumentsSchema>;
35
+ export type TaskDef = NonNullable<DelegateArguments["tasks"]>[number];
36
+ /** Top-level async ticket action: "poll" | "cancel" | "wait". */
37
+ export type DelegateAction = NonNullable<DelegateArguments["action"]>;
38
+ /** Per-task session action: "prompt" | "close" | "list". */
39
+ export type SessionAction = NonNullable<TaskDef["action"]>;
40
+
41
+ // ── Async Ticket Types ─────────────────────────────────────────────────────
42
+
43
+ export interface TicketWaiter {
44
+ signal?: AbortSignal;
45
+ onUpdate?: AgentToolUpdateCallback<DelegateDetails>;
46
+ resolve: (result: AgentToolResult<DelegateDetails>) => void;
47
+ reject: (reason: unknown) => void;
48
+ clearDeadline?: () => void;
49
+ settled: boolean;
50
+ }
51
+
52
+ export interface AsyncTicket {
53
+ id: string;
54
+ created: number;
55
+ completedAt?: number;
56
+ tasks: TaskDef[];
57
+ resolved: ResolvedTask[];
58
+ status: "running" | "cancelling" | "done" | "failed" | "cancelled";
59
+ results: (TaskResult | undefined)[];
60
+ progress: TaskProgress[];
61
+ controller: AbortController;
62
+ error?: string;
63
+ parentModelId?: string;
64
+ /** Active blocking waiters. Resolved by terminal delivery or timeout/abort. */
65
+ waiters?: TicketWaiter[];
66
+ }
67
+
68
+ export interface ReuseIntent {
69
+ /** Explicit model requested by this call/profile; omitted means use frozen. */
70
+ model?: Model<Api>;
71
+ /** Explicit base prompt requested by this call/profile; omitted means frozen. */
72
+ systemPrompt?: string;
73
+ }
74
+
75
+ export interface ResolvedTask {
76
+ prompt: string;
77
+ agent?: string;
78
+ model: Model<Api>;
79
+ tools: string[];
80
+ thinking: ThinkingLevel;
81
+ systemPrompt: string;
82
+ cwd: string;
83
+ context?: "fresh" | "with-parent-transcript";
84
+ sessionId?: string;
85
+ action?: SessionAction;
86
+ resumeFrom?: string;
87
+ agentName: string;
88
+ warnings: string[];
89
+ /** Explicit settings that must match a live pooled session on reuse. */
90
+ reuseIntent?: ReuseIntent;
91
+ }
92
+
93
+ export interface ToolActivity {
94
+ id: string;
95
+ name: string;
96
+ args: Record<string, unknown>;
97
+ result?: {
98
+ content: Array<{ type: string; text?: string }>;
99
+ isError: boolean;
100
+ };
101
+ startTime: number;
102
+ endTime?: number;
103
+ /** Live stdout/stderr preview from tool_execution_update events. */
104
+ liveOutput?: string;
105
+ }
106
+
107
+ /** Stable machine-readable reason for a task failure.
108
+ * - `stalled`: inactivity watchdog fired; the prompt was cooperatively aborted.
109
+ * - `model_error`: the failure is attributable to the resolved model/provider
110
+ * (account usage limit, quota exhausted, auth lost) — not transient for that
111
+ * model, so same-model retry is pointless. The parent should resume with a
112
+ * different `model` (see `resumeFrom` + `model`). */
113
+ export type TaskFailureKind = "stalled" | "model_error";
114
+
115
+ export interface TaskProgress {
116
+ index: number;
117
+ agent: string;
118
+ task: string;
119
+ status: "pending" | "running" | "done" | "failed";
120
+ durationMs: number;
121
+ tokens: number;
122
+ toolUses: number;
123
+ error?: string;
124
+ failureKind?: TaskFailureKind;
125
+ model?: string;
126
+ lastActivityAt?: number;
127
+ activities: ToolActivity[];
128
+ /** Human-facing notices (e.g. unknown tools ignored). Surfaced in the TUI
129
+ * under the task; the LLM gets the same text in `content` already. */
130
+ warnings?: string[];
131
+ }
132
+
133
+ export interface DelegateDetails {
134
+ tasks: TaskDef[];
135
+ results: (TaskResult | { error: string })[];
136
+ progress: TaskProgress[];
137
+ parentModel?: string;
138
+ ticketId?: string;
139
+ /** Terminal/live ticket status when this result comes from an async ticket. */
140
+ status?: AsyncTicket["status"];
141
+ }
142
+
143
+ export interface TaskResult {
144
+ agent: string;
145
+ output: string;
146
+ error?: string;
147
+ /** Stable machine-readable failure reason; error remains human-facing. */
148
+ failureKind?: TaskFailureKind;
149
+ durationMs: number;
150
+ /** Display token count for the task, derived from the compaction-inclusive
151
+ * session-stat delta. This matches `usage.totalTokens`; the usage object
152
+ * additionally preserves the provider breakdown and cost. */
153
+ tokens: number;
154
+ /** Full provider Usage consumed by this task, including compacted-away
155
+ * history. Always present (`emptyUsage()` on no-op/early-failure paths) so a
156
+ * sync delegate call can fold subagent spend into the parent's session
157
+ * total. Aggregate `cost.total` is accurate; the per-component cost fields
158
+ * stay 0 because `getSessionStats()` exposes only the aggregate cost — and
159
+ * Pi sums `cost.total` for nested usage anyway. */
160
+ usage: Usage;
161
+ sessionFile?: string;
162
+ touchedFiles: string[];
163
+ }
164
+
165
+ /** Single source of truth for a subagent's runtime configuration.
166
+ * Passed to `createAgentSession` as `model` / `thinkingLevel` / `tools` / `cwd`. */
167
+ export interface AgentRunConfig {
168
+ systemPrompt: string;
169
+ model: Model<Api>;
170
+ thinking: ThinkingLevel;
171
+ tools: string[];
172
+ cwd: string;
173
+ }
174
+
175
+ export interface AgentProgressUpdate {
176
+ tokens: number;
177
+ toolUses: number;
178
+ durationMs: number;
179
+ lastActivityAt?: number;
180
+ activities: ToolActivity[];
181
+ /** Set as soon as a terminal condition is detected, before cleanup settles. */
182
+ failureKind?: TaskFailureKind;
183
+ }
184
+
185
+ export interface TaskRunEnv {
186
+ /** Abort signal — parent's for sync, ticket's for async. May be undefined when no parent signal is available. */
187
+ signal: AbortSignal | undefined;
188
+ modelRegistry: ModelRegistry;
189
+ /** Parent session manager — used to link subagent sessions for /resume. */
190
+ parentSessionManager: { getSessionFile?(): string | undefined } | undefined;
191
+ /** Ticket id for busy-guard self-checks. undefined for sync. */
192
+ ticketId?: string;
193
+ /** When the delegate started. Used for close/list progress (elapsed time). */
194
+ delegateStartedAt: number;
195
+ /** Called for every progress update from runAgentSession. */
196
+ onProgress: (p: TaskProgress, u: AgentProgressUpdate) => void;
197
+ /** Called after every TaskProgress mutation (early-returns, completion). Sync uses this to fire onUpdate. */
198
+ onStatusChange?: () => void;
199
+ }
200
+
201
+ /** Structural subset of Pi's `ExtensionContext` used by delegate's
202
+ * task-resolution and dispatch modules. Kept loose so the orchestrator can
203
+ * pass the real ctx through without re-typing every Pi field. The
204
+ * `sessionManager` mirrors Pi's `ReadonlySessionManager` (not re-exported
205
+ * from the package index) — only the members delegate actually touches are
206
+ * listed. */
207
+ export interface DelegateToolCtx {
208
+ cwd: string;
209
+ model: Model<Api> | undefined;
210
+ modelRegistry: ModelRegistry;
211
+ sessionManager:
212
+ | {
213
+ getEntries(): SessionEntry[];
214
+ getLeafId(): string | null;
215
+ getSessionFile?(): string | undefined;
216
+ }
217
+ | undefined;
218
+ /** Optional hook Pi exposes for extensions to read the live system prompt. */
219
+ getSystemPrompt?: () => string | undefined;
220
+ }
221
+
222
+ /** Shape returned by the delegate tool's `execute`. Mirrors Pi's
223
+ * `AgentToolResult<DelegateDetails>` without depending on the generic.
224
+ * `usage` (sync dispatch only) is the aggregate subagent spend; Pi 0.81+
225
+ * persists it on the tool result and folds it into the parent's
226
+ * session/footer totals. Older hosts ignore it. Async tickets can't attach
227
+ * usage — their results arrive via a follow-up message with no usage slot. */
228
+ export interface DelegateToolResult {
229
+ content: Array<{ type: "text"; text: string }>;
230
+ details: DelegateDetails;
231
+ /** Aggregate subagent usage for sync dispatch. Pi reads this for session
232
+ * totals on 0.81+; harmless on older versions. */
233
+ usage?: Usage;
234
+ }
235
+
236
+ export interface AcquiredSession {
237
+ /** The live AgentSession — constructed once and reused across prompts (pool hits). */
238
+ session: AgentSession;
239
+ sessionManager: SessionManager | undefined;
240
+ sessionFile: string | undefined;
241
+ /** Fresh/resumed sessions are lifecycle-owned until a successful pool commit. */
242
+ lifecycleOwnsSession: boolean;
243
+ }
package/usage.ts ADDED
@@ -0,0 +1,121 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import type { Usage } from "@earendil-works/pi-ai";
3
+
4
+ /** Snapshot of the cumulative session usage fields we read for delta accounting. */
5
+ export interface SessionUsageSnapshot {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheWrite: number;
10
+ cost: number;
11
+ }
12
+
13
+ /** Return a zero-valued Usage suitable for an action that made no model call. */
14
+ export function emptyUsage(): Usage {
15
+ return {
16
+ input: 0,
17
+ output: 0,
18
+ cacheRead: 0,
19
+ cacheWrite: 0,
20
+ totalTokens: 0,
21
+ cost: {
22
+ input: 0,
23
+ output: 0,
24
+ cacheRead: 0,
25
+ cacheWrite: 0,
26
+ total: 0,
27
+ },
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Read cumulative provider usage from a live AgentSession.
33
+ *
34
+ * `getSessionStats()` covers history that has been compacted away (and, on
35
+ * newer Pi hosts, compaction/branch-summary calls), so this is the right
36
+ * snapshot for pooled or resumed sessions — reading `session.messages` alone
37
+ * would under-count once a compaction boundary is crossed.
38
+ */
39
+ export function snapshotSessionUsage(
40
+ session: Pick<AgentSession, "getSessionStats">,
41
+ ): SessionUsageSnapshot {
42
+ const stats = session.getSessionStats();
43
+ return {
44
+ input: stats.tokens.input,
45
+ output: stats.tokens.output,
46
+ cacheRead: stats.tokens.cacheRead,
47
+ cacheWrite: stats.tokens.cacheWrite,
48
+ cost: stats.cost,
49
+ };
50
+ }
51
+
52
+ function delta(after: number, before: number): number {
53
+ // A session can be replaced/branched by host code. Never report a negative
54
+ // billable amount if cumulative stats move backwards between snapshots.
55
+ return Math.max(0, after - before);
56
+ }
57
+
58
+ /** Convert a cumulative-snapshot delta into Pi's nested-tool Usage shape. */
59
+ export function usageDelta(
60
+ before: SessionUsageSnapshot,
61
+ after: SessionUsageSnapshot,
62
+ ): Usage {
63
+ const input = delta(after.input, before.input);
64
+ const output = delta(after.output, before.output);
65
+ const cacheRead = delta(after.cacheRead, before.cacheRead);
66
+ const cacheWrite = delta(after.cacheWrite, before.cacheWrite);
67
+ return {
68
+ input,
69
+ output,
70
+ cacheRead,
71
+ cacheWrite,
72
+ totalTokens: input + output + cacheRead + cacheWrite,
73
+ // SessionStats exposes the aggregate cost only; Pi's session/footer
74
+ // accounting reads cost.total for nested tool usage. The component fields
75
+ // stay zero rather than inventing a provider-specific split.
76
+ cost: {
77
+ input: 0,
78
+ output: 0,
79
+ cacheRead: 0,
80
+ cacheWrite: 0,
81
+ total: delta(after.cost, before.cost),
82
+ },
83
+ };
84
+ }
85
+
86
+ /** Add two Usage values, preserving optional provider breakdowns when present. */
87
+ export function addUsage(left: Usage, right: Usage): Usage {
88
+ const cacheWrite1h =
89
+ left.cacheWrite1h !== undefined || right.cacheWrite1h !== undefined
90
+ ? (left.cacheWrite1h ?? 0) + (right.cacheWrite1h ?? 0)
91
+ : undefined;
92
+ const reasoning =
93
+ left.reasoning !== undefined || right.reasoning !== undefined
94
+ ? (left.reasoning ?? 0) + (right.reasoning ?? 0)
95
+ : undefined;
96
+
97
+ return {
98
+ input: left.input + right.input,
99
+ output: left.output + right.output,
100
+ cacheRead: left.cacheRead + right.cacheRead,
101
+ cacheWrite: left.cacheWrite + right.cacheWrite,
102
+ ...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
103
+ ...(reasoning === undefined ? {} : { reasoning }),
104
+ totalTokens: left.totalTokens + right.totalTokens,
105
+ cost: {
106
+ input: left.cost.input + right.cost.input,
107
+ output: left.cost.output + right.cost.output,
108
+ cacheRead: left.cost.cacheRead + right.cost.cacheRead,
109
+ cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
110
+ total: left.cost.total + right.cost.total,
111
+ },
112
+ };
113
+ }
114
+
115
+ /** Sum optional per-task usage without making callers manufacture zero values. */
116
+ export function sumUsage(usages: readonly (Usage | undefined)[]): Usage {
117
+ return usages.reduce<Usage>(
118
+ (total, usage) => (usage ? addUsage(total, usage) : total),
119
+ emptyUsage(),
120
+ );
121
+ }
package/utils.ts ADDED
@@ -0,0 +1,131 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
4
+
5
+ /** Resolve a path relative to the caller's working directory. Tilde paths and
6
+ * absolute paths intentionally ignore `baseCwd`. */
7
+ export function resolveCwd(cwd: string, baseCwd = process.cwd()): string {
8
+ const expanded = cwd.startsWith("~")
9
+ ? path.join(os.homedir(), cwd.slice(1))
10
+ : cwd;
11
+ return path.isAbsolute(expanded)
12
+ ? path.resolve(expanded)
13
+ : path.resolve(baseCwd, expanded);
14
+ }
15
+
16
+ /** Validate the absolute `.jsonl` path accepted by `resumeFrom`. */
17
+ export function validateResumeFromPath(resumeFrom: string): string | undefined {
18
+ if (!resumeFrom.trim()) {
19
+ return "expected an absolute .jsonl session file path copied from delegate retry output";
20
+ }
21
+
22
+ if (!resumeFrom.endsWith(".jsonl")) {
23
+ return "expected an absolute .jsonl session file path copied from delegate retry output";
24
+ }
25
+
26
+ const expanded = resumeFrom.startsWith("~")
27
+ ? path.join(os.homedir(), resumeFrom.slice(1))
28
+ : resumeFrom;
29
+ if (!path.isAbsolute(expanded)) {
30
+ return "expected an absolute .jsonl session file path copied from delegate retry output";
31
+ }
32
+
33
+ return undefined;
34
+ }
35
+
36
+ /** Extract text content from a partial tool result (tool_execution_update). */
37
+ export function extractTextFromPartialResult(
38
+ partialResult: unknown,
39
+ ): string | undefined {
40
+ if (
41
+ !partialResult ||
42
+ typeof partialResult !== "object" ||
43
+ !("content" in partialResult)
44
+ )
45
+ return undefined;
46
+ const content = (partialResult as { content?: unknown }).content;
47
+ if (!Array.isArray(content)) return undefined;
48
+ const text = content
49
+ .filter(
50
+ (c): c is { type: string; text?: string } =>
51
+ c && typeof c === "object" && "type" in c && c.type === "text",
52
+ )
53
+ .map((c) => c.text)
54
+ .filter((t): t is string => typeof t === "string")
55
+ .join("\n");
56
+ return text || undefined;
57
+ }
58
+
59
+ /** Strip ANSI escape sequences from text. */
60
+ export function stripAnsi(text: string): string {
61
+ // eslint-disable-next-line no-control-regex
62
+ return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
63
+ }
64
+
65
+ /** Resolve carriage-return progress bars to their final line state. */
66
+ export function resolveCarriageReturn(text: string): string {
67
+ return text
68
+ .split("\n")
69
+ .map((line) => {
70
+ const parts = line.split("\r");
71
+ return parts[parts.length - 1] ?? "";
72
+ })
73
+ .join("\n");
74
+ }
75
+
76
+ /** Concatenate text blocks from assistant messages in a session slice. */
77
+ export function extractOutput(messages: AgentMessage[]): string {
78
+ const parts: string[] = [];
79
+ for (const msg of messages) {
80
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
81
+ for (const block of msg.content) {
82
+ if (block.type === "text" && block.text) parts.push(block.text);
83
+ }
84
+ }
85
+ return parts.join("\n\n");
86
+ }
87
+
88
+ interface UsageLike {
89
+ input?: number;
90
+ output?: number;
91
+ cacheRead?: number;
92
+ total?: number;
93
+ totalTokens?: number;
94
+ }
95
+
96
+ function isUsageLike(u: unknown): u is UsageLike {
97
+ if (u === null || typeof u !== "object") return false;
98
+ const r = u as Record<string, unknown>;
99
+ const numericKeys: (keyof UsageLike)[] = [
100
+ "input",
101
+ "output",
102
+ "cacheRead",
103
+ "total",
104
+ "totalTokens",
105
+ ];
106
+ let hasNumeric = false;
107
+ for (const k of numericKeys) {
108
+ const v = r[k as string];
109
+ if (v !== undefined) {
110
+ if (typeof v !== "number" || !Number.isFinite(v)) return false;
111
+ hasNumeric = true;
112
+ }
113
+ }
114
+ return hasNumeric;
115
+ }
116
+
117
+ /** Sum finite usage fields from assistant messages. */
118
+ export function extractUsage(messages: AgentMessage[]) {
119
+ const usage = { input: 0, output: 0, cacheRead: 0, total: 0 };
120
+ for (const msg of messages) {
121
+ if (msg.role !== "assistant") continue;
122
+ const rawUsage: unknown = msg.usage;
123
+ if (!isUsageLike(rawUsage)) continue;
124
+ const u = rawUsage;
125
+ usage.input += u.input ?? 0;
126
+ usage.output += u.output ?? 0;
127
+ usage.cacheRead += u.cacheRead ?? 0;
128
+ usage.total += u.total ?? u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0);
129
+ }
130
+ return usage;
131
+ }