@cat-factory/executor-harness 1.64.4 → 1.68.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/src/job.ts CHANGED
@@ -12,6 +12,16 @@ import {
12
12
  type ReproductionReport,
13
13
  type ReproductionSpec,
14
14
  } from './reproduction-proof.js'
15
+ import {
16
+ parseMcpServerSpecs,
17
+ parseSkillSpecs,
18
+ type McpServerSpec,
19
+ type SkillResourceSpec,
20
+ type SkillSpec,
21
+ } from './agent-capabilities.js'
22
+
23
+ // Re-exported so the job body stays the one import site for a harness handler describing a job.
24
+ export type { McpServerSpec, SkillResourceSpec, SkillSpec }
15
25
 
16
26
  // The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
17
27
  // types with a hand-rolled validator so the image needs no schema dependency.
@@ -632,26 +642,6 @@ export interface ContextFileSpec {
632
642
  content: string
633
643
  }
634
644
 
635
- /** One materialisable resource file of a skill (repo-sourced Claude Skills). */
636
- export interface SkillResourceSpec {
637
- /** Path within the skill directory, e.g. `templates/report.md` (subdirs preserved, no traversal). */
638
- relPath: string
639
- content: string
640
- }
641
-
642
- /**
643
- * A repo-sourced Claude Skill to make available for a `skill` step. Materialised HARNESS-AWARE:
644
- * `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resources) for the claude-code CLI to load
645
- * natively, or `.cat-context/skill/<relPath>` for the Pi/codex checkout (their prompt carries the
646
- * instructions). A dedicated top-level body field (like `packageRegistries`), never a context file.
647
- */
648
- export interface SkillSpec {
649
- name: string
650
- description: string
651
- instructions: string
652
- resources: SkillResourceSpec[]
653
- }
654
-
655
645
  /** How an explore agent's reply is consumed. */
656
646
  export interface AgentOutputSpec {
657
647
  /** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
@@ -739,11 +729,20 @@ export interface AgentJob extends HarnessAuthFields {
739
729
  */
740
730
  packageRegistries?: PackageRegistrySpec[]
741
731
  /**
742
- * A repo-sourced Claude Skill to make available for a `skill` step (see {@link SkillSpec}).
743
- * Materialised harness-aware before the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/`
744
- * for claude-code, or `.cat-context/skill/<relPath>` for Pi/codex. Absent ⇒ no skill installed.
732
+ * The skills to make available for this run (see {@link SkillSpec}) — a `skill` step's picked
733
+ * skill and/or the playbooks the running agent kind declares. Materialised harness-aware before
734
+ * the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/` for claude-code, or
735
+ * `.cat-context/skill/<name>/<relPath>` for Pi/codex. Absent ⇒ no skills installed.
745
736
  */
746
- skill?: SkillSpec
737
+ skills?: SkillSpec[]
738
+ /**
739
+ * Tool servers (MCP) to wire into the agent CLI for this run (see {@link McpServerSpec}). The
740
+ * backend has already dropped anything this harness cannot serve, so every entry here is
741
+ * expected to work. SECRET-BEARING (`env`/`headers` carry resolved credentials), so the config
742
+ * files written from it live outside the checkout and are never logged. Absent ⇒ the CLI's
743
+ * built-in tools only.
744
+ */
745
+ mcpServers?: McpServerSpec[]
747
746
  /**
748
747
  * Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
749
748
  * band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
@@ -942,6 +941,12 @@ export interface AgentResult {
942
941
  exitCode: number
943
942
  validationOutputTail?: string
944
943
  iteration?: number
944
+ /**
945
+ * The work-branch HEAD the command was judged against. The engine compares it across
946
+ * consecutive failing iterations to end a loop that has stopped committing anything,
947
+ * instead of spending the rest of its budget re-learning that. Absent when unreadable.
948
+ */
949
+ headSha?: string
945
950
  }
946
951
  /**
947
952
  * Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
@@ -1029,71 +1034,6 @@ function parseContextFiles(value: unknown): ContextFileSpec[] {
1029
1034
  return files
1030
1035
  }
1031
1036
 
1032
- /**
1033
- * Sanitize a skill resource's relative path: keep the subdirectory structure (so
1034
- * `templates/report.md` materialises nested) but reject anything that could escape the skill
1035
- * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
1036
- * for an unsafe path (the resource is then dropped).
1037
- */
1038
- function sanitizeSkillRelPath(value: unknown): string | undefined {
1039
- if (typeof value !== 'string') return undefined
1040
- const segments = value.replace(/\\/g, '/').split('/')
1041
- const clean: string[] = []
1042
- for (const seg of segments) {
1043
- if (seg === '' || seg === '.') continue
1044
- if (seg === '..') return undefined
1045
- // Same character class as a context-file name, per segment.
1046
- const c = seg.replace(/[^A-Za-z0-9._-]/g, '')
1047
- if (!c || c === '.' || c === '..' || c.startsWith('.')) return undefined
1048
- clean.push(c)
1049
- }
1050
- return clean.length ? clean.join('/') : undefined
1051
- }
1052
-
1053
- /**
1054
- * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
1055
- * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
1056
- * default keeps the skill installable rather than dropping it — which, on the claude-code path,
1057
- * would leave the prompt pointing at a skill that was never installed (a blind run).
1058
- */
1059
- const FALLBACK_SKILL_NAME = 'skill'
1060
-
1061
- /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
1062
- function sanitizeSkillName(value: unknown): string | undefined {
1063
- if (typeof value !== 'string') return undefined
1064
- const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
1065
- const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
1066
- if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
1067
- return cleaned
1068
- }
1069
-
1070
- /** Validate the optional `skill` field, or undefined when absent/malformed. */
1071
- function parseSkillSpec(value: unknown): SkillSpec | undefined {
1072
- if (typeof value !== 'object' || value === null) return undefined
1073
- const o = value as Record<string, unknown>
1074
- const instructions = typeof o.instructions === 'string' ? o.instructions : undefined
1075
- // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
1076
- // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
1077
- // directory, so fall back to a safe default rather than dropping the whole skill.
1078
- if (!instructions) return undefined
1079
- const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME
1080
- const description = typeof o.description === 'string' ? o.description : ''
1081
- const resources: SkillResourceSpec[] = []
1082
- if (Array.isArray(o.resources)) {
1083
- const used = new Set<string>()
1084
- for (const entry of o.resources) {
1085
- if (typeof entry !== 'object' || entry === null) continue
1086
- const e = entry as Record<string, unknown>
1087
- const relPath = sanitizeSkillRelPath(e.relPath)
1088
- if (!relPath || used.has(relPath)) continue
1089
- if (typeof e.content !== 'string') continue
1090
- used.add(relPath)
1091
- resources.push({ relPath, content: e.content })
1092
- }
1093
- }
1094
- return { name, description, instructions, resources }
1095
- }
1096
-
1097
1037
  /** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
1098
1038
  function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
1099
1039
  if (typeof value !== 'object' || value === null) return undefined
@@ -1251,7 +1191,20 @@ export interface InlineResult {
1251
1191
  text: string
1252
1192
  /** `length` when the model hit its output cap (the reviewer rejects a truncated doc). */
1253
1193
  finishReason?: 'stop' | 'length'
1254
- usage?: { inputTokens: number; outputTokens: number }
1194
+ /**
1195
+ * The job's token usage with the input side split into its three ORTHOGONAL classes:
1196
+ * `inputTokens` is FRESH input only, so the total input is
1197
+ * `inputTokens + cacheReadTokens + cacheWriteTokens`. Folded from the per-call metrics below,
1198
+ * which is the only channel that knows the split; a CLI that streamed none falls back to the
1199
+ * coarse total with both cache classes 0 — honest, since on that shape nothing is known to
1200
+ * have been cached.
1201
+ */
1202
+ usage?: {
1203
+ inputTokens: number
1204
+ cacheReadTokens: number
1205
+ cacheWriteTokens: number
1206
+ outputTokens: number
1207
+ }
1255
1208
  /** Per-model-call telemetry lifted from the CLI stream (recorded into `llm_call_metrics`). */
1256
1209
  callMetrics?: HarnessCallMetric[]
1257
1210
  /** A structured failure marks a job-level failure even on a clean HTTP exit (see JobResultBase). */
@@ -1322,7 +1275,8 @@ export function parseAgentJob(input: unknown): AgentJob {
1322
1275
  bootstrap: parseAgentBootstrapSpec(o.bootstrap),
1323
1276
  contextFiles: parseContextFiles(o.contextFiles),
1324
1277
  packageRegistries: parsePackageRegistries(o.packageRegistries),
1325
- skill: parseSkillSpec(o.skill),
1278
+ skills: parseSkillSpecs(o.skills),
1279
+ mcpServers: parseMcpServerSpecs(o.mcpServers),
1326
1280
  testSecrets: parseTestSecrets(o.testSecrets),
1327
1281
  guardLimits: parseGuardLimits(o.guardLimits),
1328
1282
  validation: parseValidationSpec(o.validation),
@@ -1361,7 +1315,8 @@ interface ParsedAgentJobParts {
1361
1315
  bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
1362
1316
  contextFiles: ReturnType<typeof parseContextFiles>
1363
1317
  packageRegistries: ReturnType<typeof parsePackageRegistries>
1364
- skill: ReturnType<typeof parseSkillSpec>
1318
+ skills: ReturnType<typeof parseSkillSpecs>
1319
+ mcpServers: ReturnType<typeof parseMcpServerSpecs>
1365
1320
  testSecrets: ReturnType<typeof parseTestSecrets>
1366
1321
  guardLimits: ReturnType<typeof parseGuardLimits>
1367
1322
  validation: ReturnType<typeof parseValidationSpec>
@@ -1415,7 +1370,8 @@ function assembleAgentJob(
1415
1370
  bootstrap,
1416
1371
  contextFiles,
1417
1372
  packageRegistries,
1418
- skill,
1373
+ skills,
1374
+ mcpServers,
1419
1375
  testSecrets,
1420
1376
  guardLimits,
1421
1377
  validation,
@@ -1439,7 +1395,8 @@ function assembleAgentJob(
1439
1395
  ...(output ? { output } : {}),
1440
1396
  ...(contextFiles.length ? { contextFiles } : {}),
1441
1397
  ...(packageRegistries.length ? { packageRegistries } : {}),
1442
- ...(skill ? { skill } : {}),
1398
+ ...(skills ? { skills } : {}),
1399
+ ...(mcpServers ? { mcpServers } : {}),
1443
1400
  ...(testSecrets.length ? { testSecrets } : {}),
1444
1401
  ...(infra ? { infra } : {}),
1445
1402
  ...(pr ? { pr } : {}),
@@ -1,7 +1,8 @@
1
1
  import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
- import type { RepoSpec, SkillSpec } from './job.js'
4
+ import type { RepoSpec } from './job.js'
5
+ import type { McpServerSpec, SkillSpec } from './agent-capabilities.js'
5
6
  import { readEffortReport } from './effort.js'
6
7
  import { log } from './logger.js'
7
8
  import {
@@ -206,12 +207,20 @@ export interface AgentRunSpec {
206
207
  */
207
208
  contextFiles?: ContextFileInfo[]
208
209
  /**
209
- * A repo-sourced Claude Skill to make available for this run (slice 2). Installed HARNESS-AWARE:
210
- * the claude-code runner writes it natively into the config dir's `skills/`; for Pi/codex the
211
- * resource files are materialised under `.cat-context/skill/` (their prompt already carries the
212
- * folded-in instructions). Absent ⇒ no skill.
210
+ * The skills to make available for this run a `skill` step's picked skill and/or the playbooks
211
+ * the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
212
+ * natively into the config dir's `skills/`; for Pi/codex the resource files are materialised
213
+ * under `.cat-context/skill/<name>/` (their prompt already carries the folded-in instructions).
214
+ * Absent ⇒ no skills.
213
215
  */
214
- skill?: SkillSpec
216
+ skills?: SkillSpec[]
217
+ /**
218
+ * Tool servers (MCP) to wire into the agent CLI. Served by the subscription harnesses only —
219
+ * Pi has no MCP client, and the BACKEND is what decides that (it drops an unservable server and
220
+ * tells the agent so), which is why this path simply forwards whatever it is given rather than
221
+ * re-deciding. Absent ⇒ the CLI's built-in tools only.
222
+ */
223
+ mcpServers?: McpServerSpec[]
215
224
  /**
216
225
  * Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
217
226
  * backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
@@ -268,14 +277,14 @@ export async function runAgentInWorkspace(
268
277
  // harness paths; kept out of the agent's commits via a local git exclude entry.
269
278
  const contextFiles = spec.contextFiles ?? []
270
279
  await materializeContextFiles(spec.dir, contextFiles)
271
- // Repo-sourced skill (slice 2): claude-code installs it natively into its ISOLATED config dir,
272
- // so it reads from there. Everything else reads the checkout, so materialise the skill's
273
- // resources under `.cat-context/skill/` (its instructions are folded into the prompt by the
274
- // backend) — Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install
275
- // into (the runner refuses to write a repo's skill into the developer's own `~/.claude`; see
276
- // `runClaudeCode`). A resource-free skill is a no-op here.
277
- if (spec.skill && !installsSkillNatively(spec)) {
278
- await materializeSkillResources(spec.dir, spec.skill)
280
+ // Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
281
+ // there. Everything else reads the checkout, so materialise each skill's resources under
282
+ // `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
283
+ // Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
284
+ // runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
285
+ // Resource-free skills are a no-op here.
286
+ if (spec.skills?.length && !installsSkillNatively(spec)) {
287
+ await materializeSkillResources(spec.dir, spec.skills)
279
288
  }
280
289
 
281
290
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
@@ -296,7 +305,8 @@ export async function runAgentInWorkspace(
296
305
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
297
306
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
298
307
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
299
- ...(spec.skill ? { skill: spec.skill } : {}),
308
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
309
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
300
310
  ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
301
311
  signal: opts.signal,
302
312
  // Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
@@ -367,12 +377,12 @@ export async function runAgentInWorkspace(
367
377
  }
368
378
 
369
379
  /**
370
- * Whether the claude-code runner will install this run's repo-sourced skill natively (into the
371
- * CLI's config dir) rather than the caller materialising it into the checkout. True ONLY for a
380
+ * Whether the claude-code runner will install this run's skills natively (into the CLI's config
381
+ * dir) rather than the caller materialising them into the checkout. True ONLY for a
372
382
  * leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
373
- * uses the developer's own `~/.claude`, which the runner will not write a repo's skill into —
374
- * it would outlive the run in their personal setup, and two concurrent jobs carrying same-named
375
- * skills from different repos would overwrite each other's.
383
+ * uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
384
+ * outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
385
+ * would overwrite each other's.
376
386
  */
377
387
  export function installsSkillNatively(
378
388
  spec: Pick<AgentRunSpec, 'harness' | 'ambientAuth'>,
package/src/pi.ts CHANGED
@@ -253,29 +253,37 @@ export async function materializeContextFiles(
253
253
  }
254
254
  }
255
255
 
256
- /** Subdirectory of {@link CONTEXT_DIR} where a repo-sourced skill's resources are materialised. */
256
+ /** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
257
257
  export const SKILL_CONTEXT_SUBDIR = 'skill'
258
258
 
259
259
  /**
260
- * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
261
- * (repo-sourced Claude Skills, slice 2) — the path for every run that does NOT get a native
262
- * install: Pi, codex, and ambient claude-code (no isolated `CLAUDE_CONFIG_DIR` to install into).
263
- * Their agents read the checkout, and the skill's instructions are folded into their prompt by the
264
- * backend (`renderSkillForHarness`, which keys off ambient auth as well as the harness). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
265
- * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
266
- * exclude entry. A skill with no resource bodies is a no-op.
260
+ * Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
261
+ * — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
262
+ * (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
263
+ * skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
264
+ * which keys off ambient auth as well as the harness).
265
+ *
266
+ * Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
267
+ * the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
268
+ * overwrite each other — silently handing the agent the wrong template. The names were sanitized
269
+ * to a single safe path segment at the job boundary, as were the resource sub-paths (no
270
+ * traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
271
+ * `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
267
272
  */
268
273
  export async function materializeSkillResources(
269
274
  cwd: string,
270
- skill: { resources: { relPath: string; content: string }[] },
275
+ skills: { name: string; resources: { relPath: string; content: string }[] }[],
271
276
  ): Promise<void> {
272
- if (!skill.resources.length) return
273
- const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR)
274
- await mkdir(dir, { recursive: true })
275
- for (const r of skill.resources) {
276
- const dest = join(dir, r.relPath)
277
- await mkdir(dirname(dest), { recursive: true })
278
- await writeFile(dest, r.content, 'utf8')
277
+ const withResources = skills.filter((s) => s.resources.length)
278
+ if (!withResources.length) return
279
+ for (const skill of withResources) {
280
+ const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name)
281
+ await mkdir(dir, { recursive: true })
282
+ for (const r of skill.resources) {
283
+ const dest = join(dir, r.relPath)
284
+ await mkdir(dirname(dest), { recursive: true })
285
+ await writeFile(dest, r.content, 'utf8')
286
+ }
279
287
  }
280
288
  const gitRoot = await findGitRoot(cwd)
281
289
  if (!gitRoot) return
@@ -509,8 +517,21 @@ export interface HarnessCallMetric {
509
517
  responseText: string
510
518
  /** The reasoning/thinking trace, as a plain string (`''` when none). */
511
519
  reasoningText: string
520
+ /**
521
+ * FRESH (uncached) input tokens: exclusive of BOTH cache classes below, so the three
522
+ * are orthogonal and additive. Every producer normalises to this — reading the already
523
+ * exclusive field where the vendor reports the classes apart (Anthropic), subtracting
524
+ * the cached share where the vendor reports an inclusive prompt count (Codex/OpenAI).
525
+ */
512
526
  inputTokens: number
513
- cachedInputTokens: number
527
+ /** Input tokens served from the vendor's prompt cache (~0.1× base input). */
528
+ cacheReadTokens: number
529
+ /**
530
+ * Input tokens written INTO the vendor's cache (1.25–2× base input — dearer than fresh),
531
+ * kept apart from the reads so a loop that keeps re-writing the prefix is distinguishable
532
+ * from one riding a warm cache. 0 where the CLI reports no separate write class.
533
+ */
534
+ cacheWriteTokens: number
514
535
  outputTokens: number
515
536
  /** The provider finish/stop reason when the CLI reports one (else null). */
516
537
  finishReason: string | null
package/src/subagents.ts CHANGED
@@ -236,7 +236,16 @@ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions)
236
236
  if (event.type !== 'assistant' || !isObject(event.message)) return
237
237
  const message = event.message as Record<string, unknown>
238
238
  const u = claudeCallUsage(message.usage)
239
- if (u.inputTokens === 0 && u.outputTokens === 0) return
239
+ // Every input class counts towards "did this turn report usage at all": a turn riding a
240
+ // warm cache legitimately reports 0 fresh input, and skipping it would drop precisely the
241
+ // cache-heavy calls this telemetry exists to weigh.
242
+ if (
243
+ u.inputTokens === 0 &&
244
+ u.cacheReadTokens === 0 &&
245
+ u.cacheWriteTokens === 0 &&
246
+ u.outputTokens === 0
247
+ )
248
+ return
240
249
  const content = Array.isArray(message.content) ? message.content : []
241
250
  const { text, reasoning } = claudeAssistantContent(content)
242
251
  publishCallMetric(
@@ -254,13 +263,18 @@ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions)
254
263
  responseText: redactBody(text, secrets),
255
264
  reasoningText: redactBody(reasoning, secrets),
256
265
  inputTokens: u.inputTokens,
257
- cachedInputTokens: u.cachedInputTokens,
266
+ cacheReadTokens: u.cacheReadTokens,
267
+ cacheWriteTokens: u.cacheWriteTokens,
258
268
  outputTokens: u.outputTokens,
259
269
  finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
260
270
  },
261
271
  opts.onCallMetric,
262
272
  )
263
- usage.inputTokens += u.inputTokens
273
+ // The run-level `usage` is the COARSE rotation-window weight, which counts every billed
274
+ // input bucket — unlike the per-call metric above, whose `inputTokens` is fresh-only. Sum
275
+ // all three classes back together here or a cache-heavy subagent looks nearly free to the
276
+ // rotation.
277
+ usage.inputTokens += u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens
264
278
  usage.outputTokens += u.outputTokens
265
279
  }
266
280